Skip to main content

studio_worker/ui/
app.rs

1//! The eframe `App`: a client of the daemon.  It renders the [`Replica`]
2//! the poller keeps fresh inside the window's chrome (rail, pulse header,
3//! status bar) and sends the operator's actions through the
4//! [`ActionRunner`]; it never runs a job itself.
5
6use std::{
7    path::PathBuf,
8    sync::{atomic::Ordering, Arc},
9    time::Duration,
10};
11
12use eframe::egui;
13use parking_lot::Mutex;
14use tokio::runtime::Handle;
15
16use crate::{
17    daemon_api::EditableConfig,
18    daemon_link::{Action, LinkState, Replica},
19    runtime::HEARTBEAT_INTERVAL,
20};
21
22use super::{
23    actions::ActionRunner,
24    chrome,
25    notifier::{decide, NotificationPrefs, Notifier, NotifyDecision},
26    page::Page,
27    pages::{
28        config::{self as config_page, ConfigDraft},
29        jobs::{self as jobs_page, JobsContext, JobsState},
30        logs::{self as logs_page, LogFilter},
31        models::{self as models_page, ModelAction, ModelsView},
32        worker::{self as worker_page, AboutState, UpdateFeed, WorkerAction},
33    },
34    prefs::{self, UiPrefs},
35    pulse::{Pulse, PulseInputs},
36    theme::{self, Palette, ThemeChoice},
37    tray::{self, TrayVariant},
38};
39
40/// Tracing target for App-level lifecycle + tray events.  Stable so
41/// operators can filter with `RUST_LOG=studio_worker::ui::app=info`.
42const TRACE_TARGET: &str = "studio_worker::ui::app";
43
44/// How often the window repaints while running work glows (≈ 20 fps).
45pub const GLOW_FRAME: Duration = Duration::from_millis(50);
46/// How often it repaints otherwise, so durations tick.
47pub const IDLE_FRAME: Duration = Duration::from_millis(500);
48/// Widest the text-heavy pages grow, in points: lines stay readable.
49pub const READING_WIDTH: f32 = 1080.0;
50
51/// Emit a structured breadcrumb when the tray health indicator flips
52/// between idle / busy / disconnected.  Pulled out of
53/// [`App::refresh_tray_variant`] so it is unit-testable without
54/// constructing a (non-`Send`) `App` + a real OS tray.
55fn log_tray_variant_change(from: TrayVariant, to: TrayVariant) {
56    tracing::info!(
57        target: TRACE_TARGET,
58        op = "tray_variant",
59        from = ?from,
60        to = ?to,
61        "tray status indicator changed"
62    );
63}
64
65/// The tray colour for the daemon's state: disconnected whenever the link
66/// is down, else derived from busy + heartbeat.
67pub fn tray_variant_for(link: &LinkState, replica: &Replica) -> TrayVariant {
68    if !link.is_connected() {
69        return TrayVariant::Disconnected;
70    }
71    let busy =
72        replica.busy.load(Ordering::SeqCst) || !replica.observers.active_jobs.lock().is_empty();
73    let hb = replica.observers.last_heartbeat.lock().clone();
74    tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL)
75}
76
77/// The pulse the header shows, from the replica.
78pub fn pulse_of(link: &LinkState, replica: &Replica, now: chrono::DateTime<chrono::Utc>) -> Pulse {
79    let registration = replica.registration.lock().clone();
80    let session = replica.observers.session_state.lock().clone();
81    let active = replica.observers.active_jobs.lock().clone();
82    let models = replica.models.lock().clone();
83    let vram_total_gb = replica
84        .status
85        .lock()
86        .as_ref()
87        .map_or(0.0, |s| s.vram_total_gb);
88    Pulse::build(PulseInputs {
89        link,
90        registered: replica.registered(),
91        registration: &registration,
92        session: &session,
93        busy: replica.busy.load(Ordering::SeqCst),
94        paused: replica.paused.load(Ordering::SeqCst),
95        active: &active,
96        models: &models,
97        vram_total_gb,
98        now,
99    })
100}
101
102/// Everything `App` needs to render and act on the world.
103pub struct AppDeps {
104    pub replica: Replica,
105    /// Minimise the window on its first frame (the config's
106    /// `start_minimised`, read before the daemon answers).
107    pub start_minimised: bool,
108    pub actions: ActionRunner,
109    pub config_path: PathBuf,
110    pub tokio: Handle,
111}
112
113/// A config save on its way to the daemon.
114type PendingSave = Arc<Mutex<Option<Result<EditableConfig, String>>>>;
115
116pub struct App {
117    deps: AppDeps,
118    page: Page,
119    config_draft: ConfigDraft,
120    pending_save: PendingSave,
121    log_filter: LogFilter,
122    about_state: AboutState,
123    jobs: JobsState,
124    /// `STUDIO_WORKER_UI_JOB`: a job to select once it shows up.
125    initial_job: Option<String>,
126    /// Identity (`job_id` + `finished_at`) of the newest recent-job we
127    /// have already raised a notification for.  Tracking identity
128    /// rather than ring length means a saturated, capped
129    /// `recent_jobs` ring (whose length pins at `RECENT_JOBS_CAP`)
130    /// can't make new arrivals invisible.
131    last_notified: Option<(String, chrono::DateTime<chrono::Utc>)>,
132    notifier: Box<dyn Notifier + Send + Sync>,
133    /// The window's own preferences, stored in `ui.toml`.
134    prefs: UiPrefs,
135    prefs_path: PathBuf,
136    /// The theme last handed to egui; `None` until the first frame.
137    applied_theme: Option<ThemeChoice>,
138    tray_variant: TrayVariant,
139    quit_requested: Arc<std::sync::atomic::AtomicBool>,
140    /// Quit is under way: the daemon was told to stop, the window closes.
141    quitting: bool,
142    tray: Option<super::tray_host::TrayHandle>,
143    /// One-shot request to minimise the window on the first frame
144    /// (config `start_minimised`, default true).  Minimised to the
145    /// taskbar — not hidden — so the window stays reachable even when
146    /// no tray host is available.
147    start_minimised_pending: bool,
148}
149
150impl App {
151    pub fn new(deps: AppDeps) -> Self {
152        Self::with_notifier(deps, Self::default_notifier_box())
153    }
154
155    /// Used by tests to inject a `CapturingNotifier`.
156    pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
157        let config_draft = ConfigDraft::from(&deps.replica.cfg.lock());
158        let start_minimised_pending = deps.start_minimised;
159        let prefs_path = prefs::path_for(&deps.config_path);
160        let prefs = prefs::load(&prefs_path);
161        Self {
162            deps,
163            page: Page::initial(),
164            config_draft,
165            pending_save: Arc::default(),
166            log_filter: LogFilter::default(),
167            about_state: AboutState::default(),
168            jobs: JobsState::default(),
169            initial_job: std::env::var("STUDIO_WORKER_UI_JOB").ok(),
170            last_notified: None,
171            notifier,
172            prefs,
173            prefs_path,
174            applied_theme: None,
175            tray_variant: TrayVariant::Disconnected,
176            quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
177            quitting: false,
178            tray: None,
179            start_minimised_pending,
180        }
181    }
182
183    /// Whether the first frame will request a minimised window.
184    pub fn start_minimised_pending(&self) -> bool {
185        self.start_minimised_pending
186    }
187
188    pub fn attach_tray(&mut self, tray: super::tray_host::TrayHandle) {
189        self.tray = Some(tray);
190    }
191
192    pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
193        self.quit_requested.clone()
194    }
195
196    pub fn notification_prefs(&self) -> NotificationPrefs {
197        self.prefs.notifications()
198    }
199
200    pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
201        self.prefs.notify_on_completion = prefs.on_completion;
202        self.prefs.notify_on_failure = prefs.on_failure;
203    }
204
205    pub fn prefs(&self) -> UiPrefs {
206        self.prefs
207    }
208
209    pub fn tray_variant(&self) -> TrayVariant {
210        self.tray_variant
211    }
212
213    /// Exposed for `ui::run` which builds a notifier before App::new.
214    pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
215        Box::new(super::notifier::DesktopNotifier)
216    }
217
218    /// Process any new entries in the recent-jobs ring and emit
219    /// notifications according to current prefs.  Idempotent.
220    pub fn drain_notifications(&mut self) {
221        // `recent_jobs` is newest-first, so walk from the front collecting
222        // every entry newer than the last one we notified on (identified
223        // by `job_id` + `finished_at`).
224        let new_entries: Vec<_> = {
225            let ring = self.deps.replica.observers.recent_jobs.lock();
226            let mut collected = Vec::new();
227            for entry in ring.iter() {
228                if self
229                    .last_notified
230                    .as_ref()
231                    .is_some_and(|(id, ts)| entry.job_id == *id && entry.finished_at == *ts)
232                {
233                    break;
234                }
235                collected.push(entry.clone());
236            }
237            collected
238        };
239        if let Some(newest) = new_entries.first() {
240            self.last_notified = Some((newest.job_id.clone(), newest.finished_at));
241        }
242        // Notify oldest-first so the OS order matches completion order.
243        let prefs = self.notification_prefs();
244        for entry in new_entries.into_iter().rev() {
245            if let NotifyDecision::Show { title, body } = decide(prefs, &entry) {
246                self.notifier.show(&title, &body);
247            }
248        }
249    }
250
251    /// Recompute the tray variant from live state.  Pushes the new
252    /// icon + tooltip to the OS tray when the variant changes.
253    pub fn refresh_tray_variant(&mut self) -> TrayVariant {
254        let link = self.deps.replica.link.lock().clone();
255        let v = tray_variant_for(&link, &self.deps.replica);
256        if v != self.tray_variant {
257            log_tray_variant_change(self.tray_variant, v);
258            if let Some(tray) = self.tray.as_mut() {
259                tray.set_variant(v);
260            }
261        }
262        self.tray_variant = v;
263        v
264    }
265
266    /// Hand the theme to egui when it changed.
267    fn apply_theme(&mut self, ctx: &egui::Context) {
268        if self.applied_theme != Some(self.prefs.theme) {
269            theme::apply(ctx, self.prefs.theme);
270            self.applied_theme = Some(self.prefs.theme);
271        }
272    }
273
274    /// Shared by the real `ui` entry point and the headless tests.
275    pub fn render(&mut self, ui: &mut egui::Ui) {
276        let ctx = ui.ctx().clone();
277        self.apply_theme(&ctx);
278        if let Some(page) = chrome::page_shortcut(&ctx) {
279            self.page = page;
280        }
281        let link = self.deps.replica.link.lock().clone();
282        let pulse = pulse_of(&link, &self.deps.replica, chrono::Utc::now());
283        let glow = theme::breath(ui.input(|i| i.time), self.prefs.reduce_motion);
284        let p = *Palette::of_ui(ui);
285        let chrome_frame = egui::Frame::new().fill(p.chrome);
286
287        egui::Panel::left("rail")
288            .exact_size(chrome::RAIL_WIDTH)
289            .resizable(false)
290            .frame(chrome_frame)
291            .show_inside(ui, |ui| {
292                if let Some(page) = chrome::rail(ui, self.page, crate::AGENT_VERSION) {
293                    self.page = page;
294                }
295            });
296        egui::Panel::top("pulse")
297            .exact_size(chrome::HEADER_HEIGHT)
298            .resizable(false)
299            .frame(chrome_frame.inner_margin(egui::Margin::symmetric(20, 8)))
300            .show_inside(ui, |ui| {
301                if let Some(paused) = chrome::header(ui, &pulse, glow) {
302                    self.deps.actions.run(Action::SetPaused(paused));
303                }
304            });
305        egui::Panel::bottom("status")
306            .exact_size(chrome::STATUS_BAR_HEIGHT)
307            .resizable(false)
308            .frame(chrome_frame.inner_margin(egui::Margin::symmetric(16, 0)))
309            .show_inside(ui, |ui| {
310                let feedback = self.deps.actions.feedback.lock().clone();
311                chrome::status_bar(ui, &link.summary(), feedback.as_ref());
312            });
313        egui::CentralPanel::default()
314            .frame(
315                egui::Frame::new()
316                    .fill(p.page)
317                    .inner_margin(egui::Margin::same(20)),
318            )
319            .show_inside(ui, |ui| self.render_page(ui, &link, &pulse, glow));
320
321        // The poller updates the replica asynchronously; keep repainting so
322        // durations tick, and more often while running work glows.
323        let frame = if pulse.activity.glows() && !self.prefs.reduce_motion {
324            GLOW_FRAME
325        } else {
326            IDLE_FRAME
327        };
328        ctx.request_repaint_after(frame);
329    }
330
331    fn render_page(&mut self, ui: &mut egui::Ui, link: &LinkState, pulse: &Pulse, glow: f32) {
332        if !link.is_connected() {
333            let detail = match link {
334                LinkState::Starting { error } | LinkState::Unreachable { error, .. } => {
335                    error.as_str()
336                }
337                _ => "",
338            };
339            let log = crate::daemon_link::daemon_log_path(&self.deps.config_path);
340            let offline = worker_page::Offline {
341                summary: link.summary(),
342                detail: detail.to_string(),
343                daemon_log: log,
344            };
345            if self.page == Page::Worker {
346                reading_column(ui, "worker", |ui| {
347                    self.render_worker(ui, pulse, glow, Some(&offline))
348                });
349            } else {
350                reading_column(ui, "unreachable", |ui| {
351                    worker_page::unreachable_card(ui, &offline)
352                });
353            }
354            return;
355        }
356        match self.page {
357            Page::Jobs => self.render_jobs(ui),
358            Page::Models => reading_column(ui, "models", |ui| self.render_models(ui)),
359            Page::Worker => {
360                reading_column(ui, "worker", |ui| self.render_worker(ui, pulse, glow, None))
361            }
362            Page::Logs => logs_page::render(
363                ui,
364                &self.deps.replica.observers.recent_logs,
365                &mut self.log_filter,
366            ),
367            Page::Config => self.render_config(ui),
368        }
369    }
370
371    /// Shared housekeeping invoked before every frame's render.
372    fn pre_render(&mut self, ctx: &egui::Context) {
373        if self.start_minimised_pending {
374            self.start_minimised_pending = false;
375            tracing::info!(
376                target: TRACE_TARGET,
377                op = "start_minimised",
378                "minimising window on startup (config start_minimised)"
379            );
380            ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
381        }
382
383        self.drain_notifications();
384        self.refresh_tray_variant();
385        self.take_save_result();
386
387        // Hide-to-tray: intercept the OS close request and hide the window.
388        // The daemon keeps running either way; Quit comes from the tray.
389        if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
390            tracing::info!(
391                target: TRACE_TARGET,
392                op = "hide_to_tray",
393                "window close intercepted; hiding to tray (the daemon keeps running)"
394            );
395            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
396            ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
397        }
398
399        // Tray Quit: stop the daemon, then close the UI.
400        if self.quit_requested.load(Ordering::SeqCst) && !self.quitting {
401            self.quitting = true;
402            tracing::info!(
403                target: TRACE_TARGET,
404                op = "quit",
405                "quit requested; stopping the daemon and closing the tray UI"
406            );
407            self.deps.actions.run_and_wait(Action::Shutdown);
408            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
409        }
410    }
411
412    /// The page the window shows.
413    pub fn current_page(&self) -> Page {
414        self.page
415    }
416
417    pub fn set_page(&mut self, page: Page) {
418        self.page = page;
419    }
420
421    pub fn deps(&self) -> &AppDeps {
422        &self.deps
423    }
424
425    fn render_jobs(&mut self, ui: &mut egui::Ui) {
426        let replica = &self.deps.replica;
427        let view = jobs_page::JobsView::build(&replica.observers);
428        if let Some(spec) = &self.initial_job {
429            if let Some(id) = jobs_page::resolve_initial_selection(spec, &view) {
430                *replica.selected_job.lock() = Some(id);
431                self.initial_job = None;
432            }
433        }
434        let selected = replica.selected_job.lock().clone();
435        let log = replica.selected_log.lock().clone();
436        let changed = jobs_page::render(
437            ui,
438            &view,
439            JobsContext {
440                thumbnails: &replica.observers.thumbnails,
441                state: &mut self.jobs,
442                selected: selected.as_deref(),
443                log: log.as_ref(),
444                paused: replica.paused.load(Ordering::SeqCst),
445                reduce_motion: self.prefs.reduce_motion,
446            },
447        );
448        if let Some(selection) = changed {
449            *replica.selected_log.lock() = None;
450            *replica.selected_job.lock() = selection;
451        }
452    }
453
454    fn render_models(&mut self, ui: &mut egui::Ui) {
455        let vram_total_gb = self
456            .deps
457            .replica
458            .status
459            .lock()
460            .as_ref()
461            .map_or(0.0, |s| s.vram_total_gb);
462        let view = ModelsView::build(&self.deps.replica.models.lock(), vram_total_gb);
463        match models_page::render(ui, &view) {
464            Some(ModelAction::Load(id)) => self.deps.actions.run(Action::Load(id)),
465            Some(ModelAction::Unload(id)) => self.deps.actions.run(Action::Unload(id)),
466            None => {}
467        }
468    }
469
470    fn render_worker(
471        &mut self,
472        ui: &mut egui::Ui,
473        pulse: &Pulse,
474        glow: f32,
475        offline: Option<&worker_page::Offline>,
476    ) {
477        let replica = &self.deps.replica;
478        let status = replica.status.lock().clone();
479        let facts = offline.is_none().then(|| {
480            let cfg = replica.cfg.lock();
481            let registration = replica.registration.lock().clone();
482            let session = replica.observers.session_state.lock().clone();
483            let hb = replica.observers.last_heartbeat.lock().clone();
484            let gpu = replica.observers.gpu_runtime.lock().clone();
485            worker_page::WorkerFacts::build(worker_page::FactsInputs {
486                cfg: &cfg,
487                registered: replica.registered(),
488                registration: &registration,
489                session: &session,
490                heartbeat: hb.as_ref(),
491                gpu: gpu.as_ref(),
492                vram_total_gb: status.as_ref().map_or(0.0, |s| s.vram_total_gb),
493                held_gb: pulse.gpu.map_or(0.0, |g| g.held_gb),
494                local_api_url: replica.observers.local_api_url.lock().clone(),
495            })
496        });
497        let about = worker_page::AboutView::build(
498            &self.about_state,
499            &self.deps.config_path,
500            status.map(|s| s.version),
501        );
502        let feed = {
503            let cfg = replica.cfg.lock();
504            UpdateFeed {
505                url: cfg.auto_update_feed.clone(),
506                prerelease: cfg.auto_update_prerelease,
507            }
508        };
509        let action = worker_page::render(
510            ui,
511            worker_page::WorkerContext {
512                activity: &pulse.activity,
513                paused: pulse.paused,
514                facts: facts.as_ref(),
515                offline,
516                about: &about,
517                about_state: &self.about_state,
518                tokio: &self.deps.tokio,
519                feed: &feed,
520                glow,
521            },
522        );
523        match action {
524            Some(WorkerAction::SetPaused(paused)) => {
525                self.deps.actions.run(Action::SetPaused(paused))
526            }
527            Some(WorkerAction::ResetRegistration) => {
528                self.deps.actions.run(Action::ResetRegistration)
529            }
530            None => {}
531        }
532    }
533
534    fn render_config(&mut self, ui: &mut egui::Ui) {
535        let live = self.deps.replica.cfg.lock().clone();
536        self.config_draft.follow(&live);
537        let outcome = config_page::render(
538            ui,
539            &mut self.config_draft,
540            &self.deps.config_path,
541            &mut self.prefs,
542        );
543        if outcome.prefs_changed {
544            // A failed save is logged; the preference still applies now.
545            let _ = prefs::save(&self.prefs_path, &self.prefs);
546        }
547        if let Some(edit) = outcome.save {
548            let slot = self.pending_save.clone();
549            let path = self.deps.config_path.clone();
550            let edit = EditableConfig::from_config(&edit);
551            std::thread::spawn(move || {
552                *slot.lock() = Some(crate::daemon_link::save_config(&path, &edit));
553            });
554        }
555    }
556
557    /// Apply the daemon's answer to a Save, once it arrived.
558    fn take_save_result(&mut self) {
559        let Some(result) = self.pending_save.lock().take() else {
560            return;
561        };
562        match result {
563            Ok(saved) => {
564                let mut cfg = self.deps.replica.cfg.lock();
565                saved.apply_to(&mut cfg);
566                self.config_draft.saved(&cfg);
567            }
568            Err(err) => self.config_draft.save_failed(err),
569        }
570    }
571}
572
573/// A vertically scrolling column no wider than [`READING_WIDTH`].
574fn reading_column(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) {
575    egui::ScrollArea::vertical()
576        .id_salt(id)
577        .auto_shrink([false, false])
578        .show(ui, |ui| {
579            ui.set_max_width(READING_WIDTH);
580            add(ui);
581        });
582}
583
584impl eframe::App for App {
585    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
586        let ctx = ui.ctx().clone();
587        self.pre_render(&ctx);
588        self.render(ui);
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::{config::Config, daemon_link::Replica};
596
597    fn tokio_handle() -> Handle {
598        static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
599        RT.get_or_init(|| {
600            tokio::runtime::Builder::new_multi_thread()
601                .enable_all()
602                .worker_threads(1)
603                .build()
604                .expect("tokio runtime")
605        })
606        .handle()
607        .clone()
608    }
609
610    fn mock_deps() -> AppDeps {
611        let replica = Replica::default();
612        let config_path = PathBuf::from("/tmp/studio-worker-test/config.toml");
613        AppDeps {
614            actions: ActionRunner::new(config_path.clone(), replica.clone()),
615            replica,
616            start_minimised: true,
617            config_path,
618            tokio: tokio_handle(),
619        }
620    }
621
622    fn connected(deps: &AppDeps) {
623        *deps.replica.link.lock() = LinkState::Connected {
624            url: "http://127.0.0.1:4787".into(),
625            version: crate::AGENT_VERSION.into(),
626        };
627    }
628
629    #[test]
630    fn start_minimised_pending_follows_the_config() {
631        let app = App::new(mock_deps());
632        assert!(app.start_minimised_pending());
633
634        let deps = AppDeps {
635            start_minimised: false,
636            ..mock_deps()
637        };
638        let app = App::new(deps);
639        assert!(!app.start_minimised_pending());
640    }
641
642    #[test]
643    fn log_tray_variant_change_emits_structured_transition() {
644        use crate::test_support::capture;
645        let logs = capture(|| {
646            super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
647        });
648        assert!(logs.contains("studio_worker::ui::app"), "{logs}");
649        assert!(logs.contains("op=\"tray_variant\""), "{logs}");
650        assert!(logs.contains("from=Disconnected"), "{logs}");
651        assert!(logs.contains("to=Busy"), "{logs}");
652    }
653
654    #[test]
655    fn the_tray_is_disconnected_whenever_the_link_is_down() {
656        let replica = Replica::default();
657        replica.busy.store(true, Ordering::SeqCst);
658        assert_eq!(
659            tray_variant_for(&LinkState::Connecting, &replica),
660            TrayVariant::Disconnected
661        );
662        let link = LinkState::Connected {
663            url: "u".into(),
664            version: "v".into(),
665        };
666        assert_eq!(tray_variant_for(&link, &replica), TrayVariant::Busy);
667    }
668
669    #[test]
670    fn a_running_lane_job_makes_the_tray_busy() {
671        let replica = Replica::default();
672        replica
673            .observers
674            .active_jobs
675            .lock()
676            .push(crate::runtime::CurrentJob {
677                job_id: "l".into(),
678                kind: crate::types::TaskKind::Llm,
679                model: "m".into(),
680                prompt: String::new(),
681                started_at: chrono::Utc::now(),
682                source: crate::runtime::JobSource::Lane,
683            });
684        let link = LinkState::Connected {
685            url: "u".into(),
686            version: "v".into(),
687        };
688        assert_eq!(tray_variant_for(&link, &replica), TrayVariant::Busy);
689    }
690
691    #[test]
692    fn the_window_opens_on_jobs() {
693        let app = App::new(mock_deps());
694        assert_eq!(app.current_page(), Page::Jobs);
695    }
696
697    #[test]
698    fn every_page_renders_connected_and_not_in_both_themes() {
699        for page in Page::ALL {
700            for link_up in [false, true] {
701                for theme in [ThemeChoice::Dark, ThemeChoice::Light] {
702                    let deps = mock_deps();
703                    if link_up {
704                        connected(&deps);
705                        seed(&deps.replica);
706                    }
707                    let mut app = App::new(deps);
708                    app.prefs.theme = theme;
709                    app.set_page(page);
710                    egui::__run_test_ui(|ui| app.render(ui));
711                    assert_eq!(app.applied_theme, Some(theme));
712                }
713            }
714        }
715    }
716
717    /// A replica with a running job, a finished one and a loaded model.
718    fn seed(replica: &Replica) {
719        let now = chrono::Utc::now();
720        replica
721            .observers
722            .active_jobs
723            .lock()
724            .push(crate::runtime::CurrentJob {
725                job_id: "run".into(),
726                kind: crate::types::TaskKind::Image,
727                model: "sd".into(),
728                prompt: "a fox".into(),
729                started_at: now,
730                source: crate::runtime::JobSource::Studio,
731            });
732        crate::runtime::record_recent_job(&replica.observers, completed_recent_job("done"));
733        *replica.selected_job.lock() = Some("done".into());
734    }
735
736    #[test]
737    fn the_pulse_reads_the_replica() {
738        let deps = mock_deps();
739        connected(&deps);
740        seed(&deps.replica);
741        let link = deps.replica.link.lock().clone();
742        let pulse = pulse_of(&link, &deps.replica, chrono::Utc::now());
743        assert!(pulse.activity.glows());
744        assert!(pulse.can_pause);
745    }
746
747    #[test]
748    fn window_preferences_load_from_and_save_next_to_the_config() {
749        let dir = tempfile::tempdir().unwrap();
750        let config_path = dir.path().join("config.toml");
751        prefs::save(
752            &prefs::path_for(&config_path),
753            &UiPrefs {
754                theme: ThemeChoice::Light,
755                reduce_motion: true,
756                ..UiPrefs::default()
757            },
758        )
759        .unwrap();
760        let deps = AppDeps {
761            config_path: config_path.clone(),
762            ..mock_deps()
763        };
764        let mut app = App::new(deps);
765        assert_eq!(app.prefs().theme, ThemeChoice::Light);
766        assert!(app.prefs().reduce_motion);
767        app.set_notification_prefs(NotificationPrefs {
768            on_completion: true,
769            on_failure: false,
770        });
771        assert!(app.notification_prefs().on_completion);
772    }
773
774    #[test]
775    fn a_save_answer_becomes_the_draft_baseline() {
776        let deps = mock_deps();
777        let mut app = App::new(deps);
778        app.config_draft.current.vram_threshold_gb = 2.0;
779        app.config_draft.pending = true;
780        let mut saved = EditableConfig::from_config(&Config::default());
781        saved.vram_threshold_gb = 2.0;
782        *app.pending_save.lock() = Some(Ok(saved));
783        app.take_save_result();
784        assert!(!app.config_draft.dirty());
785        assert_eq!(app.deps.replica.cfg.lock().vram_threshold_gb, 2.0);
786
787        app.config_draft.current.vram_threshold_gb = 3.0;
788        *app.pending_save.lock() = Some(Err("invalid config".into()));
789        app.take_save_result();
790        assert_eq!(
791            app.config_draft.last_save_error.as_deref(),
792            Some("invalid config")
793        );
794    }
795
796    fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
797        let now = chrono::Utc::now();
798        crate::runtime::RecentJob {
799            job_id: id.into(),
800            kind: crate::types::TaskKind::Image,
801            model: "synthetic".into(),
802            prompt: "p".into(),
803            outcome: crate::runtime::JobOutcome::Completed,
804            started_at: now,
805            finished_at: now,
806            source: crate::runtime::JobSource::Studio,
807        }
808    }
809
810    /// Shared handle into a `CapturingNotifier`'s recorded
811    /// (title, body) pairs.
812    type Captured = Arc<Mutex<Vec<(String, String)>>>;
813
814    fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
815        let captured: Captured = Arc::new(Mutex::new(Vec::new()));
816        let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
817            captured: captured.clone(),
818        });
819        let mut app = App::with_notifier(deps, notifier);
820        app.set_notification_prefs(NotificationPrefs {
821            on_completion: true,
822            on_failure: true,
823        });
824        (app, captured)
825    }
826
827    #[test]
828    fn drain_notifications_fires_for_each_new_completed_job() {
829        let deps = mock_deps();
830        let observers = deps.replica.observers.clone();
831        let (mut app, captured) = app_with_capturing_notifier(deps);
832        crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
833        crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
834        app.drain_notifications();
835        assert_eq!(captured.lock().len(), 2);
836    }
837
838    #[test]
839    fn drain_notifications_is_idempotent_without_new_jobs() {
840        let deps = mock_deps();
841        let observers = deps.replica.observers.clone();
842        let (mut app, captured) = app_with_capturing_notifier(deps);
843        crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
844        app.drain_notifications();
845        app.drain_notifications();
846        assert_eq!(captured.lock().len(), 1);
847    }
848
849    #[test]
850    fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
851        let deps = mock_deps();
852        let observers = deps.replica.observers.clone();
853        let (mut app, captured) = app_with_capturing_notifier(deps);
854        for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
855            crate::runtime::record_recent_job(
856                &observers,
857                completed_recent_job(&format!("warm-{i}")),
858            );
859        }
860        app.drain_notifications();
861        captured.lock().clear();
862        crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
863        app.drain_notifications();
864        let shown = captured.lock();
865        assert_eq!(shown.len(), 1);
866        assert!(shown[0].1.contains("image"), "{:?}", shown[0]);
867    }
868}