From 12a4f39328ed98e6543ea717dc6bf90275a748e1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 8 Jun 2026 11:19:28 +0900 Subject: [PATCH] tui: make panel transitions nonblocking --- crates/tui/src/multi_pod.rs | 177 ++++++++++++++++++++++++++--------- crates/tui/src/single_pod.rs | 1 - 2 files changed, 133 insertions(+), 45 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 2b37a2ae..efc8f783 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -102,18 +102,26 @@ pub(crate) struct OpenPodRequest { pub(crate) async fn load_app( runtime_command: PodRuntimeCommand, ) -> Result { - MultiPodApp::load(None, runtime_command).await + Ok(MultiPodApp::loading(runtime_command)) } pub(crate) async fn run( terminal: &mut Terminal>, app: &mut MultiPodApp, ) -> Result { - if app.panel.rows.is_empty() && app.panel.header.diagnostics.is_empty() { + if app.panel.rows.is_empty() + && app.panel.header.diagnostics.is_empty() + && app.enter_reload.is_none() + { return Err(MultiPodError::NoPods); } let mut pending_reload = PendingReload::default(); + if let Some(mode) = app.enter_reload.take() { + if pending_reload.start(mode) { + app.refreshing = true; + } + } let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; loop { @@ -125,7 +133,7 @@ pub(crate) async fn run( let now = Instant::now(); if now >= next_poll { - pending_reload.start(); + pending_reload.start(OrchestratorLifecycleMode::Observe); next_poll = now + MULTI_POD_POLL_INTERVAL; continue; } @@ -141,6 +149,7 @@ pub(crate) async fn run( MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit), MultiPodAction::Open => { if let Some(request) = app.prepare_open() { + terminal.draw(|f| draw(f, app))?; return Ok(MultiPodOutcome::Open(request)); } } @@ -149,7 +158,9 @@ pub(crate) async fn run( terminal.draw(|f| draw(f, app))?; let result = dispatch_ticket_action(request).await; app.finish_ticket_action_dispatch(result); - app.reload_or_notice().await; + if pending_reload.start(OrchestratorLifecycleMode::Observe) { + app.refreshing = true; + } next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } MultiPodAction::LaunchIntake(request) => { @@ -157,7 +168,9 @@ pub(crate) async fn run( terminal.draw(|f| draw(f, app))?; let result = launch_intake_with_handoff(request).await; app.finish_intake_launch(result); - app.reload_or_notice().await; + if pending_reload.start(OrchestratorLifecycleMode::Observe) { + app.refreshing = true; + } next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } MultiPodAction::SendCompanion(request) => { @@ -165,7 +178,9 @@ pub(crate) async fn run( terminal.draw(|f| draw(f, app))?; let result = dispatch_companion_message(request).await; app.finish_companion_send(result); - app.reload_or_notice().await; + if pending_reload.start(OrchestratorLifecycleMode::Observe) { + app.refreshing = true; + } next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } }, @@ -181,12 +196,12 @@ struct PendingReload { } impl PendingReload { - fn start(&mut self) -> bool { + fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode) -> bool { if self.handle.is_some() { return false; } - self.handle = Some(tokio::spawn(async { - load_multi_pod_snapshot(None, OrchestratorLifecycleMode::Observe).await + self.handle = Some(tokio::spawn(async move { + load_multi_pod_snapshot(None, lifecycle_mode).await })); true } @@ -486,50 +501,47 @@ pub(crate) struct MultiPodApp { composer_target: ComposerTarget, notice: Option, sending: bool, + refreshing: bool, + enter_reload: Option, runtime_command: PodRuntimeCommand, last_companion_lifecycle_failure: Option, last_orchestrator_lifecycle_failure: Option, } impl MultiPodApp { - async fn load( - selected_name: Option, - runtime_command: PodRuntimeCommand, - ) -> Result { - let snapshot = load_multi_pod_snapshot( - selected_name, - OrchestratorLifecycleMode::Ensure { - runtime_command: runtime_command.clone(), - }, - ) - .await?; - let last_companion_lifecycle_failure = - companion_lifecycle_failure_from_panel(&snapshot.panel); - let last_orchestrator_lifecycle_failure = - orchestrator_lifecycle_failure_from_panel(&snapshot.panel); - let mut app = Self { - list: snapshot.list, - panel: snapshot.panel, + fn loading(runtime_command: PodRuntimeCommand) -> Self { + let workspace_root = current_workspace_root(); + let mut panel = WorkspacePanelViewModel::empty(&workspace_root); + panel + .header + .diagnostics + .push("Loading workspace dashboard…".to_string()); + Self { + list: PodList::from_sources( + PodVisibilitySource::ResumePicker, + Vec::new(), + Vec::new(), + None, + MAX_ENTRIES, + ), + panel, input: InputBuffer::new(), selected_row: None, composer_target: ComposerTarget::Companion, notice: None, sending: false, + refreshing: true, + enter_reload: Some(OrchestratorLifecycleMode::Ensure { + runtime_command: runtime_command.clone(), + }), runtime_command, - last_companion_lifecycle_failure, - last_orchestrator_lifecycle_failure, - }; - app.ensure_selection_visible(); - app.ensure_composer_target_available(); - Ok(app) - } - - pub(crate) async fn reload_or_notice(&mut self) { - let result = load_multi_pod_snapshot(None, OrchestratorLifecycleMode::Observe).await; - self.apply_reload_result(result); + last_companion_lifecycle_failure: None, + last_orchestrator_lifecycle_failure: None, + } } fn apply_reload_result(&mut self, result: Result) { + self.refreshing = false; match result { Ok(snapshot) => self.apply_reloaded_snapshot(snapshot), Err(error) => { @@ -799,7 +811,7 @@ impl MultiPodApp { } pub(crate) fn prepare_open(&mut self) -> Option { - let (pod_name, socket_override) = { + let (pod_name, socket_override, progress) = { let entry = match self.selected_pod_entry() { Some(entry) => entry, None => { @@ -811,12 +823,20 @@ impl MultiPodApp { self.notice = Some("Selected Pod cannot be opened from this view.".to_string()); return None; } + let progress = if entry.live.as_ref().is_some_and(|live| live.reachable) { + "Attaching to" + } else if entry.stored.is_some() { + "Restoring/opening" + } else { + "Opening" + }; ( entry.name.clone(), entry.attach_socket_path().map(PathBuf::from), + progress, ) }; - self.notice = Some(format!("Opening {pod_name}…")); + self.notice = Some(format!("{progress} {pod_name}…")); Some(OpenPodRequest { pod_name, socket_override, @@ -830,12 +850,16 @@ impl MultiPodApp { ) { match result { Ok(()) => { - self.notice = Some(format!("Returned from {pod_name}.")); + self.notice = Some(format!("Returned from {pod_name}. Refreshing workspace…")); } Err(error) => { - self.notice = Some(format!("Open failed for {pod_name}: {error}")); + self.notice = Some(format!( + "Open failed for {pod_name}: {error}. Refreshing workspace…" + )); } } + self.refreshing = true; + self.enter_reload = Some(OrchestratorLifecycleMode::Observe); } fn composer_is_blank(&self) -> bool { @@ -2797,6 +2821,14 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { "launching Ticket Intake…".to_string() } else if app.sending { "working…".to_string() + } else if app.refreshing { + match app.notice.as_deref() { + Some(notice) if notice.contains("Refreshing") || notice.contains("refreshing") => { + notice.to_string() + } + Some(notice) => format!("{notice} Refreshing workspace…"), + None => "Refreshing workspace…".to_string(), + } } else if let Some(notice) = app.notice.as_deref() { notice.to_string() } else if let Some(reason) = app.selected_open_disabled_reason() { @@ -3966,7 +3998,12 @@ mod tests { ); assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); assert_eq!(input_text(&app), "draft survives open"); - assert!(app.notice.as_deref().unwrap().contains("Opening alpha")); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Attaching to alpha") + ); } #[test] @@ -3986,6 +4023,51 @@ mod tests { .unwrap() .contains("Open failed for alpha") ); + assert!(app.refreshing); + assert!(matches!( + app.enter_reload, + Some(OrchestratorLifecycleMode::Observe) + )); + } + + #[test] + fn multi_loading_app_defers_initial_snapshot_to_enter_reload() { + let app = MultiPodApp::loading(PodRuntimeCommand::for_executable("/tmp/yoi")); + + assert!(app.panel.rows.is_empty()); + assert!( + app.panel + .header + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("Loading workspace dashboard")) + ); + assert!(app.refreshing); + assert!(matches!( + app.enter_reload, + Some(OrchestratorLifecycleMode::Ensure { .. }) + )); + } + + #[test] + fn multi_open_success_requests_background_reload_without_dropping_state() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("keep this draft"); + + app.finish_open("alpha", Ok(())); + + assert_eq!(input_text(&app), "keep this draft"); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Refreshing workspace") + ); + assert!(app.refreshing); + assert!(matches!( + app.enter_reload, + Some(OrchestratorLifecycleMode::Observe) + )); } #[test] @@ -4015,7 +4097,12 @@ mod tests { Some(PathBuf::from("/tmp/alpha.sock")) ); assert_eq!(input_text(&app), ""); - assert!(app.notice.as_deref().unwrap().contains("Opening alpha")); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Attaching to alpha") + ); } #[test] @@ -4310,6 +4397,8 @@ mod tests { composer_target: ComposerTarget::Companion, notice: None, sending: false, + refreshing: false, + enter_reload: None, runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), last_companion_lifecycle_failure, last_orchestrator_lifecycle_failure, diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 1576e45c..f90f3f21 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -203,7 +203,6 @@ pub(crate) async fn run_panel( return Err(error); } } - app.reload_or_notice().await; } } }