1use std::{
6 path::PathBuf,
7 sync::{atomic::Ordering, Arc},
8 time::Duration,
9};
10
11use eframe::egui;
12use parking_lot::Mutex;
13use tokio::runtime::Handle;
14
15use crate::{
16 daemon_api::EditableConfig,
17 daemon_link::{Action, LinkState, Replica},
18 runtime::HEARTBEAT_INTERVAL,
19};
20
21use super::{
22 actions::ActionRunner,
23 notifier::{decide, NotificationPrefs, Notifier, NotifyDecision},
24 tab::Tab,
25 tabs::{
26 about::{self as about_tab, AboutState, UpdateFeed},
27 config::{self as config_tab, ConfigDraft},
28 jobs::{self as jobs_tab, JobsContext, ThumbnailTextures},
29 logs::{self as logs_tab, LogFilter},
30 models::{self as models_tab, ModelAction, ModelRow},
31 status::{self as status_tab, StatusAction},
32 },
33 tray::{self, TrayVariant},
34};
35
36const TRACE_TARGET: &str = "studio_worker::ui::app";
39
40fn log_tray_variant_change(from: TrayVariant, to: TrayVariant) {
45 tracing::info!(
46 target: TRACE_TARGET,
47 op = "tray_variant",
48 from = ?from,
49 to = ?to,
50 "tray status indicator changed"
51 );
52}
53
54pub fn tray_variant_for(link: &LinkState, replica: &Replica) -> TrayVariant {
57 if !link.is_connected() {
58 return TrayVariant::Disconnected;
59 }
60 let busy =
61 replica.busy.load(Ordering::SeqCst) || !replica.observers.active_jobs.lock().is_empty();
62 let hb = replica.observers.last_heartbeat.lock().clone();
63 tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL)
64}
65
66pub struct AppDeps {
68 pub replica: Replica,
69 pub start_minimised: bool,
72 pub actions: ActionRunner,
73 pub config_path: PathBuf,
74 pub tokio: Handle,
75}
76
77type PendingSave = Arc<Mutex<Option<Result<EditableConfig, String>>>>;
79
80pub struct App {
81 deps: AppDeps,
82 tab: Tab,
83 config_draft: ConfigDraft,
84 pending_save: PendingSave,
85 log_filter: LogFilter,
86 about_state: AboutState,
87 textures: ThumbnailTextures,
88 initial_job: Option<String>,
90 last_notified: Option<(String, chrono::DateTime<chrono::Utc>)>,
96 notifier: Box<dyn Notifier + Send + Sync>,
97 notification_prefs: NotificationPrefs,
98 tray_variant: TrayVariant,
99 quit_requested: Arc<std::sync::atomic::AtomicBool>,
100 quitting: bool,
102 tray: Option<super::tray_host::TrayHandle>,
103 start_minimised_pending: bool,
108}
109
110impl App {
111 pub fn new(deps: AppDeps) -> Self {
112 Self::with_notifier(deps, Self::default_notifier_box())
113 }
114
115 pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
117 let config_draft = ConfigDraft::from(&deps.replica.cfg.lock());
118 let start_minimised_pending = deps.start_minimised;
119 Self {
120 deps,
121 tab: Tab::initial(),
122 config_draft,
123 pending_save: Arc::default(),
124 log_filter: LogFilter::default(),
125 about_state: AboutState::default(),
126 textures: ThumbnailTextures::default(),
127 initial_job: std::env::var("STUDIO_WORKER_UI_JOB").ok(),
128 last_notified: None,
129 notifier,
130 notification_prefs: NotificationPrefs::default(),
131 tray_variant: TrayVariant::Disconnected,
132 quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
133 quitting: false,
134 tray: None,
135 start_minimised_pending,
136 }
137 }
138
139 pub fn start_minimised_pending(&self) -> bool {
141 self.start_minimised_pending
142 }
143
144 pub fn attach_tray(&mut self, tray: super::tray_host::TrayHandle) {
145 self.tray = Some(tray);
146 }
147
148 pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
149 self.quit_requested.clone()
150 }
151
152 pub fn notification_prefs(&self) -> NotificationPrefs {
153 self.notification_prefs
154 }
155
156 pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
157 self.notification_prefs = prefs;
158 }
159
160 pub fn tray_variant(&self) -> TrayVariant {
161 self.tray_variant
162 }
163
164 pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
166 Box::new(super::notifier::DesktopNotifier)
167 }
168
169 pub fn drain_notifications(&mut self) {
172 let new_entries: Vec<_> = {
176 let ring = self.deps.replica.observers.recent_jobs.lock();
177 let mut collected = Vec::new();
178 for entry in ring.iter() {
179 if self
180 .last_notified
181 .as_ref()
182 .is_some_and(|(id, ts)| entry.job_id == *id && entry.finished_at == *ts)
183 {
184 break;
185 }
186 collected.push(entry.clone());
187 }
188 collected
189 };
190 if let Some(newest) = new_entries.first() {
191 self.last_notified = Some((newest.job_id.clone(), newest.finished_at));
192 }
193 for entry in new_entries.into_iter().rev() {
195 if let NotifyDecision::Show { title, body } = decide(self.notification_prefs, &entry) {
196 self.notifier.show(&title, &body);
197 }
198 }
199 }
200
201 pub fn refresh_tray_variant(&mut self) -> TrayVariant {
204 let link = self.deps.replica.link.lock().clone();
205 let v = tray_variant_for(&link, &self.deps.replica);
206 if v != self.tray_variant {
207 log_tray_variant_change(self.tray_variant, v);
208 if let Some(tray) = self.tray.as_mut() {
209 tray.set_variant(v);
210 }
211 }
212 self.tray_variant = v;
213 v
214 }
215
216 pub fn render(&mut self, ui: &mut egui::Ui) {
218 let link = self.deps.replica.link.lock().clone();
219 egui::Panel::top("tab_bar").show_inside(ui, |ui| {
220 ui.add_space(4.0);
221 ui.horizontal(|ui| {
222 for tab in Tab::ALL {
223 let selected = self.tab == tab;
224 if ui.selectable_label(selected, tab.label()).clicked() {
225 self.tab = tab;
226 }
227 }
228 });
229 ui.add_space(2.0);
230 self.render_status_line(ui, &link);
231 ui.add_space(4.0);
232 });
233
234 egui::CentralPanel::default().show_inside(ui, |ui| {
235 egui::ScrollArea::vertical().show(ui, |ui| {
236 if !link.is_connected() && self.tab != Tab::About {
237 render_unreachable(ui, &link, &self.deps.config_path);
238 return;
239 }
240 match self.tab {
241 Tab::Status => self.render_status(ui),
242 Tab::Jobs => self.render_jobs(ui),
243 Tab::Models => self.render_models(ui),
244 Tab::Config => self.render_config(ui),
245 Tab::Logs => self.render_logs(ui),
246 Tab::About => self.render_about(ui),
247 }
248 });
249 });
250
251 ui.ctx().request_repaint_after(Duration::from_millis(500));
254 }
255
256 fn render_status_line(&self, ui: &mut egui::Ui, link: &LinkState) {
259 ui.horizontal(|ui| {
260 let colour = match link {
261 LinkState::Connected { .. } => egui::Color32::LIGHT_GREEN,
262 LinkState::Connecting | LinkState::Starting { .. } => {
263 egui::Color32::from_rgb(232, 168, 56)
264 }
265 LinkState::Unreachable { .. } => egui::Color32::LIGHT_RED,
266 };
267 let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
268 ui.painter().circle_filled(dot.center(), 4.0, colour);
269 ui.label(egui::RichText::new(link.summary()).small());
270 if let Some(feedback) = self.deps.actions.feedback.lock().clone() {
271 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
272 let colour = if feedback.ok {
273 egui::Color32::from_gray(190)
274 } else {
275 egui::Color32::LIGHT_RED
276 };
277 ui.label(
278 egui::RichText::new(format!(
279 "{} \u{00b7} {}",
280 feedback.at.with_timezone(&chrono::Local).format("%H:%M:%S"),
281 feedback.text
282 ))
283 .small()
284 .color(colour),
285 );
286 });
287 }
288 });
289 }
290
291 fn pre_render(&mut self, ctx: &egui::Context) {
293 if self.start_minimised_pending {
294 self.start_minimised_pending = false;
295 tracing::info!(
296 target: TRACE_TARGET,
297 op = "start_minimised",
298 "minimising window on startup (config start_minimised)"
299 );
300 ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
301 }
302
303 self.drain_notifications();
304 self.refresh_tray_variant();
305 self.take_save_result();
306
307 if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
310 tracing::info!(
311 target: TRACE_TARGET,
312 op = "hide_to_tray",
313 "window close intercepted; hiding to tray (the daemon keeps running)"
314 );
315 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
316 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
317 }
318
319 if self.quit_requested.load(Ordering::SeqCst) && !self.quitting {
321 self.quitting = true;
322 tracing::info!(
323 target: TRACE_TARGET,
324 op = "quit",
325 "quit requested; stopping the daemon and closing the tray UI"
326 );
327 self.deps.actions.run_and_wait(Action::Shutdown);
328 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
329 }
330 }
331
332 pub fn current_tab(&self) -> Tab {
334 self.tab
335 }
336
337 pub fn set_tab(&mut self, tab: Tab) {
338 self.tab = tab;
339 }
340
341 pub fn deps(&self) -> &AppDeps {
342 &self.deps
343 }
344
345 fn render_jobs(&mut self, ui: &mut egui::Ui) {
346 let replica = &self.deps.replica;
347 let view = jobs_tab::JobsView::build(&replica.observers, chrono::Utc::now());
348 if let Some(spec) = &self.initial_job {
349 if let Some(id) = jobs_tab::resolve_initial_selection(spec, &view) {
350 *replica.selected_job.lock() = Some(id);
351 self.initial_job = None;
352 }
353 }
354 let selected = replica.selected_job.lock().clone();
355 let log = replica.selected_log.lock().clone();
356 let changed = jobs_tab::render(
357 ui,
358 &view,
359 JobsContext {
360 thumbnails: &replica.observers.thumbnails,
361 textures: &mut self.textures,
362 selected: selected.as_deref(),
363 log: log.as_ref(),
364 },
365 );
366 if let Some(selection) = changed {
367 *replica.selected_log.lock() = None;
368 *replica.selected_job.lock() = selection;
369 }
370 }
371
372 fn render_models(&mut self, ui: &mut egui::Ui) {
373 let rows: Vec<ModelRow> = self
374 .deps
375 .replica
376 .models
377 .lock()
378 .iter()
379 .map(ModelRow::from_entry)
380 .collect();
381 match models_tab::render(ui, &rows) {
382 Some(ModelAction::Load(id)) => self.deps.actions.run(Action::Load(id)),
383 Some(ModelAction::Unload(id)) => self.deps.actions.run(Action::Unload(id)),
384 None => {}
385 }
386 }
387
388 fn render_config(&mut self, ui: &mut egui::Ui) {
389 let live = self.deps.replica.cfg.lock().clone();
390 self.config_draft.follow(&live);
391 if let Some(edit) = config_tab::render(
392 ui,
393 &mut self.config_draft,
394 &self.deps.config_path,
395 &mut self.notification_prefs,
396 ) {
397 let slot = self.pending_save.clone();
398 let path = self.deps.config_path.clone();
399 let edit = EditableConfig::from_config(&edit);
400 std::thread::spawn(move || {
401 *slot.lock() = Some(crate::daemon_link::save_config(&path, &edit));
402 });
403 }
404 }
405
406 fn take_save_result(&mut self) {
408 let Some(result) = self.pending_save.lock().take() else {
409 return;
410 };
411 match result {
412 Ok(saved) => {
413 let mut cfg = self.deps.replica.cfg.lock();
414 saved.apply_to(&mut cfg);
415 self.config_draft.saved(&cfg);
416 }
417 Err(err) => self.config_draft.save_failed(err),
418 }
419 }
420
421 fn render_logs(&mut self, ui: &mut egui::Ui) {
422 logs_tab::render(
423 ui,
424 &self.deps.replica.observers.recent_logs,
425 &mut self.log_filter,
426 );
427 }
428
429 fn render_about(&mut self, ui: &mut egui::Ui) {
430 let daemon_version = self
431 .deps
432 .replica
433 .status
434 .lock()
435 .as_ref()
436 .map(|s| s.version.clone());
437 let view =
438 about_tab::AboutView::build(&self.about_state, &self.deps.config_path, daemon_version);
439 let feed = {
440 let cfg = self.deps.replica.cfg.lock();
441 UpdateFeed {
442 url: cfg.auto_update_feed.clone(),
443 prerelease: cfg.auto_update_prerelease,
444 }
445 };
446 about_tab::render(ui, &view, &self.about_state, &self.deps.tokio, &feed);
447 }
448
449 fn render_status(&mut self, ui: &mut egui::Ui) {
450 let replica = &self.deps.replica;
451 let view = {
452 let cfg = replica.cfg.lock();
453 let registration = replica.registration.lock().clone();
454 let hb = replica.observers.last_heartbeat.lock().clone();
455 let session_state = replica.observers.session_state.lock().clone();
456 let gpu = replica.observers.gpu_runtime.lock().clone();
457 let vram_total_gb = replica
458 .status
459 .lock()
460 .as_ref()
461 .map_or(0.0, |s| s.vram_total_gb);
462 status_tab::StatusView::build(
463 &cfg,
464 replica.registered(),
465 ®istration,
466 replica.busy.load(Ordering::SeqCst),
467 replica.paused.load(Ordering::SeqCst),
468 hb.as_ref(),
469 vram_total_gb,
470 &session_state,
471 gpu.as_ref(),
472 )
473 };
474 match status_tab::render(ui, &view) {
475 Some(StatusAction::SetPaused(paused)) => {
476 self.deps.actions.run(Action::SetPaused(paused))
477 }
478 Some(StatusAction::ResetRegistration) => {
479 self.deps.actions.run(Action::ResetRegistration)
480 }
481 None => {}
482 }
483 }
484}
485
486fn render_unreachable(ui: &mut egui::Ui, link: &LinkState, config_path: &std::path::Path) {
489 ui.heading("Worker daemon not reachable");
490 ui.add_space(6.0);
491 ui.horizontal(|ui| {
492 ui.spinner();
493 ui.label(link.summary());
494 });
495 ui.add_space(8.0);
496 let detail = match link {
497 LinkState::Starting { error } | LinkState::Unreachable { error, .. } => error.as_str(),
498 _ => "",
499 };
500 if !detail.is_empty() {
501 ui.label(
502 egui::RichText::new(detail)
503 .monospace()
504 .color(egui::Color32::from_gray(180)),
505 );
506 ui.add_space(8.0);
507 }
508 ui.label(format!(
509 "The tray UI shows what the daemon (`studio-worker run`) does. It starts one when \
510 none is running and keeps retrying. A daemon it starts writes its output to {}.",
511 crate::daemon_link::daemon_log_path(config_path).display()
512 ));
513}
514
515impl eframe::App for App {
516 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
517 let ctx = ui.ctx().clone();
518 self.pre_render(&ctx);
519 self.render(ui);
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::{config::Config, daemon_link::Replica};
527
528 fn tokio_handle() -> Handle {
529 static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
530 RT.get_or_init(|| {
531 tokio::runtime::Builder::new_multi_thread()
532 .enable_all()
533 .worker_threads(1)
534 .build()
535 .expect("tokio runtime")
536 })
537 .handle()
538 .clone()
539 }
540
541 fn mock_deps() -> AppDeps {
542 let replica = Replica::default();
543 let config_path = PathBuf::from("/tmp/studio-worker-test/config.toml");
544 AppDeps {
545 actions: ActionRunner::new(config_path.clone(), replica.clone()),
546 replica,
547 start_minimised: true,
548 config_path,
549 tokio: tokio_handle(),
550 }
551 }
552
553 fn connected(deps: &AppDeps) {
554 *deps.replica.link.lock() = LinkState::Connected {
555 url: "http://127.0.0.1:4787".into(),
556 version: crate::AGENT_VERSION.into(),
557 };
558 }
559
560 #[test]
561 fn start_minimised_pending_follows_the_config() {
562 let app = App::new(mock_deps());
563 assert!(app.start_minimised_pending());
564
565 let deps = AppDeps {
566 start_minimised: false,
567 ..mock_deps()
568 };
569 let app = App::new(deps);
570 assert!(!app.start_minimised_pending());
571 }
572
573 #[test]
574 fn log_tray_variant_change_emits_structured_transition() {
575 use crate::test_support::capture;
576 let logs = capture(|| {
577 super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
578 });
579 assert!(logs.contains("studio_worker::ui::app"), "{logs}");
580 assert!(logs.contains("op=\"tray_variant\""), "{logs}");
581 assert!(logs.contains("from=Disconnected"), "{logs}");
582 assert!(logs.contains("to=Busy"), "{logs}");
583 }
584
585 #[test]
586 fn the_tray_is_disconnected_whenever_the_link_is_down() {
587 let replica = Replica::default();
588 replica.busy.store(true, Ordering::SeqCst);
589 assert_eq!(
590 tray_variant_for(&LinkState::Connecting, &replica),
591 TrayVariant::Disconnected
592 );
593 let link = LinkState::Connected {
594 url: "u".into(),
595 version: "v".into(),
596 };
597 assert_eq!(tray_variant_for(&link, &replica), TrayVariant::Busy);
598 }
599
600 #[test]
601 fn a_running_lane_job_makes_the_tray_busy() {
602 let replica = Replica::default();
603 replica
604 .observers
605 .active_jobs
606 .lock()
607 .push(crate::runtime::CurrentJob {
608 job_id: "l".into(),
609 kind: crate::types::TaskKind::Llm,
610 model: "m".into(),
611 prompt: String::new(),
612 started_at: chrono::Utc::now(),
613 source: crate::runtime::JobSource::Lane,
614 });
615 let link = LinkState::Connected {
616 url: "u".into(),
617 version: "v".into(),
618 };
619 assert_eq!(tray_variant_for(&link, &replica), TrayVariant::Busy);
620 }
621
622 #[test]
623 fn new_defaults_to_status_tab() {
624 let app = App::new(mock_deps());
625 assert_eq!(app.current_tab(), Tab::Status);
626 }
627
628 #[test]
629 fn render_each_tab_connected_and_not_does_not_panic() {
630 for tab in Tab::ALL {
631 for link_up in [false, true] {
632 let deps = mock_deps();
633 if link_up {
634 connected(&deps);
635 }
636 let mut app = App::new(deps);
637 app.set_tab(tab);
638 egui::__run_test_ui(|ui| app.render(ui));
639 }
640 }
641 }
642
643 #[test]
644 fn a_save_answer_becomes_the_draft_baseline() {
645 let deps = mock_deps();
646 let mut app = App::new(deps);
647 app.config_draft.current.vram_threshold_gb = 2.0;
648 app.config_draft.pending = true;
649 let mut saved = EditableConfig::from_config(&Config::default());
650 saved.vram_threshold_gb = 2.0;
651 *app.pending_save.lock() = Some(Ok(saved));
652 app.take_save_result();
653 assert!(!app.config_draft.dirty());
654 assert_eq!(app.deps.replica.cfg.lock().vram_threshold_gb, 2.0);
655
656 app.config_draft.current.vram_threshold_gb = 3.0;
657 *app.pending_save.lock() = Some(Err("invalid config".into()));
658 app.take_save_result();
659 assert_eq!(
660 app.config_draft.last_save_error.as_deref(),
661 Some("invalid config")
662 );
663 }
664
665 fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
666 let now = chrono::Utc::now();
667 crate::runtime::RecentJob {
668 job_id: id.into(),
669 kind: crate::types::TaskKind::Image,
670 model: "synthetic".into(),
671 prompt: "p".into(),
672 outcome: crate::runtime::JobOutcome::Completed,
673 started_at: now,
674 finished_at: now,
675 source: crate::runtime::JobSource::Studio,
676 }
677 }
678
679 type Captured = Arc<Mutex<Vec<(String, String)>>>;
682
683 fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
684 let captured: Captured = Arc::new(Mutex::new(Vec::new()));
685 let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
686 captured: captured.clone(),
687 });
688 let mut app = App::with_notifier(deps, notifier);
689 app.set_notification_prefs(NotificationPrefs {
690 on_completion: true,
691 on_failure: true,
692 });
693 (app, captured)
694 }
695
696 #[test]
697 fn drain_notifications_fires_for_each_new_completed_job() {
698 let deps = mock_deps();
699 let observers = deps.replica.observers.clone();
700 let (mut app, captured) = app_with_capturing_notifier(deps);
701 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
702 crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
703 app.drain_notifications();
704 assert_eq!(captured.lock().len(), 2);
705 }
706
707 #[test]
708 fn drain_notifications_is_idempotent_without_new_jobs() {
709 let deps = mock_deps();
710 let observers = deps.replica.observers.clone();
711 let (mut app, captured) = app_with_capturing_notifier(deps);
712 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
713 app.drain_notifications();
714 app.drain_notifications();
715 assert_eq!(captured.lock().len(), 1);
716 }
717
718 #[test]
719 fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
720 let deps = mock_deps();
721 let observers = deps.replica.observers.clone();
722 let (mut app, captured) = app_with_capturing_notifier(deps);
723 for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
724 crate::runtime::record_recent_job(
725 &observers,
726 completed_recent_job(&format!("warm-{i}")),
727 );
728 }
729 app.drain_notifications();
730 captured.lock().clear();
731 crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
732 app.drain_notifications();
733 let shown = captured.lock();
734 assert_eq!(shown.len(), 1);
735 assert!(shown[0].1.contains("image"), "{:?}", shown[0]);
736 }
737}