1use 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
40const TRACE_TARGET: &str = "studio_worker::ui::app";
43
44pub const GLOW_FRAME: Duration = Duration::from_millis(50);
46pub const IDLE_FRAME: Duration = Duration::from_millis(500);
48pub const READING_WIDTH: f32 = 1080.0;
50
51fn 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
65pub 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
77pub 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: ®istration,
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
102pub struct AppDeps {
104 pub replica: Replica,
105 pub start_minimised: bool,
108 pub actions: ActionRunner,
109 pub config_path: PathBuf,
110 pub tokio: Handle,
111}
112
113type 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 initial_job: Option<String>,
126 last_notified: Option<(String, chrono::DateTime<chrono::Utc>)>,
132 notifier: Box<dyn Notifier + Send + Sync>,
133 prefs: UiPrefs,
135 prefs_path: PathBuf,
136 applied_theme: Option<ThemeChoice>,
138 tray_variant: TrayVariant,
139 quit_requested: Arc<std::sync::atomic::AtomicBool>,
140 quitting: bool,
142 tray: Option<super::tray_host::TrayHandle>,
143 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 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 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 pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
215 Box::new(super::notifier::DesktopNotifier)
216 }
217
218 pub fn drain_notifications(&mut self) {
221 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 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 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 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 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 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 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 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 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 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: ®istration,
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 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 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
573fn 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 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 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}