From 5415a9478d913ada6f53c47d7307c28f0cc0cd70 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 18:12:18 +0900 Subject: [PATCH] tui: introduce dashboard console boundaries --- README.md | 6 +- crates/tui/README.md | 6 +- .../tui/src/{single_pod.rs => console/mod.rs} | 86 +- .../src/{multi_pod.rs => dashboard/mod.rs} | 4508 +---------------- crates/tui/src/dashboard/render.rs | 1025 ++++ crates/tui/src/dashboard/tests.rs | 3176 ++++++++++++ crates/tui/src/lib.rs | 16 +- crates/tui/src/spawn.rs | 22 +- crates/yoi/src/main.rs | 14 +- crates/yoi/src/ticket_cli.rs | 3 +- docs/development/work-items.md | 24 +- .../panel/orchestrator_idle_queue_notice.md | 2 +- 12 files changed, 4465 insertions(+), 4423 deletions(-) rename crates/tui/src/{single_pod.rs => console/mod.rs} (96%) rename crates/tui/src/{multi_pod.rs => dashboard/mod.rs} (52%) create mode 100644 crates/tui/src/dashboard/render.rs create mode 100644 crates/tui/src/dashboard/tests.rs diff --git a/README.md b/README.md index ea5d57ce..50466069 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Main highlights: - Multi-agent orchestration with scoped coder/reviewer Pods. - Profile, Manifest, and prompt-based runtime configuration. - Local Tickets and workflow files for auditable project coordination. -- TUI and CLI entry points, including a multi-Pod dashboard. +- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Pod Console. Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change. @@ -38,7 +38,7 @@ nix build .#yoi ```sh yoi --help yoi -yoi --multi +yoi panel yoi --pod yoi pod --help ``` @@ -46,7 +46,7 @@ yoi pod --help Typical flow: 1. Configure providers, models, profiles, prompts, and scopes. -2. Start or attach to a named Pod from the CLI/TUI. +2. Start or attach to a named Pod in the Console, or inspect workspace activity in the Dashboard. 3. Use explicit tools and scoped delegation for multi-agent work. 4. Record project work through Tickets, workflow files, and git history. diff --git a/crates/tui/README.md b/crates/tui/README.md index 739dc2bf..db204787 100644 --- a/crates/tui/README.md +++ b/crates/tui/README.md @@ -2,7 +2,7 @@ ## Role -`tui` implements terminal UI clients for interacting with one or more Pods. +`tui` implements terminal UI clients for the single-Pod Console and workspace Dashboard surfaces. ## Boundaries @@ -10,8 +10,8 @@ Owns: - terminal rendering and input handling - local composer state and UI affordances -- single-Pod attach/restore screens -- multi-Pod dashboard presentation +- single-Pod Console attach/restore/chat screens +- workspace Dashboard presentation and role-action UI Does not own: diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/console/mod.rs similarity index 96% rename from crates/tui/src/single_pod.rs rename to crates/tui/src/console/mod.rs index e1907e7e..1c622f32 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/console/mod.rs @@ -1,3 +1,4 @@ +use std::error::Error; use std::fmt; use std::future::Future; use std::io; @@ -30,9 +31,15 @@ use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::picker::PickerOutcome; use crate::spawn::{SpawnOutcome, SpawnReady}; -use crate::{multi_pod, picker, spawn, ui}; +use crate::{picker, spawn, ui}; -type FullscreenTerminal = Terminal>; +pub(crate) type ConsoleTerminal = Terminal>; + +/// Narrow request bridge used when the workspace Dashboard opens a Pod Console. +pub(crate) struct DashboardConsoleOpenRequest { + pub(crate) pod_name: String, + pub(crate) socket_override: Option, +} /// Enable SGR coordinates plus normal mouse tracking. This captures clicks, /// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`) @@ -58,13 +65,13 @@ impl Command for EnableSinglePodMouseCapture { } } -/// Enable Panel mouse input without drag tracking. The Panel only needs button -/// presses/releases and wheel events; enabling `?1002h` can make terminal drag -/// selection look captured and is intentionally avoided for Panel startup. +/// Enable Dashboard mouse input without drag tracking. The Dashboard only needs +/// button presses/releases and wheel events; enabling `?1002h` can make terminal +/// drag selection look captured and is intentionally avoided before startup. #[derive(Debug, Clone, Copy)] -struct EnablePanelMouseCapture; +struct EnableDashboardMouseCapture; -impl Command for EnablePanelMouseCapture { +impl Command for EnableDashboardMouseCapture { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { // 1006: SGR extended coordinates used by crossterm's parser // 1000: normal mouse tracking (button presses/releases and wheel) @@ -165,7 +172,7 @@ pub(crate) async fn run_pod_name( } async fn run_connected_pod( - terminal: &mut FullscreenTerminal, + terminal: &mut ConsoleTerminal, pod_name: String, client: PodClient, runtime_command: PodRuntimeCommand, @@ -176,12 +183,12 @@ async fn run_connected_pod( run_loop(terminal, &mut app, client, runtime_command).await } -async fn run_pod_name_nested( - terminal: &mut FullscreenTerminal, - request: multi_pod::OpenPodRequest, +pub(crate) async fn open_from_dashboard( + terminal: &mut ConsoleTerminal, + request: DashboardConsoleOpenRequest, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let multi_pod::OpenPodRequest { + let DashboardConsoleOpenRequest { pod_name, socket_override, } = request; @@ -196,7 +203,7 @@ async fn run_pod_name_nested( } async fn spawn_pod_name_from_fullscreen( - terminal: &mut FullscreenTerminal, + terminal: &mut ConsoleTerminal, pod_name: &str, runtime_command: PodRuntimeCommand, ) -> Result> { @@ -233,7 +240,7 @@ impl std::fmt::Display for NestedOpenCancelled { impl std::error::Error for NestedOpenCancelled {} async fn run_ready_pod( - terminal: &mut FullscreenTerminal, + terminal: &mut ConsoleTerminal, ready: SpawnReady, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { @@ -281,36 +288,7 @@ pub(crate) async fn run_resume( run_pod_name(pod_name, socket_override, runtime_command).await } -pub(crate) async fn run_panel( - runtime_command: PodRuntimeCommand, -) -> Result<(), Box> { - let mut app = multi_pod::load_app(runtime_command.clone()).await?; - let mut terminal = enter_panel_fullscreen()?; - - loop { - match multi_pod::run(&mut terminal, &mut app).await? { - multi_pod::MultiPodOutcome::Quit => { - let _ = leave_fullscreen(&mut terminal); - return Ok(()); - } - multi_pod::MultiPodOutcome::Open(request) => { - let pod_name = request.pod_name.clone(); - match run_pod_name_nested(&mut terminal, request, runtime_command.clone()).await { - Ok(()) => app.finish_open(&pod_name, Ok(())), - Err(error) if is_recoverable_multi_open_error(error.as_ref()) => { - app.finish_open(&pod_name, Err(error.as_ref())); - } - Err(error) => { - let _ = leave_fullscreen(&mut terminal); - return Err(error); - } - } - } - } - } -} - -fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool { +pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool { error.is::() || error.is::() } @@ -353,7 +331,7 @@ pub(crate) async fn run_spawn( result } -fn enter_fullscreen() -> Result> { +fn enter_fullscreen() -> Result> { let mut stdout = io::stdout(); // Enable button-event tracking so the transcript can own drag selection; // avoid all-motion capture because hover-motion reports are unnecessary. @@ -362,17 +340,17 @@ fn enter_fullscreen() -> Result> Ok(Terminal::new(backend)?) } -fn enter_panel_fullscreen() -> Result> { +pub(crate) fn enter_dashboard_fullscreen() -> Result> { let mut stdout = io::stdout(); - // Panel needs clicks and wheel input only; do not capture drag motion before + // Dashboard needs clicks and wheel input only; do not capture drag motion before // the first visible frame. - execute!(stdout, EnterAlternateScreen, EnablePanelMouseCapture)?; + execute!(stdout, EnterAlternateScreen, EnableDashboardMouseCapture)?; let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) } fn enter_fullscreen_existing( - terminal: &mut FullscreenTerminal, + terminal: &mut ConsoleTerminal, ) -> Result<(), Box> { // Re-enable the same least-intrusive wheel mouse mode after returning from // nested inline screens. @@ -384,7 +362,7 @@ fn enter_fullscreen_existing( Ok(()) } -fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> { +fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> { execute!( terminal.backend_mut(), DisableMouseCapture, @@ -392,8 +370,12 @@ fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> { ) } +pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> { + leave_fullscreen(terminal) +} + async fn run( - terminal: &mut FullscreenTerminal, + terminal: &mut ConsoleTerminal, pod_name: String, socket_path: &std::path::Path, runtime_command: PodRuntimeCommand, @@ -480,7 +462,7 @@ fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender Result<(), Box> { let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/dashboard/mod.rs similarity index 52% rename from crates/tui/src/multi_pod.rs rename to crates/tui/src/dashboard/mod.rs index 55990976..fb7106d7 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/dashboard/mod.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::error::Error; use std::fmt; use std::io; use std::path::{Path, PathBuf}; @@ -59,26 +60,30 @@ use crate::workspace_panel::{ workspace_companion_pod_name, workspace_orchestrator_pod_name, }; +mod render; + +use render::{PanelListRow, row_hit_boxes}; + const MAX_ENTRIES: usize = 50; const CLOSED_VISIBLE_ROWS: usize = 3; const ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE: &str = - include_str!("../../../resources/prompts/panel/orchestrator_idle_queue_notice.md"); + include_str!("../../../../resources/prompts/panel/orchestrator_idle_queue_notice.md"); const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6; const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120; const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400; const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); -const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); +const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100); const PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS: usize = 4_000; #[derive(Debug)] -pub(crate) enum MultiPodError { +pub(crate) enum DashboardError { Io(io::Error), Store(session_store::StoreError), NoPods, } -impl std::fmt::Display for MultiPodError { +impl std::fmt::Display for DashboardError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "io error: {e}"), @@ -91,25 +96,65 @@ impl std::fmt::Display for MultiPodError { } } -impl std::error::Error for MultiPodError {} +impl std::error::Error for DashboardError {} -impl From for MultiPodError { +impl From for DashboardError { fn from(e: io::Error) -> Self { Self::Io(e) } } -impl From for MultiPodError { +impl From for DashboardError { fn from(e: session_store::StoreError) -> Self { Self::Store(e) } } -pub(crate) enum MultiPodOutcome { +pub(crate) enum DashboardOutcome { Quit, Open(OpenPodRequest), } +pub(crate) async fn launch(runtime_command: PodRuntimeCommand) -> Result<(), Box> { + let mut app = load_app(runtime_command.clone()).await?; + let mut terminal = crate::console::enter_dashboard_fullscreen()?; + loop { + match run_loop(&mut terminal, &mut app).await? { + DashboardOutcome::Quit => { + crate::console::leave_dashboard_fullscreen(&mut terminal)?; + return Ok(()); + } + DashboardOutcome::Open(request) => { + let pod_name = request.pod_name.clone(); + let console_request = crate::console::DashboardConsoleOpenRequest { + pod_name: request.pod_name, + socket_override: request.socket_override, + }; + let result = crate::console::open_from_dashboard( + &mut terminal, + console_request, + runtime_command.clone(), + ) + .await; + if let Err(error) = result { + app.finish_open(&pod_name, Err(&error)); + crate::console::leave_dashboard_fullscreen(&mut terminal)?; + if crate::console::is_recoverable_dashboard_open_error(error.as_ref()) { + return Ok(()); + } + return Err(error); + } + app.finish_open(&pod_name, Ok(())); + app = load_app(runtime_command.clone()).await?; + let key = PanelRowKey::Pod(pod_name); + if app.panel.row(&key).is_some() { + app.select_panel_key(key); + } + } + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct OpenPodRequest { pub(crate) pod_name: String, @@ -118,25 +163,25 @@ pub(crate) struct OpenPodRequest { pub(crate) async fn load_app( runtime_command: PodRuntimeCommand, -) -> Result { - Ok(MultiPodApp::loading(runtime_command)) +) -> Result { + Ok(DashboardApp::loading(runtime_command)) } -pub(crate) async fn run( +async fn run_loop( terminal: &mut Terminal>, - app: &mut MultiPodApp, -) -> Result { + app: &mut DashboardApp, +) -> Result { if app.panel.rows.is_empty() && app.panel.header.diagnostics.is_empty() && app.enter_reload.is_none() { - return Err(MultiPodError::NoPods); + return Err(DashboardError::NoPods); } let mut pending_reload = PendingReload::default(); let mut pending_queue_attention_notice = PendingQueueAttentionNotice::default(); let mut deferred_enter_reload = app.enter_reload.take(); - let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + let mut next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; #[cfg(feature = "e2e-test")] let mut emitted_panel_ready = false; @@ -151,7 +196,7 @@ pub(crate) async fn run( } } - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| render::draw(f, app))?; #[cfg(feature = "e2e-test")] { if !emitted_panel_ready { @@ -174,7 +219,7 @@ pub(crate) async fn run( let now = Instant::now(); if now >= next_poll { pending_reload.start(OrchestratorLifecycleMode::Observe); - next_poll = now + MULTI_POD_POLL_INTERVAL; + next_poll = now + DASHBOARD_POLL_INTERVAL; continue; } @@ -185,17 +230,17 @@ pub(crate) async fn run( match read()? { TermEvent::Key(key) => match app.handle_key(key) { - MultiPodAction::None => {} - MultiPodAction::Quit => { + DashboardAction::None => {} + DashboardAction::Quit => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit("panel", "quit_requested", serde_json::json!({})); abort_panel_background_work_for_quit( &mut pending_reload, &mut pending_queue_attention_notice, ); - return Ok(MultiPodOutcome::Quit); + return Ok(DashboardOutcome::Quit); } - MultiPodAction::Open => { + DashboardAction::Open => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", @@ -203,11 +248,11 @@ pub(crate) async fn run( serde_json::json!({ "action": "open" }), ); if let Some(request) = app.prepare_open() { - terminal.draw(|f| draw(f, app))?; - return Ok(MultiPodOutcome::Open(request)); + terminal.draw(|f| render::draw(f, app))?; + return Ok(DashboardOutcome::Open(request)); } } - MultiPodAction::DispatchTicketAction(request) => { + DashboardAction::DispatchTicketAction(request) => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", @@ -216,15 +261,15 @@ pub(crate) async fn run( ); pending_reload.abort(); pending_queue_attention_notice.abort(); - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| render::draw(f, app))?; let result = dispatch_ticket_action(request).await; app.finish_ticket_action_dispatch(result); if pending_reload.start(OrchestratorLifecycleMode::Observe) { app.refreshing = true; } - next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; } - MultiPodAction::ReturnReadyTicketToPlanning(request) => { + DashboardAction::ReturnReadyTicketToPlanning(request) => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", @@ -233,12 +278,12 @@ pub(crate) async fn run( ); pending_reload.abort(); pending_queue_attention_notice.abort(); - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| render::draw(f, app))?; match dispatch_ready_ticket_planning_return(request).await { Ok(outcome) => { match app.finish_ready_ticket_planning_return_success(outcome) { ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request) => { - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| render::draw(f, app))?; let planning_notice = app.notice.clone().unwrap_or_default(); let result = launch_intake_with_handoff(request).await; app.finish_ready_ticket_planning_return_with_intake_launch( @@ -247,8 +292,8 @@ pub(crate) async fn run( ); } ReadyTicketPlanningReturnAfterMutation::OpenClaim(request) => { - terminal.draw(|f| draw(f, app))?; - return Ok(MultiPodOutcome::Open(request)); + terminal.draw(|f| render::draw(f, app))?; + return Ok(DashboardOutcome::Open(request)); } ReadyTicketPlanningReturnAfterMutation::None => {} } @@ -258,9 +303,9 @@ pub(crate) async fn run( if pending_reload.start(OrchestratorLifecycleMode::Observe) { app.refreshing = true; } - next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; } - MultiPodAction::LaunchIntake(request) => { + DashboardAction::LaunchIntake(request) => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", @@ -269,15 +314,15 @@ pub(crate) async fn run( ); pending_reload.abort(); pending_queue_attention_notice.abort(); - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| render::draw(f, app))?; let result = launch_intake_with_handoff(request).await; app.finish_intake_launch(result); if pending_reload.start(OrchestratorLifecycleMode::Observe) { app.refreshing = true; } - next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; } - MultiPodAction::SendCompanion(request) => { + DashboardAction::SendCompanion(request) => { #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", @@ -286,13 +331,13 @@ pub(crate) async fn run( ); pending_reload.abort(); pending_queue_attention_notice.abort(); - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| render::draw(f, app))?; let result = dispatch_companion_message(request).await; app.finish_companion_send(result); if pending_reload.start(OrchestratorLifecycleMode::Observe) { app.refreshing = true; } - next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; } }, TermEvent::Paste(text) => app.input.insert_paste(text), @@ -306,7 +351,7 @@ pub(crate) async fn run( } struct PendingReload { - handle: Option>>, + handle: Option>>, } impl PendingReload { @@ -326,7 +371,7 @@ impl PendingReload { self.handle = Some(tokio::spawn(async move { #[cfg(feature = "e2e-test")] crate::e2e_observer::hold_background_task_if_requested("reload").await; - load_multi_pod_snapshot(None, lifecycle_mode).await + load_dashboard_snapshot(None, lifecycle_mode).await })); true } @@ -334,7 +379,7 @@ impl PendingReload { #[cfg(test)] fn start_with_handle( &mut self, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, ) -> bool { if self.handle.is_some() { handle.abort(); @@ -344,7 +389,7 @@ impl PendingReload { true } - async fn finish_if_ready(&mut self) -> Option> { + async fn finish_if_ready(&mut self) -> Option> { if !self.handle.as_ref()?.is_finished() { return None; } @@ -357,7 +402,7 @@ impl PendingReload { ); Some(match handle.await { Ok(result) => result, - Err(e) => Err(MultiPodError::Io(io::Error::other(format!( + Err(e) => Err(DashboardError::Io(io::Error::other(format!( "reload task failed: {e}" )))), }) @@ -457,20 +502,20 @@ fn abort_panel_background_work_for_quit( pending_queue_attention_notice.abort(); } -fn default_store_dir() -> Result { +fn default_store_dir() -> Result { manifest::paths::sessions_dir().ok_or_else(|| { - MultiPodError::Io(io::Error::new( + DashboardError::Io(io::Error::new( io::ErrorKind::NotFound, "could not resolve sessions directory", )) }) } -fn default_pod_store_dir() -> Result { +fn default_pod_store_dir() -> Result { manifest::paths::data_dir() .map(|dir| dir.join("pods")) .ok_or_else(|| { - MultiPodError::Io(io::Error::new( + DashboardError::Io(io::Error::new( io::ErrorKind::NotFound, "could not resolve pod state directory", )) @@ -1136,7 +1181,7 @@ fn panel_e2e_dashboard_content_is_ready( }) } -pub(crate) struct MultiPodApp { +pub(crate) struct DashboardApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, pub(crate) input: InputBuffer, @@ -1158,7 +1203,7 @@ pub(crate) struct MultiPodApp { emitted_dashboard_content_ready: bool, } -impl MultiPodApp { +impl DashboardApp { fn loading(runtime_command: PodRuntimeCommand) -> Self { let workspace_root = current_workspace_root(); let mut panel = WorkspacePanelViewModel::empty(&workspace_root); @@ -1197,7 +1242,7 @@ impl MultiPodApp { } } - fn apply_reload_result(&mut self, result: Result) { + fn apply_reload_result(&mut self, result: Result) { self.refreshing = false; match result { Ok(snapshot) => self.apply_reloaded_snapshot(snapshot), @@ -1216,10 +1261,10 @@ impl MultiPodApp { .filter(|name| list.entries.iter().any(|entry| entry.name == *name)) .or_else(|| list.entries.first().map(|entry| entry.name.clone())); let panel = build_workspace_panel(¤t_workspace_root(), &list); - self.apply_reloaded_snapshot(MultiPodSnapshot { list, panel }); + self.apply_reloaded_snapshot(DashboardSnapshot { list, panel }); } - fn apply_reloaded_snapshot(&mut self, mut snapshot: MultiPodSnapshot) { + fn apply_reloaded_snapshot(&mut self, mut snapshot: DashboardSnapshot) { self.apply_companion_lifecycle_memory(&mut snapshot.panel); self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel); let previous_selected_pod = self.list.selected_name.clone(); @@ -2323,17 +2368,17 @@ impl MultiPodApp { } } - fn handle_key(&mut self, key: KeyEvent) -> MultiPodAction { + fn handle_key(&mut self, key: KeyEvent) -> DashboardAction { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if self.panel_diagnostic_open { return match key.code { KeyCode::Esc | KeyCode::F(2) => { self.panel_diagnostic_open = false; - MultiPodAction::None + DashboardAction::None } - KeyCode::Char('d') if ctrl => MultiPodAction::Quit, - KeyCode::Char('c') if ctrl => MultiPodAction::Quit, - _ => MultiPodAction::None, + KeyCode::Char('d') if ctrl => DashboardAction::Quit, + KeyCode::Char('c') if ctrl => DashboardAction::Quit, + _ => DashboardAction::None, }; } @@ -2341,7 +2386,7 @@ impl MultiPodApp { if let Some(action) = composer_action { if action.is_modifier_action() { self.apply_composer_edit_action(action); - return MultiPodAction::None; + return DashboardAction::None; } } @@ -2352,75 +2397,75 @@ impl MultiPodApp { } else { self.notice = Some("No Panel diagnostic details yet".to_string()); } - MultiPodAction::None + DashboardAction::None } - KeyCode::Char('d') if ctrl => MultiPodAction::Quit, - KeyCode::Char('c') if ctrl => MultiPodAction::Quit, + KeyCode::Char('d') if ctrl => DashboardAction::Quit, + KeyCode::Char('c') if ctrl => DashboardAction::Quit, KeyCode::Esc => { self.clear_panel_selection(); self.notice = Some( "Row selection cleared; composer draft and target are unchanged.".to_string(), ); - MultiPodAction::None + DashboardAction::None } KeyCode::Tab => { // Completion owns Tab before panel target switching when a - // completion popup exists. The workspace panel currently has + // completion popup exists. The workspace Dashboard currently has // no completion source, so this is the target switch path. self.cycle_composer_target(); - MultiPodAction::None + DashboardAction::None } KeyCode::Up if self.composer_is_blank() => { self.select_prev(); - MultiPodAction::None + DashboardAction::None } KeyCode::Down if self.composer_is_blank() => { self.select_next(); - MultiPodAction::None + DashboardAction::None } KeyCode::Enter if self.composer_is_blank() && self.selected_ticket_action() == Some(NextUserAction::Clarify) => { self.prepare_existing_ticket_intake_launch() - .map(MultiPodAction::LaunchIntake) - .unwrap_or(MultiPodAction::None) + .map(DashboardAction::LaunchIntake) + .unwrap_or(DashboardAction::None) } KeyCode::Enter if self.composer_is_blank() && self.selected_ticket_action().is_some() => { self.prepare_ticket_action_dispatch() - .map(MultiPodAction::DispatchTicketAction) - .unwrap_or(MultiPodAction::None) + .map(DashboardAction::DispatchTicketAction) + .unwrap_or(DashboardAction::None) } - KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open, + KeyCode::Enter if self.composer_is_blank() => DashboardAction::Open, KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake && self.selected_ticket_action() == Some(NextUserAction::Queue) => { self.prepare_ready_ticket_planning_return() - .map(MultiPodAction::ReturnReadyTicketToPlanning) - .unwrap_or(MultiPodAction::None) + .map(DashboardAction::ReturnReadyTicketToPlanning) + .unwrap_or(DashboardAction::None) } KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake => self .prepare_intake_launch() - .map(MultiPodAction::LaunchIntake) - .unwrap_or(MultiPodAction::None), + .map(DashboardAction::LaunchIntake) + .unwrap_or(DashboardAction::None), KeyCode::Enter => self .prepare_companion_send() - .map(MultiPodAction::SendCompanion) - .unwrap_or(MultiPodAction::None), + .map(DashboardAction::SendCompanion) + .unwrap_or(DashboardAction::None), _ => { if let Some(action) = composer_action { self.apply_composer_edit_action(action); } - MultiPodAction::None + DashboardAction::None } } } } -enum MultiPodAction { +enum DashboardAction { None, Quit, Open, @@ -2431,7 +2476,7 @@ enum MultiPodAction { } #[derive(Debug, Clone)] -struct MultiPodSnapshot { +struct DashboardSnapshot { list: PodList, panel: WorkspacePanelViewModel, } @@ -2478,10 +2523,10 @@ enum OrchestratorLifecycleMode { Observe, } -async fn load_multi_pod_snapshot( +async fn load_dashboard_snapshot( selected_name: Option, lifecycle_mode: OrchestratorLifecycleMode, -) -> Result { +) -> Result { let workspace_root = current_workspace_root(); #[cfg(feature = "e2e-test")] let load_started = Instant::now(); @@ -2640,7 +2685,7 @@ async fn load_multi_pod_snapshot( diagnostics: panel.header.diagnostics.len(), }, ); - Ok(MultiPodSnapshot { list, panel }) + Ok(DashboardSnapshot { list, panel }) } #[derive(Debug, Clone)] @@ -2889,7 +2934,7 @@ fn build_orchestrator_launch_context( pod_name: &str, ) -> TicketRoleLaunchContext { let mut context = TicketRoleLaunchContext::new( - original_workspace_root.to_path_buf(), + orchestration_workspace_root.to_path_buf(), TicketRole::Orchestrator, ) .with_cwd(orchestration_workspace_root.to_path_buf()) @@ -2897,7 +2942,7 @@ fn build_orchestrator_launch_context( .with_target_workspace_root(original_workspace_root.to_path_buf()); context.pod_name = Some(pod_name.to_string()); context.user_instruction = Some( - "Workspace panel opened for this Ticket-enabled workspace. Coordinate Ticket routing and wait for explicit follow-up before spawning role Pods." + "Workspace Dashboard opened for this Ticket-enabled workspace. Coordinate Ticket routing and wait for explicit follow-up before spawning role Pods." .to_string(), ); context @@ -3378,7 +3423,7 @@ fn existing_ticket_claim_notice( async fn load_pod_list( selected_name: Option, max_entries: usize, -) -> Result { +) -> Result { let store_dir = default_store_dir()?; let store = FsStore::new(&store_dir)?; let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?; @@ -3870,13 +3915,13 @@ async fn dispatch_ready_ticket_planning_return( TicketConfigAvailability::Usable => {} TicketConfigAvailability::Absent => { return Err(TicketActionError::Stale( - "Ticket config is absent; workspace panel no longer exposes Ticket actions" + "Ticket config is absent; workspace Dashboard no longer exposes Ticket actions" .to_string(), )); } TicketConfigAvailability::Unusable(message) => { return Err(TicketActionError::Stale(format!( - "Ticket config is unusable; workspace panel no longer exposes Ticket actions: {message}" + "Ticket config is unusable; workspace Dashboard no longer exposes Ticket actions: {message}" ))); } } @@ -4040,13 +4085,13 @@ async fn dispatch_ticket_action( TicketConfigAvailability::Usable => {} TicketConfigAvailability::Absent => { return Err(TicketActionError::Stale( - "Ticket config is absent; workspace panel no longer exposes Ticket actions" + "Ticket config is absent; workspace Dashboard no longer exposes Ticket actions" .to_string(), )); } TicketConfigAvailability::Unusable(message) => { return Err(TicketActionError::Stale(format!( - "Ticket config is unusable; workspace panel no longer exposes Ticket actions: {message}" + "Ticket config is unusable; workspace Dashboard no longer exposes Ticket actions: {message}" ))); } } @@ -4860,12 +4905,12 @@ fn panel_close_resolution( ) -> ticket::MarkdownText { if is_japanese_ticket_record_language(record_language) { ticket::MarkdownText::new(format!( - "Ticket `{}` (`{}`) はすでに `state: done` に到達していたため、workspace Panel から close しました。\n\nこの Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n", + "Ticket `{}` (`{}`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。\n\nこの Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n", ticket.meta.id, ticket.meta.title )) } else { ticket::MarkdownText::new(format!( - "Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `state: done`.\n\nNo implementation work, state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n", + "Closed from the workspace Dashboard because Ticket `{}` (`{}`) had already reached `state: done`.\n\nNo implementation work, state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n", ticket.meta.id, ticket.meta.title )) } @@ -4887,7 +4932,7 @@ fn orchestrator_queue_notification_message( ) -> String { let title = ticket.title.replace(['\r', '\n'], " "); format!( - "Workspace panel Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default). After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.", + "Workspace Dashboard Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default). After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.", ticket.id, title.trim() ) @@ -5080,19 +5125,19 @@ fn row_status_label(entry: &PodListEntry) -> (&'static str, Style) { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MultiPodSectionKind { +enum DashboardSectionKind { Pending, Working, Closed, } #[derive(Debug, Clone, PartialEq, Eq)] -struct MultiPodSection { - kind: MultiPodSectionKind, +struct DashboardSection { + kind: DashboardSectionKind, entries: Vec, } -impl MultiPodSection { +impl DashboardSection { fn hidden_count(&self) -> usize { self.entries .len() @@ -5100,37 +5145,37 @@ impl MultiPodSection { } } -fn classify_entry(entry: &PodListEntry) -> MultiPodSectionKind { +fn classify_entry(entry: &PodListEntry) -> DashboardSectionKind { if entry.live.is_some() { if entry.actions.can_send_now { - MultiPodSectionKind::Pending + DashboardSectionKind::Pending } else { - MultiPodSectionKind::Working + DashboardSectionKind::Working } } else { - MultiPodSectionKind::Closed + DashboardSectionKind::Closed } } -fn sectioned_entries(list: &PodList) -> Vec { - let mut pending = MultiPodSection { - kind: MultiPodSectionKind::Pending, +fn sectioned_entries(list: &PodList) -> Vec { + let mut pending = DashboardSection { + kind: DashboardSectionKind::Pending, entries: Vec::new(), }; - let mut working = MultiPodSection { - kind: MultiPodSectionKind::Working, + let mut working = DashboardSection { + kind: DashboardSectionKind::Working, entries: Vec::new(), }; - let mut closed = MultiPodSection { - kind: MultiPodSectionKind::Closed, + let mut closed = DashboardSection { + kind: DashboardSectionKind::Closed, entries: Vec::new(), }; for (index, entry) in list.entries.iter().enumerate() { match classify_entry(entry) { - MultiPodSectionKind::Pending => pending.entries.push(index), - MultiPodSectionKind::Working => working.entries.push(index), - MultiPodSectionKind::Closed => closed.entries.push(index), + DashboardSectionKind::Pending => pending.entries.push(index), + DashboardSectionKind::Working => working.entries.push(index), + DashboardSectionKind::Closed => closed.entries.push(index), } } @@ -5160,7 +5205,7 @@ fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec Vec { +fn visible_section_indices(section: &DashboardSection) -> Vec { section .entries .iter() @@ -5169,30 +5214,30 @@ fn visible_section_indices(section: &MultiPodSection) -> Vec { .collect() } -fn visible_section_len(kind: MultiPodSectionKind, len: usize) -> usize { +fn visible_section_len(kind: DashboardSectionKind, len: usize) -> usize { match kind { - MultiPodSectionKind::Pending | MultiPodSectionKind::Working => len, - MultiPodSectionKind::Closed => len.min(CLOSED_VISIBLE_ROWS), + DashboardSectionKind::Pending | DashboardSectionKind::Working => len, + DashboardSectionKind::Closed => len.min(CLOSED_VISIBLE_ROWS), } } fn section_header_line( - kind: MultiPodSectionKind, + kind: DashboardSectionKind, total: usize, hidden: usize, width: u16, ) -> Line<'static> { let label = match kind { - MultiPodSectionKind::Pending => "pending", - MultiPodSectionKind::Working => "working", - MultiPodSectionKind::Closed => "closed", + DashboardSectionKind::Pending => "pending", + DashboardSectionKind::Working => "working", + DashboardSectionKind::Closed => "closed", }; let detail = if hidden > 0 { format!(" {total} total, +{hidden} hidden") } else { String::new() }; - let text = truncate_with_ellipsis(&format!("--{label}{detail}---"), width as usize); + let text = render::truncate_with_ellipsis(&format!("--{label}{detail}---"), width as usize); Line::from(Span::styled( text, Style::default() @@ -5202,7 +5247,7 @@ fn section_header_line( } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct MultiPodLayoutState { +struct DashboardLayoutState { title: Rect, list: Rect, boundary: Rect, @@ -5212,7 +5257,7 @@ struct MultiPodLayoutState { list_draws_own_separator: bool, } -fn multi_pod_layout(area: Rect, input_height: u16) -> MultiPodLayoutState { +fn dashboard_layout(area: Rect, input_height: u16) -> DashboardLayoutState { let chunks = Layout::vertical([ Constraint::Length(1), Constraint::Min(0), @@ -5223,7 +5268,7 @@ fn multi_pod_layout(area: Rect, input_height: u16) -> MultiPodLayoutState { ]) .split(area); - MultiPodLayoutState { + DashboardLayoutState { title: chunks[0], list: chunks[1], boundary: chunks[2], @@ -5234,4219 +5279,6 @@ fn multi_pod_layout(area: Rect, input_height: u16) -> MultiPodLayoutState { } } -fn draw(frame: &mut Frame<'_>, app: &mut MultiPodApp) { - let area = frame.area(); - let input_content_width = area.width.saturating_sub(2).max(1); - let mut input_render = app.input.render(input_content_width); - let input_height = input_area_height(&input_render, area.height); - app.input - .apply_cursor_viewport(&mut input_render, input_height); - let layout = multi_pod_layout(area, input_height); - - draw_title(frame, app, layout.title); - draw_list(frame, app, layout.list); - draw_separator(frame, layout.boundary); - draw_target_status(frame, app, layout.target_status); - draw_input(frame, &input_render, layout.input); - draw_actionbar(frame, app, layout.actionbar); - if app.panel_diagnostic_open { - render_panel_diagnostic(frame, app, area); - } -} - -fn panel_diagnostic_area(area: Rect) -> Rect { - let width = if area.width <= 20 { - area.width - } else { - area.width.saturating_sub(4).min(100).max(20) - }; - let height = if area.height <= 8 { - area.height - } else { - area.height.saturating_sub(4).min(24).max(8) - }; - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new(x, y, width, height) -} - -fn render_panel_diagnostic(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let Some(diagnostic) = app.panel_diagnostic.as_ref() else { - return; - }; - let popup_area = panel_diagnostic_area(area); - let title = format!(" {} ", diagnostic.title); - let text = format!("{}\n\nF2/Esc: close", diagnostic.details); - let paragraph = Paragraph::new(text) - .block(Block::default().title(title).borders(Borders::ALL)) - .wrap(Wrap { trim: false }); - frame.render_widget(Clear, popup_area); - frame.render_widget(paragraph, popup_area); -} - -fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { - let needed = render.lines.len().max(1) as u16; - let cap = (terminal_height / 3).max(1).min(10); - needed.clamp(1, cap) -} - -fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let guidance = if app - .panel - .composer - .is_available(ComposerTarget::TicketIntake) - { - " Row selection: blank Enter opens/dispatches · text Enter uses target · Tab target" - } else if app.panel.header.ticket_configured { - " Row selection: blank Enter opens/dispatches · text Enter sends to Companion" - } else { - " Pod-centric view · Row selection: blank Enter opens · text Enter sends to Companion" - }; - let mut spans = vec![ - Span::styled( - "workspace dashboard", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled(guidance, Style::default().fg(Color::DarkGray)), - ]; - if let Some(companion) = &app.panel.header.companion { - spans.push(Span::styled( - " · companion ", - Style::default().fg(Color::DarkGray), - )); - spans.push(Span::styled( - companion.status.label(), - companion_status_style(companion.status), - )); - if let Some(detail) = companion.detail.as_deref() { - spans.push(Span::styled( - format!(" ({detail})"), - Style::default().fg(Color::DarkGray), - )); - } - } - if let Some(orchestrator) = &app.panel.header.orchestrator { - spans.push(Span::styled( - " · orchestrator ", - Style::default().fg(Color::DarkGray), - )); - spans.push(Span::styled( - orchestrator.status.label(), - orchestrator_status_style(orchestrator.status), - )); - } - frame.render_widget(Paragraph::new(Line::from(spans)), area); -} - -fn companion_status_style(status: CompanionPanelStatus) -> Style { - match status { - CompanionPanelStatus::Live - | CompanionPanelStatus::Restored - | CompanionPanelStatus::Spawned => Style::default().fg(Color::Green), - CompanionPanelStatus::Stopped | CompanionPanelStatus::Missing => { - Style::default().fg(Color::Yellow) - } - CompanionPanelStatus::Unavailable => Style::default().fg(Color::Red), - } -} - -fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style { - match status { - OrchestratorPanelStatus::Live - | OrchestratorPanelStatus::Restored - | OrchestratorPanelStatus::Spawned => Style::default().fg(Color::Green), - OrchestratorPanelStatus::Stopped | OrchestratorPanelStatus::Missing => { - Style::default().fg(Color::Yellow) - } - OrchestratorPanelStatus::Unavailable => Style::default().fg(Color::Red), - } -} - -fn draw_list(frame: &mut Frame<'_>, app: &mut MultiPodApp, area: Rect) { - if area.width == 0 || area.height == 0 { - app.row_hit_boxes.clear(); - return; - } - let rows = list_rows(app, area.width, area.height); - app.set_row_hit_boxes(&rows, area); - let lines = rows.into_iter().map(|row| row.line).collect::>(); - Paragraph::new(lines).render(area, frame.buffer_mut()); -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PanelListRow { - line: Line<'static>, - key: Option, -} - -impl PanelListRow { - fn inert(line: Line<'static>) -> Self { - Self { line, key: None } - } - - fn selectable(line: Line<'static>, key: PanelRowKey) -> Self { - Self { - line, - key: Some(key), - } - } -} - +// Rendering and layout composition live in dashboard/render.rs. #[cfg(test)] -fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec> { - list_rows(app, width, height) - .into_iter() - .map(|row| row.line) - .collect() -} - -fn list_rows(app: &MultiPodApp, width: u16, height: u16) -> Vec { - let sections = sectioned_entries(&app.list); - let selected = app.selected_row.as_ref(); - let diagnostic_rows = panel_diagnostic_lines(&app.panel, width) - .into_iter() - .map(PanelListRow::inert) - .collect::>(); - let action_rows = panel_action_rows(&app.panel, selected, width); - let live_rows = sections - .iter() - .filter(|section| section.kind != MultiPodSectionKind::Closed) - .flat_map(|section| section_rows(&app.list, section, selected, width)) - .collect::>(); - let closed_rows = sections - .iter() - .find(|section| section.kind == MultiPodSectionKind::Closed) - .map(|section| section_rows(&app.list, section, selected, width)) - .unwrap_or_default(); - - let available = height as usize; - let diagnostic_len = diagnostic_rows.len().min(available); - let remaining_after_diagnostics = available.saturating_sub(diagnostic_len); - let action_len = action_rows.len().min(remaining_after_diagnostics); - let remaining_after_actions = remaining_after_diagnostics.saturating_sub(action_len); - let closed_len = closed_rows.len().min(remaining_after_actions); - let live_len = live_rows - .len() - .min(remaining_after_actions.saturating_sub(closed_len)); - let spacer_len = available.saturating_sub(diagnostic_len + action_len + live_len + closed_len); - - let mut rows = Vec::with_capacity(available); - rows.extend(diagnostic_rows.into_iter().take(diagnostic_len)); - rows.extend(action_rows.into_iter().take(action_len)); - rows.extend(live_rows.into_iter().take(live_len)); - rows.extend( - std::iter::repeat_with(|| PanelListRow::inert(Line::from(Span::raw("")))).take(spacer_len), - ); - rows.extend(closed_rows.into_iter().take(closed_len)); - rows -} - -fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec { - if area.width == 0 || area.height == 0 { - return Vec::new(); - } - - let mut hit_boxes: Vec = Vec::new(); - for (offset, row) in rows.iter().enumerate() { - let Some(key) = row.key.clone() else { - continue; - }; - let Some(y) = area.y.checked_add(offset as u16) else { - continue; - }; - if y >= area.y.saturating_add(area.height) { - continue; - } - if let Some(last) = hit_boxes.last_mut() { - if last.key == key - && last.rect.x == area.x - && last.rect.width == area.width - && last.rect.y.saturating_add(last.rect.height) == y - { - last.rect.height = last.rect.height.saturating_add(1); - continue; - } - } - hit_boxes.push(PanelRowHitBox { - rect: Rect::new(area.x, y, area.width, 1), - key, - }); - } - hit_boxes -} - -fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec> { - panel - .header - .diagnostics - .iter() - .map(|diagnostic| { - Line::from(vec![ - Span::styled("⚠ ", Style::default().fg(Color::Yellow)), - Span::styled( - truncate_with_ellipsis(diagnostic, width.saturating_sub(2) as usize), - Style::default().fg(Color::Yellow), - ), - ]) - }) - .collect() -} - -fn panel_action_rows( - panel: &WorkspacePanelViewModel, - selected: Option<&PanelRowKey>, - width: u16, -) -> Vec { - let rows = panel - .rows - .iter() - .filter(|row| row.is_ticket_section_row()) - .collect::>(); - if rows.is_empty() { - return Vec::new(); - } - let mut lines = Vec::with_capacity((rows.len() * 2) + 1); - lines.push(PanelListRow::inert(panel_action_header_line( - rows.len(), - width, - ))); - for row in rows { - for line in panel_row_lines(row, selected == Some(&row.key), width) { - lines.push(PanelListRow::selectable(line, row.key.clone())); - } - } - lines -} - -fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { - let detail = if total == 1 { - " 1 row".to_string() - } else { - format!(" {total} rows") - }; - let text = truncate_with_ellipsis(&format!("--tickets{detail}---"), width as usize); - Line::from(Span::styled( - text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - )) -} - -const TICKET_STATE_COLUMN_WIDTH: usize = 10; -const POD_STATUS_COLUMN_WIDTH: usize = 18; - -fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec> { - if row.kind == PanelRowKind::TicketIntakePod { - vec![panel_intake_child_line(row, selected, width)] - } else { - vec![ - panel_row_title_line(row, selected, width), - panel_row_detail_line(row, selected, width), - ] - } -} - -fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { - let title_style = if selected { - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Magenta) - }; - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_ticket_primary_marker_span(&mut spans, selected, &mut remaining); - push_column_span( - &mut spans, - &row.status, - TICKET_STATE_COLUMN_WIDTH, - panel_priority_style(row.priority), - &mut remaining, - ); - push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); - - Line::from(spans) -} - -fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { - let title_style = if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Cyan) - }; - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_intake_child_marker_span(&mut spans, selected, &mut remaining); - push_column_span( - &mut spans, - &row.status, - TICKET_STATE_COLUMN_WIDTH, - intake_status_style(&row.status), - &mut remaining, - ); - push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); - - Line::from(spans) -} - -fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_ticket_detail_marker_span(&mut spans, selected, &mut remaining); - push_bounded_span( - &mut spans, - "meta ", - Style::default().fg(Color::DarkGray), - &mut remaining, - ); - push_bounded_span( - &mut spans, - &panel_ticket_detail(row), - ticket_detail_style(row), - &mut remaining, - ); - - Line::from(spans) -} - -fn push_ticket_primary_marker_span( - spans: &mut Vec>, - selected: bool, - remaining: &mut usize, -) { - let (marker, style) = if selected { - ( - "▶ ", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ) - } else { - (" ", Style::default().fg(Color::DarkGray)) - }; - push_bounded_span(spans, marker, style, remaining); -} - -fn push_ticket_detail_marker_span( - spans: &mut Vec>, - selected: bool, - remaining: &mut usize, -) { - let (marker, style) = if selected { - ( - "│ ", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ) - } else { - (" ", Style::default().fg(Color::DarkGray)) - }; - push_bounded_span(spans, marker, style, remaining); -} - -fn push_intake_child_marker_span( - spans: &mut Vec>, - selected: bool, - remaining: &mut usize, -) { - let (marker, style) = if selected { - ( - " ▶ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - } else { - (" └ ", Style::default().fg(Color::DarkGray)) - }; - push_bounded_span(spans, marker, style, remaining); -} - -fn panel_ticket_detail(row: &PanelRow) -> String { - if row.kind == PanelRowKind::InvalidTicket { - let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()]; - if let Some(reason) = panel_ticket_reason(row) { - parts.push(format!("Reason: {reason}")); - } - return parts.join(" · "); - } - - if row.kind == PanelRowKind::TicketIntakePod { - let mut parts = row - .subtitle - .as_ref() - .map(|subtitle| vec![subtitle.clone()]) - .unwrap_or_else(|| vec![panel_ticket_reference(row)]); - if let Some(action) = row.next_action { - parts.push(format!("Action: {}", action.label())); - } - if let Some(reason) = panel_ticket_reason(row) { - parts.push(format!("Reason: {reason}")); - } - return parts.join(" · "); - } - - let mut parts = vec![panel_ticket_reference(row)]; - if let Some(overlay_detail) = panel_ticket_overlay_detail(row) { - parts.push(overlay_detail); - } - if let Some(blocked_reason) = row - .ticket - .as_ref() - .and_then(|ticket| ticket.blocked_reason.as_deref()) - { - parts.push(format!("Gate: waiting for {blocked_reason}")); - } else { - parts.push("Gate: clear".to_string()); - } - if let Some(action) = row.next_action { - parts.push(format!( - "Action: {}", - panel_ticket_action_label(row, action) - )); - } - if let Some(reason) = panel_ticket_reason(row) { - parts.push(format!("Reason: {reason}")); - } - parts.join(" · ") -} - -fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str { - if action == NextUserAction::Wait - && row - .ticket - .as_ref() - .and_then(|ticket| ticket.blocked_reason.as_ref()) - .is_some() - { - "queue disabled" - } else { - action.label() - } -} - -fn panel_ticket_overlay_detail(row: &PanelRow) -> Option { - let ticket = row.ticket.as_ref()?; - let overlay = ticket.orchestration_overlay.as_ref()?; - let mut detail = format!( - "Overlay: local {} · {} {}", - ticket.workflow_state.as_str(), - overlay.source, - overlay.workflow_state.as_str() - ); - if matches!( - overlay.workflow_state, - TicketWorkflowState::Done | TicketWorkflowState::Closed - ) { - detail.push_str(" · merge pending"); - } - Some(detail) -} - -fn panel_ticket_reason(row: &PanelRow) -> Option<&str> { - row.disabled_reason - .as_deref() - .or_else(|| row.key_hint.as_deref()) -} - -fn ticket_detail_style(row: &PanelRow) -> Style { - if row.kind == PanelRowKind::InvalidTicket { - return Style::default().fg(Color::Yellow); - } - if row - .ticket - .as_ref() - .and_then(|ticket| ticket.blocked_reason.as_ref()) - .is_some() - { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::DarkGray) - } -} - -fn panel_ticket_reference(row: &PanelRow) -> String { - row.ticket - .as_ref() - .map(|ticket| ticket.id.clone()) - .unwrap_or_else(|| match &row.key { - PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), - PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(), - PanelRowKey::Pod(name) => name.clone(), - }) -} - -fn push_column_span( - spans: &mut Vec>, - value: &str, - column_width: usize, - style: Style, - remaining: &mut usize, -) { - if *remaining == 0 { - return; - } - let mut content = padded_cell(value, column_width); - content.push(' '); - push_bounded_span(spans, &content, style, remaining); -} - -fn push_bounded_span( - spans: &mut Vec>, - value: &str, - style: Style, - remaining: &mut usize, -) { - if *remaining == 0 || value.is_empty() { - return; - } - let content = truncate_with_ellipsis(value, *remaining); - *remaining = remaining.saturating_sub(content.width()); - spans.push(Span::styled(content, style)); -} - -fn padded_cell(value: &str, width: usize) -> String { - let mut cell = truncate_with_ellipsis(value, width); - let padding = width.saturating_sub(cell.width()); - cell.extend(std::iter::repeat_n(' ', padding)); - cell -} - -fn panel_priority_style(priority: ActionPriority) -> Style { - match priority { - ActionPriority::ReadyForQueue => Style::default().fg(Color::Green), - ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), - ActionPriority::Background => Style::default().fg(Color::DarkGray), - } -} - -fn intake_status_style(status: &str) -> Style { - match status { - "live" => Style::default().fg(Color::Green), - "restorable" => Style::default().fg(Color::Yellow), - "stale" => Style::default().fg(Color::DarkGray), - _ => Style::default().fg(Color::Cyan), - } -} - -fn section_rows( - list: &PodList, - section: &MultiPodSection, - selected: Option<&PanelRowKey>, - width: u16, -) -> Vec { - let visible = visible_section_indices(section); - if visible.is_empty() { - return Vec::new(); - } - - let mut rows = Vec::with_capacity(visible.len() + 1); - rows.push(PanelListRow::inert(section_header_line( - section.kind, - section.entries.len(), - section.hidden_count(), - width, - ))); - for index in visible { - if let Some(entry) = list.entries.get(index) { - let key = PanelRowKey::Pod(entry.name.clone()); - let selected = selected == Some(&key); - rows.push(PanelListRow::selectable( - row_line(entry, selected, width), - key, - )); - } - } - rows -} - -fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> { - let marker = if selected { "▶ " } else { " " }; - let name_style = if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Cyan) - }; - let (status, status_style) = row_status_label(entry); - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_bounded_span( - &mut spans, - marker, - if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }, - &mut remaining, - ); - push_column_span( - &mut spans, - status, - POD_STATUS_COLUMN_WIDTH, - status_style, - &mut remaining, - ); - push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining); - - Line::from(spans) -} - -fn draw_separator(frame: &mut Frame<'_>, area: Rect) { - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - "─".repeat(area.width as usize), - Style::default().fg(Color::DarkGray), - ))), - area, - ); -} - -fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - frame.render_widget(Paragraph::new(target_status_line(app)), area); -} - -fn target_status_line(app: &MultiPodApp) -> Line<'static> { - if !app.composer_is_blank() { - return Line::from(vec![ - Span::styled("composer target ", Style::default().fg(Color::DarkGray)), - Span::styled( - app.composer_target().label(), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · draft Enter ", Style::default().fg(Color::DarkGray)), - Span::styled( - composer_enter_status_text(app), - Style::default().fg(Color::Green), - ), - Span::styled( - " · row selection waits until composer is blank", - Style::default().fg(Color::DarkGray), - ), - ]); - } - - if let Some(row) = app - .selected_panel_row() - .filter(|row| row.is_ticket_action()) - { - let action = row - .next_action - .map(|action| panel_ticket_action_label(row, action)) - .unwrap_or("View"); - let mut spans = vec![ - Span::styled("composer target ", Style::default().fg(Color::DarkGray)), - Span::styled( - app.composer_target().label(), - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · selected Ticket ", Style::default().fg(Color::DarkGray)), - Span::styled(row.status.clone(), panel_priority_style(row.priority)), - Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)), - Span::styled(action, Style::default().fg(Color::Magenta)), - ]; - if let Some(reason) = panel_ticket_reason(row) { - spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray))); - spans.push(Span::styled( - truncate_with_ellipsis(reason, 100), - ticket_detail_style(row), - )); - } - Line::from(spans) - } else if let Some(row) = app - .selected_panel_row() - .filter(|row| row.kind == PanelRowKind::TicketIntakePod) - { - let ticket_id = panel_ticket_reference(row); - let action = if row.next_action == Some(NextUserAction::OpenPod) { - "open/attach" - } else { - "unavailable" - }; - Line::from(vec![ - Span::styled("composer target ", Style::default().fg(Color::DarkGray)), - Span::styled( - app.composer_target().label(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " · selected Intake Pod ", - Style::default().fg(Color::DarkGray), - ), - Span::styled(row.status.clone(), intake_status_style(&row.status)), - Span::styled( - format!(" · Ticket {ticket_id} · blank Enter {action}"), - Style::default().fg(Color::DarkGray), - ), - ]) - } else if let Some(entry) = app.selected_pod_entry() { - let (status, status_style) = row_status_label(entry); - Line::from(vec![ - Span::styled("composer target ", Style::default().fg(Color::DarkGray)), - Span::styled( - app.composer_target().label(), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" · selected Pod ", Style::default().fg(Color::DarkGray)), - Span::styled(status.to_string(), status_style), - Span::styled( - " · blank Enter open/attach", - Style::default().fg(Color::DarkGray), - ), - ]) - } else { - Line::from(vec![ - Span::styled("composer target ", Style::default().fg(Color::DarkGray)), - Span::styled( - app.composer_target().label(), - Style::default().fg(Color::DarkGray), - ), - Span::styled( - " · no row selected · ↑/↓ selects a row", - Style::default().fg(Color::DarkGray), - ), - ]) - } -} - -fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { - let mut lines: Vec> = Vec::with_capacity(render.lines.len()); - for (i, src) in render.lines.iter().enumerate() { - let absolute_row = render.viewport_start_row as usize + i; - let prefix = if absolute_row == 0 { "> " } else { " " }; - let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))]; - spans.extend(src.spans.iter().cloned()); - lines.push(Line::from(spans)); - } - frame.render_widget(Paragraph::new(lines), area); - - let cursor_x = area.x + 2 + render.cursor_col; - let cursor_y = area.y + render.cursor_row; - if cursor_y < area.y + area.height { - frame.set_cursor_position(Position::new(cursor_x, cursor_y)); - } -} - -fn composer_enter_status_text(app: &MultiPodApp) -> String { - match app.composer_target() { - ComposerTarget::Companion => companion_enter_status_text(app), - ComposerTarget::TicketIntake - if app.selected_ticket_action() == Some(NextUserAction::Queue) => - { - "return selected ready Ticket to planning".to_string() - } - ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(), - } -} - -fn composer_enter_actionbar_text(app: &MultiPodApp) -> String { - match app.composer_target() { - ComposerTarget::Companion => companion_enter_actionbar_text(app), - ComposerTarget::TicketIntake if app.selected_ticket_action() == Some(NextUserAction::Queue) => { - "Ticket Intake target: Enter records instructions and returns selected ready Ticket to planning".to_string() - } - ComposerTarget::TicketIntake => { - "Ticket Intake target: Enter launches Intake with composer text".to_string() - } - } -} - -fn companion_enter_status_text(app: &MultiPodApp) -> String { - match companion_send_availability(app) { - CompanionSendAvailability::Ready => "send composer text to workspace Companion".to_string(), - CompanionSendAvailability::Unavailable(reason) => format!("keep draft; {reason}"), - } -} - -fn companion_enter_actionbar_text(app: &MultiPodApp) -> String { - match companion_send_availability(app) { - CompanionSendAvailability::Ready => { - "Companion target: Enter sends composer text to workspace Companion".to_string() - } - CompanionSendAvailability::Unavailable(reason) => { - format!("Companion target: Enter keeps draft; {reason}") - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CompanionSendAvailability { - Ready, - Unavailable(String), -} - -fn companion_send_availability(app: &MultiPodApp) -> CompanionSendAvailability { - let Some(companion) = app.panel.header.companion.as_ref() else { - return CompanionSendAvailability::Unavailable( - "workspace Companion is unavailable".to_string(), - ); - }; - if matches!( - companion.status, - CompanionPanelStatus::Unavailable - | CompanionPanelStatus::Missing - | CompanionPanelStatus::Stopped - ) { - return CompanionSendAvailability::Unavailable(format!( - "workspace Companion is {}", - companion.status.label() - )); - } - let Some(entry) = app - .list - .entries - .iter() - .find(|entry| entry.name == companion.pod_name) - else { - return CompanionSendAvailability::Unavailable(format!( - "workspace Companion `{}` is not in the Pod list", - companion.pod_name - )); - }; - let Some(live) = entry.live.as_ref() else { - return CompanionSendAvailability::Unavailable(format!( - "workspace Companion `{}` is stopped", - companion.pod_name - )); - }; - if !live.reachable { - return CompanionSendAvailability::Unavailable(format!( - "workspace Companion `{}` is unreachable", - companion.pod_name - )); - } - if live.status == Some(PodStatus::Running) { - return CompanionSendAvailability::Unavailable(format!( - "workspace Companion `{}` is running", - companion.pod_name - )); - } - CompanionSendAvailability::Ready -} - -fn actionbar_left_text(app: &MultiPodApp) -> String { - if app.sending && app.composer_target() == ComposerTarget::TicketIntake { - "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 !app.composer_is_blank() { - composer_enter_actionbar_text(app) - } else if let Some(notice) = app.notice.as_deref() { - notice.to_string() - } else if let Some(reason) = app.selected_open_disabled_reason() { - reason - } else { - match app.composer_target() { - ComposerTarget::Companion => { - "Composer target: Companion; type text to send, or use ↑/↓ then blank Enter for rows" - .to_string() - } - ComposerTarget::TicketIntake => { - if app.selected_ticket_action() == Some(NextUserAction::Queue) { - "Composer target: Ticket Intake; text + Enter returns selected ready Ticket to planning".to_string() - } else { - "Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string() - } - } - } - } -} - -fn actionbar_right_text(app: &MultiPodApp) -> &'static str { - if app.panel_diagnostic_open { - "F2/Esc close details Ctrl+C quit" - } else if app.panel_diagnostic.is_some() { - "F2 details ↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit" - } else if !app.composer_is_blank() { - if app - .panel - .composer - .is_available(ComposerTarget::TicketIntake) - { - "↑/↓ draft lines Left/Right cursor Enter composer target Tab target Esc clear selection Ctrl+C quit" - } else { - "↑/↓ draft lines Left/Right cursor Enter composer target Esc clear selection Ctrl+C quit" - } - } else if app - .panel - .composer - .is_available(ComposerTarget::TicketIntake) - { - "↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit" - } else { - "↑/↓ select row Enter selected row Esc clear selection Left/Right cursor Ctrl+C quit" - } -} - -fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { - let left = actionbar_left_text(app); - let right = actionbar_right_text(app); - let left_width = area - .width - .saturating_sub(right.width() as u16) - .saturating_sub(2) as usize; - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - truncate_with_ellipsis(&left, left_width), - Style::default().fg(Color::DarkGray), - ))), - area, - ); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - right, - Style::default().fg(Color::DarkGray), - ))) - .alignment(ratatui::layout::Alignment::Right), - area, - ); -} - -fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - if s.width() <= max_width { - return s.to_string(); - } - if max_width == 1 { - return "…".to_string(); - } - let mut out = String::new(); - let mut width = 0usize; - for c in s.chars() { - let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); - if width + cw > max_width - 1 { - break; - } - out.push(c); - width += cw; - } - out.push('…'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }; - #[test] - fn orchestration_worktree_layout_is_stable_under_original_workspace_root() { - let root = Path::new("/tmp/Yoi Workspace"); - let layout = orchestration_worktree_layout(root); - assert_eq!( - layout.path, - PathBuf::from("/tmp/Yoi Workspace/.worktree/orchestration") - ); - assert_eq!(layout.branch, "orchestration"); - } - - #[test] - fn orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace() { - let original = PathBuf::from("/repo/yoi"); - let orchestration = original - .join(".worktree") - .join("orchestration") - .join("yoi-orchestrator"); - let context = - build_orchestrator_launch_context(&original, &orchestration, "yoi-orchestrator"); - assert_eq!(context.workspace_root, orchestration); - assert_eq!( - context.original_workspace_root.as_deref(), - Some(original.as_path()) - ); - assert_eq!( - context.target_workspace_root.as_deref(), - Some(original.as_path()) - ); - } - - #[test] - fn invalid_existing_orchestration_path_is_diagnostic_not_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - std::fs::create_dir_all(&root).unwrap(); - let layout = orchestration_worktree_layout(&root); - std::fs::create_dir_all(&layout.path).unwrap(); - std::fs::write(layout.path.join("keep.txt"), "do not delete").unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("not a Git worktree")); - assert!(layout.path.join("keep.txt").exists()); - } - - #[test] - fn ensure_orchestration_worktree_creates_and_reuses_git_worktree() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - std::fs::create_dir_all(&root).unwrap(); - run_test_git(&root, &["init"]).unwrap(); - std::fs::write(root.join("README.md"), "repo").unwrap(); - run_test_git(&root, &["add", "README.md"]).unwrap(); - run_test_git( - &root, - &[ - "-c", - "user.email=test@example.invalid", - "-c", - "user.name=Yoi Test", - "commit", - "-m", - "init", - ], - ) - .unwrap(); - - let created = ensure_orchestration_worktree(&root).unwrap(); - assert_eq!(created.status, OrchestrationWorktreeStatus::Created); - assert!(created.layout.path.exists()); - assert!(git_inside_worktree(&created.layout.path)); - - let reused = ensure_orchestration_worktree(&root).unwrap(); - assert_eq!(reused.status, OrchestrationWorktreeStatus::Reused); - assert_eq!(reused.layout, created.layout); - - std::fs::write(created.layout.path.join("dirty.txt"), "dirty").unwrap(); - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("dirty")); - assert!(created.layout.path.join("dirty.txt").exists()); - } - - #[test] - fn ensure_and_restore_use_configured_orchestration_layout() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - write_test_ticket_config( - &root, - r#" -[orchestration] -branch = "orchestration/custom-panel" -worktree_dir = "custom-worktrees" -worktree_name = "panel" -"#, - ); - run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); - run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); - - let resolved = resolved_orchestration_worktree_layout(&root).unwrap(); - assert_eq!(resolved.branch, "orchestration/custom-panel"); - assert!(resolved.path.ends_with("custom-worktrees/panel")); - - let created = ensure_orchestration_worktree(&root).unwrap(); - assert_eq!(created.status, OrchestrationWorktreeStatus::Created); - assert_eq!(created.layout, resolved); - let branch = - run_test_git_output(&created.layout.path, &["branch", "--show-current"]).unwrap(); - assert_eq!(branch.trim(), "orchestration/custom-panel"); - - let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); - assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); - assert_eq!(restored.layout, created.layout); - } - - #[test] - fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - std::fs::create_dir_all(&root).unwrap(); - write_test_ticket_config( - &root, - r#" -[orchestration] -branch = "orchestration/bad:branch" -"#, - ); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("failed to load ticket config")); - assert!(err.contains("git branch name")); - assert!(!root.join(".worktree").exists()); - } - - #[test] - fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - write_test_ticket_config( - &root, - r#" -[orchestration] -branch = "orchestration/custom-panel" -"#, - ); - run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); - run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); - let layout = resolved_orchestration_worktree_layout(&root).unwrap(); - run_test_git( - &root, - &[ - "worktree", - "add", - &layout.path.display().to_string(), - "-b", - "orchestration/other-panel", - "HEAD", - ], - ) - .unwrap(); - - let err = prepare_orchestration_worktree_for_restore(&root).unwrap_err(); - assert!(err.contains("expected orchestration/custom-panel")); - let branch = run_test_git_output(&layout.path, &["branch", "--show-current"]).unwrap(); - assert_eq!(branch.trim(), "orchestration/other-panel"); - } - - #[test] - fn restore_uses_existing_orchestration_worktree_even_when_dirty() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let created = ensure_orchestration_worktree(&root).unwrap(); - std::fs::write(created.layout.path.join("orchestrator-notes.txt"), "dirty").unwrap(); - - let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); - - assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); - assert_eq!(restored.layout.path, created.layout.path); - assert_ne!(restored.layout.path, root); - assert!(restored.layout.path.ends_with(".worktree/orchestration")); - } - - #[test] - fn existing_wrong_branch_worktree_is_rejected_without_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let layout = orchestration_worktree_layout(&root); - run_test_git( - &root, - &[ - "worktree", - "add", - &layout.path.display().to_string(), - "-b", - "wrong-branch", - "HEAD", - ], - ) - .unwrap(); - std::fs::write(layout.path.join("keep.txt"), "keep").unwrap(); - run_test_git(&layout.path, &["add", "keep.txt"]).unwrap(); - run_test_git( - &layout.path, - &[ - "-c", - "user.email=test@example.invalid", - "-c", - "user.name=Yoi Test", - "commit", - "-m", - "keep", - ], - ) - .unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("expected orchestration")); - assert!(layout.path.join("keep.txt").exists()); - } - - #[test] - fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let layout = orchestration_worktree_layout(&root); - std::fs::create_dir_all(&layout.path).unwrap(); - init_test_repo(&layout.path); - run_test_git(&layout.path, &["checkout", "-b", &layout.branch]).unwrap(); - std::fs::write(layout.path.join("unrelated.txt"), "keep").unwrap(); - run_test_git(&layout.path, &["add", "unrelated.txt"]).unwrap(); - run_test_git( - &layout.path, - &[ - "-c", - "user.email=test@example.invalid", - "-c", - "user.name=Yoi Test", - "commit", - "-m", - "unrelated", - ], - ) - .unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("different Git repository")); - assert!(layout.path.join("unrelated.txt").exists()); - } - - fn write_test_ticket_config(root: &Path, content: &str) { - let config_dir = root.join(".yoi"); - std::fs::create_dir_all(&config_dir).unwrap(); - std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap(); - } - - fn init_test_repo(root: &Path) { - std::fs::create_dir_all(root).unwrap(); - run_test_git(root, &["init"]).unwrap(); - run_test_git(root, &["config", "user.email", "test@example.invalid"]).unwrap(); - run_test_git(root, &["config", "user.name", "Yoi Test"]).unwrap(); - std::fs::write(root.join("README.md"), "repo").unwrap(); - run_test_git(root, &["add", "README.md"]).unwrap(); - run_test_git(root, &["commit", "-m", "init"]).unwrap(); - } - - #[test] - fn inherited_parent_worktree_directory_is_rejected_without_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let layout = orchestration_worktree_layout(&root); - run_test_git(&root, &["checkout", "-b", &layout.branch]).unwrap(); - std::fs::create_dir_all(&layout.path).unwrap(); - std::fs::write(layout.path.join("plain.txt"), "keep").unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("not the worktree root")); - assert!(layout.path.join("plain.txt").exists()); - } - - fn run_test_git(root: &Path, args: &[&str]) -> Result<(), String> { - let mut command = Command::new("git"); - command.arg("-C").arg(root).args(args); - run_git_command(command, "run test git") - } - - fn run_test_git_output(root: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .output() - .map_err(|error| format!("could not run test git: {error}"))?; - if !output.status.success() { - return Err(format!( - "git failed to run test git: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } - - use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; - use std::fs; - use tempfile::TempDir; - use ticket::{ - LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, - TicketEventKind, TicketWorkflowState, - }; - - fn ticket_workspace( - title: &str, - state: TicketWorkflowState, - configure: impl FnOnce(&mut NewTicket), - ) -> (TempDir, String, LocalTicketBackend) { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join(".yoi")).unwrap(); - fs::write( - temp.path().join(".gitignore"), - ".worktree/\n.yoi/tickets/.ticket-backend.lock\n", - ) - .unwrap(); - fs::write( - temp.path().join(".yoi/.gitignore"), - "tickets/.ticket-backend.lock\n", - ) - .unwrap(); - fs::write( - temp.path().join(".yoi/ticket.config.toml"), - "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", - ) - .unwrap(); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let mut input = NewTicket::new(title); - input.body = MarkdownText::from("Ready for panel action"); - input.workflow_state = Some(state); - configure(&mut input); - let ticket = backend.create(input).unwrap(); - (temp, ticket.id, backend) - } - - fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { - ticket_workspace(title, TicketWorkflowState::Ready, |_| {}) - } - - fn ready_ticket_git_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { - let (temp, ticket_id, backend) = ready_ticket_workspace(title); - run_test_git(temp.path(), &["init"]).unwrap(); - run_test_git( - temp.path(), - &["config", "user.email", "test@example.invalid"], - ) - .unwrap(); - run_test_git(temp.path(), &["config", "user.name", "Yoi Test"]).unwrap(); - run_test_git(temp.path(), &["add", "."]).unwrap(); - run_test_git(temp.path(), &["commit", "-m", "seed tickets"]).unwrap(); - ensure_orchestration_worktree(temp.path()).unwrap(); - (temp, ticket_id, backend) - } - - fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { - ticket_workspace(title, TicketWorkflowState::Done, |_| {}) - } - - fn request_for( - temp: &TempDir, - ticket_id: String, - action: NextUserAction, - ) -> TicketActionRequest { - TicketActionRequest { - workspace_root: temp.path().to_path_buf(), - ticket_id, - action, - orchestrator: None, - } - } - - fn planning_return_request( - temp: &TempDir, - ticket_id: String, - instruction: &str, - ) -> ReadyTicketPlanningReturnRequest { - ReadyTicketPlanningReturnRequest { - workspace_root: temp.path().to_path_buf(), - ticket_id, - user_instruction: instruction.to_string(), - followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { - pod_name: "stale-intake".to_string(), - }, - } - } - - #[tokio::test] - async fn ready_ticket_planning_return_records_instruction_and_returns_to_planning_without_queueing() - { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-ready"); - - let outcome = dispatch_ready_ticket_planning_return(planning_return_request( - &temp, - ticket_id.clone(), - "please add acceptance detail before queueing", - )) - .await - .unwrap(); - - assert!(outcome.notice.contains("returned to planning")); - assert!(outcome.notice.contains("instruction was recorded")); - assert!(matches!( - outcome.followup, - ReadyTicketPlanningReturnAfterMutation::None - )); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); - assert!(ticket.meta.queued_by.is_none()); - assert!(ticket.meta.queued_at.is_none()); - let state_change = ticket - .events - .iter() - .find(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("planning") - }) - .expect("ready -> planning state_changed event is recorded"); - assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); - assert!( - state_change - .body - .as_str() - .contains("please add acceptance detail") - ); - assert!(state_change.body.as_str().contains("not Queue routing")); - assert!( - state_change - .body - .as_str() - .contains("must not start implementation") - ); - } - - #[tokio::test] - async fn ready_ticket_planning_return_rejects_stale_non_ready_ticket() { - let (temp, ticket_id, backend) = - ticket_workspace("panel-refine-stale", TicketWorkflowState::Planning, |_| {}); - - let error = dispatch_ready_ticket_planning_return(planning_return_request( - &temp, - ticket_id.clone(), - "refine please", - )) - .await - .unwrap_err(); - - assert!(error.to_string().contains("expected ready")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); - assert!( - ticket - .events - .iter() - .all(|event| !(event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("planning"))) - ); - } - - #[test] - fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launch() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); - panel.rows.push(panel_test_ticket_row( - "20260608-000123-ready", - "Ready Ticket", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel(empty_test_list(), panel); - app.cycle_composer_target(); - app.input.insert_str("clarify expected behavior"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - MultiPodAction::ReturnReadyTicketToPlanning(request) => request, - _ => panic!("ready Ticket row with Ticket Intake text should return to planning"), - }; - - assert_eq!(request.ticket_id, "20260608-000123-ready"); - assert_eq!(request.user_instruction, "clarify expected behavior"); - assert!(matches!( - request.followup, - ReadyTicketPlanningReturnFollowup::LaunchIntake(_) - )); - assert!(app.sending); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Returning ready Ticket") - ); - assert_eq!(input_text(&app), "clarify expected behavior"); - } - - #[tokio::test] - async fn planning_return_with_launch_followup_changes_state_before_launch_followup() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-launch"); - let request = ReadyTicketPlanningReturnRequest { - workspace_root: temp.path().to_path_buf(), - ticket_id: ticket_id.clone(), - user_instruction: "launch intake after state change".to_string(), - followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { - context: TicketRoleLaunchContext::new( - temp.path().to_path_buf(), - TicketRole::Intake, - ), - runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), - peer_registration: IntakePeerRegistrationRequest::Skip { - reason: "test".to_string(), - }, - registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: temp.path().join(".yoi/local-role-sessions"), - ticket_id: ticket_id.clone(), - ticket_slug: None, - }, - }), - }; - - let outcome = dispatch_ready_ticket_planning_return(request) - .await - .unwrap(); - - assert!(matches!( - outcome.followup, - ReadyTicketPlanningReturnAfterMutation::LaunchIntake(_) - )); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); - } - - #[tokio::test] - async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue"); - let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap(); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap(); - - let root_head_after = git_rev_parse(temp.path(), "HEAD").unwrap(); - let layout = orchestration_worktree_layout(temp.path()); - let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap(); - assert_ne!(root_head_after, root_head_before); - assert_eq!(orchestration_head, root_head_after); - assert!(outcome.notice.contains("Queued Ticket")); - assert!(outcome.notice.contains(&root_head_after)); - assert!(outcome.notice.contains("root Queue commit")); - assert!(outcome.notice.contains("ff-only synced")); - assert!( - outcome - .notice - .contains("Orchestrator routing is authorized") - ); - assert!(outcome.notice.contains("queued -> inprogress acceptance")); - assert!(!outcome.notice.contains("No implementation was started")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); - assert!(ticket.meta.queued_at.is_some()); - let state_change = ticket - .events - .iter() - .find(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("queued") - }) - .expect("queue state_changed event is recorded"); - assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); - let orchestration_backend = LocalTicketBackend::new(layout.path.join(".yoi/tickets")); - let orchestration_ticket = orchestration_backend - .show(TicketIdOrSlug::Id(ticket_id)) - .unwrap(); - assert_eq!( - orchestration_ticket.meta.workflow_state, - TicketWorkflowState::Queued - ); - } - - #[tokio::test] - async fn ticket_queue_action_allows_unrelated_dirty_root_changes() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-root"); - fs::write(temp.path().join("README.md"), "dirty root notes\n").unwrap(); - fs::write(temp.path().join("dirty.txt"), "dirty").unwrap(); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap(); - - assert!(outcome.notice.contains("Queued Ticket")); - assert!(temp.path().join("dirty.txt").is_file()); - let root_status = git_status_porcelain(temp.path()).unwrap(); - assert!(root_status.iter().any(|line| line.contains("README.md"))); - assert!(root_status.iter().any(|line| line.contains("dirty.txt"))); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); - } - - #[tokio::test] - async fn ticket_queue_action_blocks_preexisting_target_ticket_changes() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-ticket"); - fs::write( - backend.root().join(&ticket_id).join("thread.md"), - "local uncommitted ticket edit\n", - ) - .unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap_err(); - let message = error.to_string(); - - assert!(message.contains("root-ticket-clean")); - assert!(message.contains(&ticket_id)); - assert!(message.contains("pre-existing changes")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.meta.queued_by.is_none()); - } - - #[tokio::test] - async fn ticket_queue_action_merges_orchestration_branch_before_queue() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-diverged"); - let layout = orchestration_worktree_layout(temp.path()); - fs::write(layout.path.join("orchestrator-only.txt"), "diverged").unwrap(); - run_test_git(&layout.path, &["add", "orchestrator-only.txt"]).unwrap(); - run_test_git(&layout.path, &["commit", "-m", "orchestrator-only"]).unwrap(); - let orchestration_commit = git_rev_parse(&layout.path, "HEAD").unwrap(); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap(); - - let root_head = git_rev_parse(temp.path(), "HEAD").unwrap(); - let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap(); - assert_eq!(root_head, orchestration_head); - assert!(temp.path().join("orchestrator-only.txt").is_file()); - assert!(outcome.notice.contains("merged orchestration branch")); - assert!(outcome.notice.contains(&orchestration_commit)); - assert!(outcome.notice.contains("root Queue commit")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); - } - - #[tokio::test] - async fn ticket_queue_action_blocks_conflicting_orchestration_merge_without_mutation() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-conflict"); - let layout = orchestration_worktree_layout(temp.path()); - fs::write(temp.path().join("README.md"), "root change\n").unwrap(); - run_test_git(temp.path(), &["add", "README.md"]).unwrap(); - run_test_git(temp.path(), &["commit", "-m", "root-change"]).unwrap(); - fs::write(layout.path.join("README.md"), "orchestration change\n").unwrap(); - run_test_git(&layout.path, &["add", "README.md"]).unwrap(); - run_test_git(&layout.path, &["commit", "-m", "orchestration-change"]).unwrap(); - let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap_err(); - let message = error.to_string(); - - assert!(message.contains("root-orchestration-merge")); - assert!(message.contains("merge was aborted")); - assert!(message.contains(&ticket_id)); - assert_eq!( - git_rev_parse(temp.path(), "HEAD").unwrap(), - root_head_before - ); - assert!(git_status_porcelain(temp.path()).unwrap().is_empty()); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.meta.queued_by.is_none()); - } - - #[tokio::test] - async fn ticket_close_action_blocks_non_done_ticket_without_mutation() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-not-done"); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("state is ready")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.resolution.is_none()); - } - - #[tokio::test] - async fn ticket_action_rejects_stale_absent_config_without_mutation() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config"); - fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("Ticket config is absent")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.meta.queued_by.is_none()); - assert!(!ticket.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - })); - } - - #[tokio::test] - async fn ticket_close_action_closes_done_ticket_with_deterministic_resolution() { - let (temp, ticket_id, backend) = done_ticket_workspace("panel-close"); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) - .await - .unwrap(); - - assert!(outcome.notice.contains("Closed Ticket")); - assert!(outcome.notice.contains("state was already done")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); - let resolution = ticket - .resolution - .as_ref() - .expect("Panel Close records resolution.md") - .as_str(); - assert!(resolution.contains("state: done")); - assert!(resolution.contains("No implementation work")); - assert!(resolution.contains("state change")); - assert!(resolution.contains("worker invocation")); - assert!(ticket.events.iter().any(|event| { - event.kind == TicketEventKind::Close && event.body.as_str().contains("workspace Panel") - })); - } - - #[tokio::test] - async fn ticket_close_action_blocks_existing_resolution_without_moving_ticket() { - let (temp, ticket_id, backend) = done_ticket_workspace("panel-close-resolution"); - fs::write( - temp.path() - .join(".yoi/tickets") - .join(&ticket_id) - .join("resolution.md"), - "Already resolved\n", - ) - .unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("resolution.md already exists")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!( - ticket.resolution.as_ref().unwrap().as_str(), - "Already resolved\n" - ); - } - - #[tokio::test] - async fn ticket_review_action_does_not_silently_approve() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-review"); - backend - .add_event( - TicketIdOrSlug::Id(ticket_id.clone()), - NewTicketEvent::new(TicketEventKind::ImplementationReport, "implemented"), - ) - .unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Wait)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("current action is Queue")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert!( - !ticket - .events - .iter() - .any(|event| event.kind == TicketEventKind::Review) - ); - } - - #[test] - fn ticket_queue_notification_message_carries_routing_contract() { - let row = panel_test_ticket_row( - "00001KTTW04W2", - "Route queued\nTicket", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "queued", - ); - let ticket = row.ticket.as_ref().unwrap(); - - let message = orchestrator_queue_notification_message(ticket); - - assert!(message.contains("Ticket `00001KTTW04W2`, title `Route queued Ticket`")); - assert!(message.contains("human authorized Orchestrator routing")); - assert!(message.contains("not an unattended scheduler")); - assert!(message.contains("Read the Ticket")); - assert!(message.contains("inspect current Orchestrator workspace state")); - assert!(message.contains("transition state queued -> inprogress")); - assert!(message.contains("before any worktree/SpawnPod implementation side effects")); - assert!(message.contains("After inprogress acceptance")); - assert!(message.contains("worktree-workflow")); - assert!(message.contains("`.worktree/`")); - assert!(message.contains("tracked `.yoi` project records visible")); - assert!(message.contains( - "`.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded" - )); - assert!(message.contains("multi-agent-workflow")); - assert!(message.contains("sibling coder/reviewer Pods")); - assert!(message.contains("coder narrow child-worktree write scope")); - assert!(message.contains("reviewer read-only by default")); - assert!(message.contains( - "integrate the implementation branch into the orchestration branch automatically" - )); - assert!(message.contains("validate in the Orchestrator worktree")); - assert!(message.contains("clean up only child implementation worktrees/branches")); - assert!(message.contains("Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace")); - assert!(message.contains("If blocked, record a concise reason")); - assert!(message.contains("leave the Ticket queued or return it to planning")); - assert!(!message.contains("Do not start implementation directly")); - } - - #[tokio::test] - async fn ticket_queue_notification_sends_notify_when_socket_available() { - let temp = TempDir::new().unwrap(); - let socket_path = temp.path().join("orchestrator.sock"); - let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - writer - .write(&Event::Snapshot { - entries: Vec::new(), - greeting: protocol::Greeting { - pod_name: "test-orchestrator".to_string(), - cwd: temp.path().display().to_string(), - provider: "test".to_string(), - model: "test".to_string(), - scope_summary: "test".to_string(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - }, - status: PodStatus::Idle, - }) - .await - .unwrap(); - reader.next::().await.unwrap().unwrap() - }); - - send_notify_only(&socket_path, "panel Queue".to_string(), true) - .await - .unwrap(); - let method = server.await.unwrap(); - assert!(matches!( - method, - Method::Notify { message, auto_run: true } if message == "panel Queue" - )); - } - - #[tokio::test] - async fn send_notify_only_can_deliver_weak_notification_without_auto_run() { - let temp = TempDir::new().unwrap(); - let socket_path = temp.path().join("companion.sock"); - let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - writer - .write(&Event::Snapshot { - entries: Vec::new(), - greeting: protocol::Greeting { - pod_name: "yoi".to_string(), - cwd: temp.path().display().to_string(), - provider: "test".to_string(), - model: "test".to_string(), - scope_summary: "test".to_string(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - }, - status: PodStatus::Idle, - }) - .await - .unwrap(); - reader.next::().await.unwrap().unwrap() - }); - - send_notify_only(&socket_path, "panel progress".to_string(), false) - .await - .unwrap(); - let method = server.await.unwrap(); - assert!(matches!( - method, - Method::Notify { message, auto_run: false } if message == "panel progress" - )); - } - - #[test] - fn no_ticket_selection_keeps_enter_pod_centric() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::Open - )); - assert!(app.prepare_ticket_action_dispatch().is_none()); - assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected.")); - } - - #[test] - fn multi_ticket_action_rows_precede_pods_and_pod_actions_still_work() { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join(".yoi")).unwrap(); - fs::write( - temp.path().join(".yoi/ticket.config.toml"), - "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", - ) - .unwrap(); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let mut ticket = NewTicket::new("Ready Ticket"); - ticket.workflow_state = Some(TicketWorkflowState::Ready); - backend.create(ticket).unwrap(); - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info("idle", PodStatus::Idle)], - None, - 10, - ); - let panel = build_workspace_panel(temp.path(), &list); - let mut app = app_with_panel(list, panel); - - assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); - let lines = list_lines(&app, 100, 6) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - let ticket_line = lines - .iter() - .position(|line| line.contains("Ready Ticket")) - .unwrap(); - let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); - assert!(ticket_line < pod_line); - - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "idle"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - let open = app.prepare_open().unwrap(); - assert_eq!(open.pod_name, "idle"); - assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock"))); - - app.input.insert_str("draft after ticket row"); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::None - )); - assert!(!app.sending); - assert_eq!(input_text(&app), "draft after ticket row"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Workspace Companion is unavailable") - ); - } - - #[test] - fn row_hit_testing_maps_only_visible_selectable_rows() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - panel.rows.push(panel_test_ticket_row( - "TICKET-2", - "Queued", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )); - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info("alpha", PodStatus::Idle)], - None, - 10, - ); - let app = app_with_panel(list, panel); - - let rows = list_rows(&app, 80, 8); - let boxes = row_hit_boxes(&rows, Rect::new(3, 5, 80, 8)); - - assert_eq!(boxes.len(), 3); - assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into())); - assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 2)); - assert_eq!(boxes[1].key, PanelRowKey::Ticket("TICKET-2".into())); - assert_eq!(boxes[1].rect, Rect::new(3, 8, 80, 2)); - assert_eq!(boxes[2].key, PanelRowKey::Pod("alpha".into())); - assert_eq!(boxes[2].rect, Rect::new(3, 11, 80, 1)); - assert!(boxes.iter().all(|hit| !hit.contains(2, hit.rect.y))); - } - - #[test] - fn mouse_click_selects_panel_row_for_blank_enter_action() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - panel.rows.push(panel_test_ticket_row( - "TICKET-2", - "Queued", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )); - let mut app = app_with_panel( - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), - panel, - ); - let rows = list_rows(&app, 80, 6); - app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6)); - - assert!(app.handle_mouse_event(left_click(2, 3))); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Ticket("TICKET-2".into())) - ); - app.selected_row = None; - assert!(app.handle_mouse_event(left_click(2, 4))); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Ticket("TICKET-2".into())) - ); - assert_eq!(app.selected_panel_row().unwrap().title, "Queued"); - assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait)); - assert!(plain_line(&target_status_line(&app)).contains("blank Enter Wait")); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2" - )); - } - - #[test] - fn mouse_non_row_click_is_noop_and_preserves_composer_draft() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel( - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), - panel, - ); - let rows = list_rows(&app, 80, 6); - app.set_row_hit_boxes(&rows, Rect::new(10, 4, 80, 6)); - app.input.insert_paste("draft".into()); - let selected = app.selected_row.clone(); - - assert!(!app.handle_mouse_event(left_click(9, 5))); - assert_eq!(app.selected_row, selected); - assert_eq!(input_text(&app), "draft"); - } - - #[test] - fn mouse_click_does_not_override_existing_composer_keyboard_behavior() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - panel.rows.push(panel_test_ticket_row( - "TICKET-2", - "Queued", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )); - let mut app = app_with_panel( - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), - panel, - ); - let rows = list_rows(&app, 80, 6); - app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6)); - - assert!(app.handle_mouse_event(left_click(2, 4))); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Ticket("TICKET-2".into())) - ); - app.input.insert_paste("hello".into()); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::None - )); - assert_eq!(input_text(&app), "hello"); - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - MultiPodAction::None - )); - assert_eq!(app.selected_row, None); - assert_eq!(input_text(&app), "hello"); - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - MultiPodAction::None - )); - assert_eq!(app.selected_row, None); - } - - #[test] - fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.companion = Some(CompanionPanelState::new( - "yoi", - CompanionPanelStatus::Live, - None, - )); - panel.rows.push(panel_test_ticket_row( - "00001KTWPE3KQ", - "Queue Me", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info("yoi", PodStatus::Idle)], - None, - 10, - ); - let mut app = app_with_panel(list, panel); - app.input.insert_str("draft to companion"); - - assert_eq!( - app.selected_ticket_action(), - Some(NextUserAction::Queue), - "selected row remains a Ticket action row" - ); - let actionbar_left = actionbar_left_text(&app); - let actionbar_right = actionbar_right_text(&app); - let target_status = plain_line(&target_status_line(&app)); - - assert!(actionbar_left.contains("Companion target: Enter sends composer text")); - assert!(actionbar_right.contains("Enter composer target")); - assert!(!actionbar_left.contains("Queue")); - assert!(!actionbar_right.contains("selected row")); - assert!(target_status.contains("composer target Companion")); - assert!(target_status.contains("draft Enter send composer text to workspace Companion")); - assert!(target_status.contains("row selection waits until composer is blank")); - assert!(!target_status.contains("blank Enter Queue")); - } - - #[test] - fn multi_bare_panel_letters_append_to_composer_and_arrows_select_when_blank() { - let mut app = test_app(vec![ - live_info("alpha", PodStatus::Idle), - live_info("beta", PodStatus::Idle), - ]); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - - for c in ['j', 'k', 'o', 'r'] { - assert!(matches!( - app.handle_key(key(KeyCode::Char(c))), - MultiPodAction::None - )); - } - - assert_eq!(input_text(&app), "jkor"); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - MultiPodAction::None - )); - assert_eq!(input_text(&app), "jkor"); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - - app.input.clear(); - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - MultiPodAction::None - )); - assert_eq!(input_text(&app), ""); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - - assert!(matches!( - app.handle_key(key(KeyCode::Up)), - MultiPodAction::None - )); - assert_eq!(input_text(&app), ""); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - } - - #[test] - fn multi_selection_changes_preserve_composer_contents() { - let mut app = test_app(vec![ - live_info("alpha", PodStatus::Idle), - live_info("beta", PodStatus::Idle), - ]); - app.input.insert_str("draft message"); - let before = input_text(&app); - - app.select_next(); - - assert_eq!(input_text(&app), before); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - } - - #[test] - fn multi_poll_reload_preserves_selection_composer_and_notice() { - let mut app = test_app(vec![ - live_info_with_updated_at("alpha", PodStatus::Idle, 10), - live_info_with_updated_at("beta", PodStatus::Idle, 20), - ]); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - app.input.insert_str("draft survives polling"); - app.notice = Some("keep this notice".to_string()); - let refreshed = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![ - live_info_with_updated_at("gamma", PodStatus::Idle, 60), - live_info_with_updated_at("alpha", PodStatus::Running, 50), - live_info_with_updated_at("beta", PodStatus::Idle, 40), - ], - None, - 10, - ); - - app.apply_reloaded_list(refreshed); - - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert_eq!( - app.list - .selected_entry() - .unwrap() - .live - .as_ref() - .unwrap() - .status, - Some(PodStatus::Running) - ); - assert_eq!(input_text(&app), "draft survives polling"); - assert_eq!(app.notice.as_deref(), Some("keep this notice")); - } - - #[test] - fn multi_poll_reload_falls_back_when_selected_pod_disappears() { - let mut app = test_app(vec![ - live_info_with_updated_at("alpha", PodStatus::Idle, 10), - live_info_with_updated_at("beta", PodStatus::Running, 20), - ]); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - let refreshed = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![stopped_info_with_updated_at("closed", 30)], - vec![live_info_with_updated_at("alpha", PodStatus::Idle, 40)], - None, - 10, - ); - - app.apply_reloaded_list(refreshed); - - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert_eq!(visible_entry_indices(&app.list), vec![0, 1]); - } - - #[test] - fn multi_poll_reload_error_keeps_previous_list_and_composer() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - app.input.insert_str("keep draft"); - - app.apply_reload_result(Err(MultiPodError::Io(io::Error::other("boom")))); - - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert_eq!(input_text(&app), "keep draft"); - let notice = app.notice.as_deref().unwrap(); - assert!(notice.contains("Refresh failed")); - assert!(notice.contains("boom")); - } - - #[test] - fn multi_orchestrator_failure_persists_over_plain_observe_missing() { - let detail = - "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), - ); - - app.apply_reloaded_snapshot(MultiPodSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); - assert_eq!(orchestrator.detail.as_deref(), Some(detail)); - assert_eq!( - app.panel - .header - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.as_str() == detail) - .count(), - 1 - ); - } - - #[test] - fn multi_orchestrator_plain_missing_remains_when_no_prior_failure_exists() { - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - ); - - app.apply_reloaded_snapshot(MultiPodSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); - assert!(orchestrator.detail.is_none()); - assert!(app.panel.header.diagnostics.is_empty()); - } - - #[test] - fn multi_orchestrator_failure_clears_after_live_lifecycle() { - let detail = - "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), - ); - - app.apply_reloaded_snapshot(MultiPodSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Live, None), - }); - assert_eq!( - app.panel.header.orchestrator.as_ref().unwrap().status, - OrchestratorPanelStatus::Live - ); - - app.apply_reloaded_snapshot(MultiPodSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); - assert!(orchestrator.detail.is_none()); - } - - #[test] - fn multi_orchestrator_failure_supersedes_prior_failure() { - let old_detail = "could not spawn workspace Orchestrator: old scope conflict"; - let new_detail = "could not restore workspace Orchestrator: socket refused"; - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(old_detail)), - ); - - app.apply_reloaded_snapshot(MultiPodSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(new_detail)), - }); - app.apply_reloaded_snapshot(MultiPodSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); - assert_eq!(orchestrator.detail.as_deref(), Some(new_detail)); - assert!( - !app.panel - .header - .diagnostics - .iter() - .any(|diagnostic| diagnostic == old_detail) - ); - assert!( - app.panel - .header - .diagnostics - .iter() - .any(|diagnostic| diagnostic == new_detail) - ); - } - - #[tokio::test] - async fn multi_poll_reload_does_not_overlap_in_flight_reload() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - let mut pending = PendingReload::default(); - - assert!(pending.start_with_handle(tokio::spawn(async { - tokio::time::sleep(Duration::from_millis(10)).await; - Err(MultiPodError::Io(io::Error::other("boom"))) - }))); - assert!(!pending.start_with_handle(tokio::spawn(async { - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info("beta", PodStatus::Idle)], - None, - 10, - ); - Ok(MultiPodSnapshot { - panel: WorkspacePanelViewModel::empty(Path::new("test")), - list, - }) - }))); - assert!(pending.finish_if_ready().await.is_none()); - - tokio::time::sleep(Duration::from_millis(20)).await; - let result = pending.finish_if_ready().await.unwrap(); - app.apply_reload_result(result); - - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert!(app.notice.as_deref().unwrap().contains("Refresh failed")); - } - - #[tokio::test] - async fn multi_quit_aborts_background_reload_and_notice_without_waiting() { - struct DropFlag(Arc); - impl Drop for DropFlag { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - let reload_cancelled = Arc::new(AtomicBool::new(false)); - let notice_cancelled = Arc::new(AtomicBool::new(false)); - let mut pending_reload = PendingReload::default(); - let mut pending_notice = PendingQueueAttentionNotice::default(); - let (reload_started_tx, reload_started_rx) = tokio::sync::oneshot::channel(); - let (notice_started_tx, notice_started_rx) = tokio::sync::oneshot::channel(); - - let reload_flag = Arc::clone(&reload_cancelled); - assert!(pending_reload.start_with_handle(tokio::spawn(async move { - let _drop_flag = DropFlag(reload_flag); - let _ = reload_started_tx.send(()); - std::future::pending::<()>().await; - Err(MultiPodError::Io(io::Error::other( - "unreachable reload completion", - ))) - }))); - - let notice_flag = Arc::clone(¬ice_cancelled); - assert!(pending_notice.start_with_handle(tokio::spawn(async move { - let _drop_flag = DropFlag(notice_flag); - let _ = notice_started_tx.send(()); - std::future::pending::<()>().await; - OrchestratorQueueAttentionNoticeResult::failed( - "unreachable".to_string(), - "unreachable notice completion", - ) - }))); - reload_started_rx.await.expect("reload task should start"); - notice_started_rx.await.expect("notice task should start"); - - tokio::time::timeout(Duration::from_millis(20), async { - abort_panel_background_work_for_quit(&mut pending_reload, &mut pending_notice); - }) - .await - .expect("quit abort should not wait for background task completion"); - - tokio::time::timeout(Duration::from_millis(100), async { - while !(reload_cancelled.load(Ordering::SeqCst) - && notice_cancelled.load(Ordering::SeqCst)) - { - tokio::task::yield_now().await; - } - }) - .await - .expect("quit abort should cancel reload and notice tasks"); - - assert!(pending_reload.finish_if_ready().await.is_none()); - assert!(pending_notice.finish_if_ready().await.is_none()); - } - - #[test] - fn multi_idle_live_selected_target_is_open_eligible() { - let app = test_app(vec![live_info("idle", PodStatus::Idle)]); - - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - assert!(app.selected_open_disabled_reason().is_none()); - } - - #[test] - fn multi_status_label_for_live_without_reported_status_is_softened() { - let mut live = live_info("probing", PodStatus::Idle); - live.status = None; - let app = test_app(vec![live]); - - let (label, _) = row_status_label(app.list.selected_entry().unwrap()); - - assert_eq!(label, "live"); - } - - #[test] - fn multi_status_labels_preserve_explicit_live_statuses() { - for (status, expected_label) in [ - (PodStatus::Idle, "live idle"), - (PodStatus::Running, "live running"), - (PodStatus::Paused, "live paused"), - ] { - let app = test_app(vec![live_info("pod", status)]); - let (label, _) = row_status_label(app.list.selected_entry().unwrap()); - - assert_eq!(label, expected_label); - } - } - - #[test] - fn panel_ticket_rows_render_state_title_then_detail_line() { - let row = panel_test_ticket_row( - "00001KTX1QMG9", - "Workspace panel composer targets", - ActionPriority::ActiveWork, - NextUserAction::Wait, - "inprogress", - ); - - let lines = panel_row_lines(&row, true, 160); - let (title, detail) = (&lines[0], &lines[1]); - let title_line = plain_line(&title); - let detail_line = plain_line(&detail); - let state_start = 2; - let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; - let row_id = row.ticket.as_ref().unwrap().id.as_str(); - - assert!(title_line.starts_with("▶ ")); - assert!(detail_line.starts_with("│ meta ")); - assert!(!title_line.contains(row_id)); - assert_eq!(display_column(&title_line, "inprogress"), state_start); - assert_eq!( - display_column(&title_line, "Workspace panel composer targets"), - title_start - ); - assert!(detail_line.contains(row_id)); - assert!(detail_line.contains("Gate: clear")); - assert!(detail_line.contains("Action: Wait")); - } - - #[test] - fn panel_ticket_non_selected_rows_align_with_selected_marker_space() { - let row = panel_test_ticket_row( - "00001KTTB479X", - "Long Ticket title that should be rendered after short columns", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - ); - - let lines = panel_row_lines(&row, false, 160); - let (title, detail) = (&lines[0], &lines[1]); - let title_line = plain_line(&title); - let detail_line = plain_line(&detail); - let state_start = 2; - let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; - - assert!(title_line.starts_with(" ready")); - assert!(detail_line.starts_with(" meta 00001KTTB479X")); - assert_eq!(display_column(&title_line, "ready"), state_start); - assert_eq!( - display_column(&title_line, "Long Ticket title"), - title_start - ); - } - - #[test] - fn panel_ticket_title_truncates_after_state_column() { - let row = panel_test_ticket_row( - "00001KTTB479X", - "Very long Ticket title that should truncate only after the state column", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - ); - - let lines = panel_row_lines(&row, false, 42); - let (title, detail) = (&lines[0], &lines[1]); - let title_line = plain_line(&title); - let detail_line = plain_line(&detail); - let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1; - - assert_eq!(title_line.width(), 42); - assert_eq!(display_column(&title_line, "Very long Ticket"), title_start); - assert!(title_line.ends_with('…')); - assert_eq!(detail_line.width(), 42); - assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear")); - assert!(detail_line.ends_with('…')); - } - - #[test] - fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() { - let mut row = panel_test_ticket_row( - "00001OVERLAY", - "Overlay column regression", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ); - row.kind = PanelRowKind::Review; - row.status = "q→done".to_string(); - row.disabled_reason = Some( - "orchestration worktree overlay shows Ticket state done; local state remains queued" - .to_string(), - ); - row.ticket.as_mut().unwrap().orchestration_overlay = - Some(crate::workspace_panel::TicketStateOverlay { - source: "orchestration".to_string(), - workflow_state: TicketWorkflowState::Done, - }); - - let lines = panel_row_lines(&row, false, 160); - let title_line = plain_line(&lines[0]); - let detail_line = plain_line(&lines[1]); - let state_start = 2; - let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; - - assert!(row.status.width() <= TICKET_STATE_COLUMN_WIDTH); - assert_eq!(display_column(&title_line, "q→done"), state_start); - assert_eq!( - display_column(&title_line, "Overlay column regression"), - title_start - ); - assert!(!title_line.contains("orchestration")); - assert!(detail_line.contains("Overlay: local queued · orchestration done · merge pending")); - } - - #[test] - fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() { - let mut row = panel_test_ticket_row( - "00001WAITING", - "Ready but gated", - ActionPriority::Background, - NextUserAction::Wait, - "ready", - ); - row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string()); - row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string()); - - let lines = panel_row_lines(&row, true, 160); - let detail = &lines[1]; - let detail_line = plain_line(&detail); - - assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on")); - assert!(detail_line.contains("Action: queue disabled")); - assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1")); - } - - #[test] - fn panel_ticket_intake_child_rows_render_as_indented_single_line() { - let row = panel_test_intake_child_row( - "00001TICKET", - "intake-live", - TicketLocalClaimStatus::Live, - Some(NextUserAction::OpenPod), - ); - - let lines = panel_row_lines(&row, false, 160); - assert_eq!(lines.len(), 1); - let line = plain_line(&lines[0]); - let status_start = 4; - let title_start = status_start + TICKET_STATE_COLUMN_WIDTH + 1; - - assert!(line.starts_with(" └ live")); - assert_eq!(display_column(&line, "live"), status_start); - assert_eq!( - display_column(&line, "Intake Pod: intake-live"), - title_start - ); - assert!(!line.starts_with(" live")); - - let selected_line = plain_line(&panel_row_lines(&row, true, 160)[0]); - assert!(selected_line.starts_with(" ▶ live")); - assert!(selected_line.contains("Intake Pod: intake-live")); - } - - #[test] - fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod() { - let ticket_id = "00001TICKET"; - let pod_name = "intake-live"; - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_intake_child_row( - ticket_id, - pod_name, - TicketLocalClaimStatus::Live, - Some(NextUserAction::OpenPod), - )); - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info(pod_name, PodStatus::Idle)], - None, - 10, - ); - let mut app = app_with_panel(list, panel); - app.select_panel_key(PanelRowKey::TicketIntakePod { - ticket_id: ticket_id.to_string(), - pod_name: pod_name.to_string(), - }); - - let status = plain_line(&target_status_line(&app)); - - assert!(status.contains("selected Intake Pod live")); - assert!(status.contains("Ticket 00001TICKET")); - assert!(status.contains("blank Enter open/attach")); - assert!(!status.contains("selected Ticket")); - assert!(!status.contains("selected Pod live")); - } - - #[test] - fn panel_pod_rows_use_aligned_columns_before_pod_name() { - let app = test_app(vec![ - live_info("companion", PodStatus::Idle), - live_info("very-long-background-worker-name", PodStatus::Running), - ]); - let idle = app - .list - .entries - .iter() - .find(|entry| entry.name == "companion") - .unwrap(); - let running = app - .list - .entries - .iter() - .find(|entry| entry.name == "very-long-background-worker-name") - .unwrap(); - - let idle_line = plain_line(&row_line(idle, false, 120)); - let running_line = plain_line(&row_line(running, false, 120)); - let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; - - assert!(!running_line.starts_with(" very-long-background-worker-name")); - assert_eq!(display_column(&idle_line, "live idle"), 2); - assert_eq!(display_column(&running_line, "live running"), 2); - assert_eq!(display_column(&idle_line, "companion"), name_start); - assert_eq!( - display_column(&running_line, "very-long-background-worker-name"), - name_start - ); - } - - #[test] - fn panel_pod_name_truncates_after_status() { - let app = test_app(vec![live_info( - "very-long-background-worker-name-that-keeps-going", - PodStatus::Running, - )]); - let entry = app.list.selected_entry().unwrap(); - - let line = plain_line(&row_line(entry, false, 58)); - let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; - - assert_eq!(line.width(), 58); - assert_eq!(display_column(&line, "live running"), 2); - assert_eq!(display_column(&line, "very-long"), name_start); - assert!(line.ends_with('…')); - } - - #[test] - fn multi_running_paused_and_stopped_targets_are_open_eligible() { - let mut app = test_app(vec![ - live_info("running", PodStatus::Running), - live_info("paused", PodStatus::Paused), - ]); - let stopped = stopped_info("stopped"); - app.list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![stopped], - vec![ - live_info_with_updated_at("running", PodStatus::Running, 30), - live_info_with_updated_at("paused", PodStatus::Paused, 20), - ], - Some("running".to_string()), - 10, - ); - app.selected_row = None; - app.ensure_selection_visible(); - - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - assert!(app.selected_open_disabled_reason().is_none()); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "paused"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - assert!(app.selected_open_disabled_reason().is_none()); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "stopped"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - assert!(app.selected_open_disabled_reason().is_none()); - } - - #[test] - fn multi_sections_classify_pending_working_and_closed() { - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![stopped_info_with_updated_at("closed", 60)], - vec![ - live_info_with_updated_at("idle", PodStatus::Idle, 50), - live_info_with_updated_at("running", PodStatus::Running, 40), - live_info_with_updated_at("paused", PodStatus::Paused, 30), - ], - Some("idle".to_string()), - 10, - ); - - let sections = sectioned_entries(&list); - - assert_eq!(section_names(&list, §ions[0]), vec!["idle"]); - assert_eq!( - section_names(&list, §ions[1]), - vec!["running", "paused"] - ); - assert_eq!(section_names(&list, §ions[2]), vec!["closed"]); - } - - #[test] - fn multi_closed_section_is_limited_to_three_visible_rows() { - let list = closed_list(5, Some("closed-0")); - let visible = visible_entry_indices(&list) - .into_iter() - .map(|index| list.entries[index].name.clone()) - .collect::>(); - let sections = sectioned_entries(&list); - let closed = sections - .iter() - .find(|section| section.kind == MultiPodSectionKind::Closed) - .unwrap(); - let app = app_with_list(list); - let lines = list_lines(&app, 80, 8) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - - assert_eq!(visible, vec!["closed-0", "closed-1", "closed-2"]); - assert_eq!(closed.hidden_count(), 2); - assert!( - lines - .iter() - .any(|line| line.contains("closed 5 total, +2 hidden")) - ); - assert!(lines.iter().any(|line| line.contains("closed-2"))); - assert!(!lines.iter().any(|line| line.contains("closed-3"))); - } - - #[test] - fn multi_selection_follows_visible_section_order_without_hidden_closed_rows() { - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - (0..5) - .map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index)) - .collect(), - vec![ - live_info_with_updated_at("running", PodStatus::Running, 70), - live_info_with_updated_at("idle", PodStatus::Idle, 60), - ], - Some("idle".to_string()), - 20, - ); - let mut app = app_with_list(list); - - assert_eq!(app.list.selected_entry().unwrap().name, "idle"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "running"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-0"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-1"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); - } - - #[test] - fn multi_selection_does_not_default_to_orchestrator_only_row() { - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info("test-orchestrator", PodStatus::Idle)], - None, - 10, - ); - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - OrchestratorPanelStatus::Live, - None, - )); - let app = app_with_panel(list, panel); - - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); - } - - #[test] - fn multi_selection_prefers_non_orchestrator_pod_by_default() { - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![ - live_info_with_updated_at("test-orchestrator", PodStatus::Idle, 80), - live_info_with_updated_at("worker", PodStatus::Idle, 70), - ], - None, - 10, - ); - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - OrchestratorPanelStatus::Live, - None, - )); - let app = app_with_panel(list, panel); - - assert_eq!(app.list.selected_entry().unwrap().name, "worker"); - } - - #[test] - fn multi_list_renders_workspace_diagnostics_before_rows() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel - .header - .diagnostics - .push("Ticket config is unusable".to_string()); - let app = app_with_panel( - PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - vec![live_info("idle", PodStatus::Idle)], - None, - 10, - ), - panel, - ); - let lines = list_lines(&app, 80, 4) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - - assert!(lines[0].contains("Ticket config is unusable")); - assert!(lines.iter().any(|line| line.contains("idle"))); - } - - #[test] - fn multi_list_pins_closed_section_below_live_flexible_area() { - let list = PodList::from_sources( - PodVisibilitySource::ResumePicker, - (0..3) - .map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index)) - .collect(), - vec![ - live_info_with_updated_at("running", PodStatus::Running, 70), - live_info_with_updated_at("idle", PodStatus::Idle, 60), - ], - Some("idle".to_string()), - 20, - ); - let app = app_with_list(list); - let lines = list_lines(&app, 80, 12) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - - assert!(lines[0].contains("pending")); - assert!(lines[2].contains("working")); - assert!(lines[4].is_empty()); - assert!(lines[8].contains("closed")); - assert!(lines[11].contains("closed-2")); - } - - #[test] - fn multi_layout_uses_single_boundary_separator_between_list_and_composer() { - let layout = multi_pod_layout(Rect::new(0, 0, 80, 24), 1); - - assert_eq!(layout.boundary.height, 1); - assert!(!layout.list_draws_own_separator); - assert_eq!(layout.boundary.y, layout.list.y + layout.list.height); - assert_eq!( - layout.target_status.y, - layout.boundary.y + layout.boundary.height - ); - } - - #[test] - fn multi_companion_submit_routes_to_workspace_companion_not_selected_pod() { - let mut app = companion_app( - vec![ - live_info("alpha", PodStatus::Idle), - live_info("yoi", PodStatus::Idle), - ], - CompanionPanelStatus::Live, - ); - let alpha_index = app - .list - .entries - .iter() - .position(|entry| entry.name == "alpha") - .unwrap(); - app.list.select_index(alpha_index); - app.input.insert_str("send to companion"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - MultiPodAction::SendCompanion(request) => request, - _ => panic!("Companion target should send to the workspace Companion"), - }; - - assert_eq!(request.pod_name, "yoi"); - assert_eq!(request.socket_path, PathBuf::from("/tmp/yoi.sock")); - assert!(app.sending); - assert_eq!(input_text(&app), "send to companion"); - assert!(app.notice.as_deref().unwrap().contains("Companion yoi")); - } - - #[test] - fn multi_companion_submit_unavailable_keeps_composer_contents() { - let mut app = companion_app(vec![], CompanionPanelStatus::Missing); - app.input.insert_str("keep me"); - let before = input_text(&app); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::None - )); - - assert_eq!(input_text(&app), before); - assert!(!app.sending); - assert!(app.notice.as_deref().unwrap().contains("draft kept")); - } - - #[test] - fn multi_companion_submit_empty_reports_empty_composer() { - let mut app = companion_app( - vec![live_info("yoi", PodStatus::Idle)], - CompanionPanelStatus::Live, - ); - - assert!(app.prepare_companion_send().is_none()); - - assert_eq!(input_text(&app), ""); - assert!(!app.sending); - assert_eq!(app.notice.as_deref(), Some("Composer is empty.")); - } - - #[test] - fn multi_companion_finish_success_clears_composer() { - let mut app = companion_app( - vec![live_info("yoi", PodStatus::Idle)], - CompanionPanelStatus::Live, - ); - app.input.insert_str("done"); - app.sending = true; - - app.finish_companion_send(Ok(CompanionSendOutcome { - notice: "Sent to Companion yoi.".to_string(), - })); - - assert_eq!(input_text(&app), ""); - assert!(!app.sending); - assert_eq!(app.notice.as_deref(), Some("Sent to Companion yoi.")); - } - - #[test] - fn multi_open_request_keeps_dashboard_state_for_nested_single_pod() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - app.input.insert_str("draft survives open"); - - let request = app.prepare_open().unwrap(); - - assert_eq!(request.pod_name, "alpha"); - assert_eq!( - request.socket_override, - Some(PathBuf::from("/tmp/alpha.sock")) - ); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert_eq!(input_text(&app), "draft survives open"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Attaching to alpha") - ); - } - - #[test] - fn multi_open_failure_keeps_composer_and_sets_notice() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - app.input.insert_str("keep this draft"); - let before = input_text(&app); - let error = io::Error::other("boom"); - - app.finish_open("alpha", Err(&error)); - - assert_eq!(input_text(&app), before); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert!( - app.notice - .as_deref() - .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] - fn multi_open_disabled_target_stays_in_dashboard() { - let mut live = live_info("unreachable", PodStatus::Idle); - live.reachable = false; - live.status = None; - let mut app = test_app(vec![live]); - - assert!(app.prepare_open().is_none()); - assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); - } - - #[test] - fn multi_empty_enter_uses_open_action() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::Open - )); - let request = app.prepare_open().unwrap(); - - assert_eq!(request.pod_name, "alpha"); - assert_eq!( - request.socket_override, - Some(PathBuf::from("/tmp/alpha.sock")) - ); - assert_eq!(input_text(&app), ""); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Attaching to alpha") - ); - } - - #[test] - fn multi_whitespace_only_enter_uses_open_action() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - app.input.insert_str(" \n\t"); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::Open - )); - let request = app.prepare_open().unwrap(); - - assert_eq!(request.pod_name, "alpha"); - assert_eq!(input_text(&app), " \n\t"); - } - - #[test] - fn multi_non_empty_enter_reports_companion_unavailable() { - let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); - app.input.insert_str("keep this draft"); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::None - )); - - assert_eq!(input_text(&app), "keep this draft"); - assert!(!app.sending); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Workspace Companion is unavailable") - ); - } - - #[test] - fn multi_alt_enter_inserts_newline_without_companion_send() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.input.insert_str("first line"); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), - MultiPodAction::None - )); - - assert_eq!(input_text(&app), "first line\n"); - assert!(!app.sending); - assert!(app.notice.is_none()); - } - - #[test] - fn multi_alt_enter_on_blank_pod_selection_inserts_newline_without_opening() { - let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); - let selected_before = app.selected_row.clone(); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), - MultiPodAction::None - )); - - assert_eq!(input_text(&app), "\n"); - assert_eq!(app.selected_row, selected_before); - assert!(app.notice.is_none()); - } - - #[test] - fn multi_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel( - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), - panel, - ); - let selected_before = app.selected_row.clone(); - assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue)); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), - MultiPodAction::None - )); - - assert_eq!(input_text(&app), "\n"); - assert_eq!(app.selected_row, selected_before); - assert!(!app.sending); - assert!(app.notice.is_none()); - } - - #[test] - fn multi_composer_shared_word_motion_and_delete_keys() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.input.insert_str("hello world"); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Left, KeyModifiers::CONTROL)), - MultiPodAction::None - )); - assert!(matches!( - app.handle_key(key(KeyCode::Char('!'))), - MultiPodAction::None - )); - assert_eq!(input_text(&app), "hello !world"); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Right, KeyModifiers::CONTROL)), - MultiPodAction::None - )); - assert!(matches!( - app.handle_key(modified_key(KeyCode::Char('w'), KeyModifiers::CONTROL)), - MultiPodAction::None - )); - assert_eq!(input_text(&app), "hello !"); - } - - #[test] - fn multi_esc_clears_row_selection_without_quitting_and_preserves_draft() { - let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]); - app.input.insert_str("draft message"); - - assert!(app.selected_row.is_some()); - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - MultiPodAction::None - )); - assert!(app.selected_row.is_none()); - assert_eq!(input_text(&app), "draft message"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Row selection cleared") - ); - assert!(matches!( - app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)), - MultiPodAction::Quit - )); - } - - #[test] - fn multi_composer_target_switch_preserves_typed_text() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.input.insert_str("draft intake request"); - - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - let selected_before = app.selected_row.clone(); - assert!(matches!( - app.handle_key(key(KeyCode::Tab)), - MultiPodAction::None - )); - - assert!(matches!( - app.composer_target(), - ComposerTarget::TicketIntake - )); - assert_eq!(app.selected_row, selected_before); - assert_eq!(input_text(&app), "draft intake request"); - } - - #[test] - fn multi_ctrl_t_does_not_switch_composer_target() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.input.insert_str("draft intake request"); - - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - assert!(matches!( - app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)), - MultiPodAction::None - )); - - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - assert_eq!(input_text(&app), "draft intake request"); - } - - #[test] - fn multi_no_ticket_workspace_exposes_only_companion_target() { - let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); - app.input.insert_str("draft message"); - - app.cycle_composer_target(); - - assert_eq!( - app.panel.composer.available_targets, - vec![ComposerTarget::Companion] - ); - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - assert_eq!(input_text(&app), "draft message"); - assert!(app.notice.as_deref().unwrap().contains("unavailable")); - } - - #[test] - fn multi_blank_ticket_intake_enter_uses_selected_row_and_preserves_input() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str(" \n\t"); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::Open - )); - - assert!(matches!( - app.composer_target(), - ComposerTarget::TicketIntake - )); - assert!(!app.sending); - assert_eq!(input_text(&app), " \n\t"); - assert!( - !app.notice - .as_deref() - .unwrap_or_default() - .contains("input is empty") - ); - } - - #[test] - fn multi_ticket_intake_enter_builds_launch_request_not_direct_send() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str("please intake this work"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - MultiPodAction::LaunchIntake(request) => request, - _ => panic!("Ticket Intake target should launch Intake"), - }; - - assert_eq!(request.context.role, TicketRole::Intake); - assert_eq!( - request.context.user_instruction.as_deref(), - Some("please intake this work") - ); - assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi")); - assert_eq!( - request.context.intake_handoff, - Some(TicketIntakeHandoff::new("test-orchestrator", "test")) - ); - assert_eq!( - request.peer_registration, - IntakePeerRegistrationRequest::Register { - orchestrator_pod: "test-orchestrator".to_string() - } - ); - assert!(app.sending); - assert!(app.notice.as_deref().unwrap().contains("Launching")); - assert_eq!(input_text(&app), "please intake this work"); - } - - #[test] - fn multi_ticket_intake_handoff_skips_peer_registration_when_orchestrator_not_live() { - let mut app = ticket_enabled_app_with_orchestrator( - vec![live_info("idle", PodStatus::Idle)], - OrchestratorPanelStatus::Unavailable, - ); - app.cycle_composer_target(); - app.input.insert_str("please intake this work"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - MultiPodAction::LaunchIntake(request) => request, - _ => panic!("Ticket Intake target should launch Intake"), - }; - - assert_eq!( - request.context.intake_handoff, - Some(TicketIntakeHandoff::new("test-orchestrator", "test")) - ); - match request.peer_registration { - IntakePeerRegistrationRequest::Skip { reason } => { - assert!(reason.contains("test-orchestrator")); - assert!(reason.contains("unavailable")); - } - other => panic!("expected peer registration skip, got {other:?}"), - } - } - - #[test] - fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str("please intake this work"); - app.sending = true; - - app.finish_intake_launch(Ok(IntakeLaunchOutcome { - launch: TicketRoleLaunchResult { - plan: client::ticket_role::TicketRoleLaunchPlan { - workspace_root: PathBuf::from("/tmp/workspace"), - cwd: None, - original_workspace_root: PathBuf::from("/tmp/workspace"), - target_workspace_root: PathBuf::from("/tmp/workspace"), - implementation_worktree_root: PathBuf::from("/tmp/workspace/.worktree"), - role: TicketRole::Intake, - pod_name: "intake-pod".to_string(), - profile: "builtin:default".to_string(), - workflow: "ticket-intake-workflow".to_string(), - launch_prompt_ref: None, - run_segments: vec![], - }, - ready: client::SpawnReady { - pod_name: "intake-pod".to_string(), - socket_path: PathBuf::from("/tmp/intake.sock"), - }, - pre_run_warnings: vec![], - }, - peer_registration: IntakePeerRegistrationStatus::Registered { - orchestrator_pod: "test-orchestrator".to_string(), - }, - registry_warning: None, - })); - - assert!(!app.sending); - assert_eq!(input_text(&app), ""); - let notice = app.notice.as_deref().unwrap(); - assert!(notice.contains("intake-pod")); - assert!(notice.contains("Handoff peer registered")); - } - - #[test] - fn multi_ticket_intake_finish_failure_keeps_composer() { - let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str("please keep this"); - app.sending = true; - - app.finish_intake_launch(Err(TicketRoleLaunchError::EmptyPodName)); - - assert!(!app.sending); - assert_eq!(input_text(&app), "please keep this"); - assert!(app.notice.as_deref().unwrap().contains("composer kept")); - } - - #[test] - fn intake_registry_update_claim_is_durable_only_after_commit() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - let update = IntakeRegistryUpdate::ClaimTicket { - registry_root: root, - ticket_id: "20260608-000000-existing".to_string(), - ticket_slug: Some("existing".to_string()), - pod_name: "existing-intake".to_string(), - }; - - assert!( - store - .claim_for_ticket("20260608-000000-existing") - .unwrap() - .is_none(), - "holding a pending Intake registry update must not persist a Ticket claim" - ); - - assert!(commit_intake_registry_update(update.clone(), None).is_none()); - assert!( - store - .claim_for_ticket("20260608-000000-existing") - .unwrap() - .is_some(), - "the claim is persisted only by the post-acceptance commit step" - ); - - assert!(commit_intake_registry_update(update, None).is_none()); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.claims.len(), 1); - assert_eq!(snapshot.sessions.len(), 1); - assert_eq!(snapshot.sessions[0].pod_name, "existing-intake"); - assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); - assert_eq!(snapshot.sessions[0].related_tickets.len(), 1); - assert_eq!( - snapshot.sessions[0].related_tickets[0].id, - "20260608-000000-existing" - ); - } - - #[test] - fn intake_registry_claims_launched_ticket_with_accepted_pod_name() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - let update = IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: root, - ticket_id: "20260608-000000-ready".to_string(), - ticket_slug: None, - }; - - assert!(commit_intake_registry_update(update, Some("launched-intake")).is_none()); - - let claim = store - .claim_for_ticket("20260608-000000-ready") - .unwrap() - .expect("launched Intake Pod is claimed after accepted launch"); - assert_eq!(claim.pod_name, "launched-intake"); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.claims.len(), 1); - assert_eq!(snapshot.sessions.len(), 1); - assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); - assert_eq!(snapshot.sessions[0].pod_name, "launched-intake"); - } - - #[test] - fn intake_registry_launched_ticket_claim_without_pod_name_is_diagnostic() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - - let warning = commit_intake_registry_update( - IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: root, - ticket_id: "20260608-000000-ready".to_string(), - ticket_slug: None, - }, - None, - ) - .expect("missing launched Pod name should be diagnostic"); - - assert!(warning.contains("missing launched Pod name")); - assert!( - store - .claim_for_ticket("20260608-000000-ready") - .unwrap() - .is_none() - ); - } - - #[test] - fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - store - .claim_ticket( - "20260608-000001-existing", - Some("existing"), - "first-intake", - TicketRole::Intake.as_str(), - ) - .unwrap(); - - let warning = commit_intake_registry_update( - IntakeRegistryUpdate::ClaimTicket { - registry_root: root, - ticket_id: "20260608-000001-existing".to_string(), - ticket_slug: Some("existing".to_string()), - pod_name: "second-intake".to_string(), - }, - None, - ) - .expect("conflicting post-success claim should be reported"); - - assert!(warning.contains("could not be committed")); - let claim = store - .claim_for_ticket("20260608-000001-existing") - .unwrap() - .unwrap(); - assert_eq!(claim.pod_name, "first-intake"); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.claims.len(), 1); - assert_eq!(snapshot.sessions.len(), 1); - assert_eq!(snapshot.sessions[0].pod_name, "first-intake"); - } - - #[test] - fn multi_empty_enter_on_non_openable_row_reports_open_diagnostic() { - let mut app = test_app(vec![unreachable_live_info("unreachable")]); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - MultiPodAction::Open - )); - assert!(app.prepare_open().is_none()); - - assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); - } - - #[test] - fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - app.refresh_orchestrator_work_set(); - - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("idle orchestrator should receive queued-work attention"); - - assert_eq!(request.pod_name, "test-orchestrator"); - assert!(request.notice.message.contains("00001QUEUE")); - assert!(request.notice.message.contains("new_queued")); - assert!(request.notice.message.contains("queued -> inprogress")); - } - - #[test] - fn active_inprogress_suppresses_queued_attention_and_retains_waiting_reason() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); - app.panel.rows = vec![ - panel_test_ticket_row( - "00001ACTIVE", - "Active work", - ActionPriority::Background, - NextUserAction::Wait, - "inprogress", - ), - panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ), - ]; - app.refresh_orchestrator_work_set(); - app.apply_orchestrator_work_set_detail(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - let queued = app - .orchestrator_work_set - .queued - .iter() - .find(|item| item.id == "00001QUEUE") - .expect("queued item retained"); - assert_eq!( - queued.classification, - OrchestratorQueuedClassification::PlannedQueued - ); - assert!( - queued - .waiting_reason - .as_deref() - .unwrap() - .contains("active_inprogress") - ); - assert!( - app.panel - .header - .orchestrator - .as_ref() - .unwrap() - .detail - .as_deref() - .unwrap() - .contains("suppressed") - ); - } - - #[test] - fn planned_queued_prompts_when_active_work_clears() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); - app.panel.rows = vec![ - panel_test_ticket_row( - "00001ACTIVE", - "Active work", - ActionPriority::Background, - NextUserAction::Wait, - "inprogress", - ), - panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ), - ]; - app.refresh_orchestrator_work_set(); - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - app.refresh_orchestrator_work_set(); - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("planned queued work should prompt after active work clears"); - - assert!(request.notice.message.contains("planned_queued")); - assert!( - !request - .notice - .message - .contains("waiting for active_inprogress") - ); - } - - #[test] - fn queued_attention_is_suppressed_when_existing_claim_prevents_duplicate_start() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); - let mut row = panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ); - row.ticket.as_mut().unwrap().local_claim = - Some(crate::workspace_panel::TicketLocalClaimEntry { - pod_name: "coder-00001QUEUE".to_string(), - role: "coder".to_string(), - status: TicketLocalClaimStatus::Live, - }); - row.related_pods.push("reviewer-00001QUEUE".to_string()); - app.panel.rows = vec![row]; - app.refresh_orchestrator_work_set(); - app.apply_orchestrator_work_set_detail(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - let waiting = app.orchestrator_work_set.queued[0] - .waiting_reason - .as_deref() - .unwrap(); - assert!(waiting.contains("duplicate start")); - assert!(waiting.contains("coder-00001QUEUE")); - } - - #[test] - fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); - app.orchestrator_work_set = OrchestratorWorkSet::default(); - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("queued ticket state should be rediscovered safely"); - - assert!(request.notice.message.contains("new_queued")); - assert!(request.notice.message.contains("00001QUEUE")); - } - - #[test] - fn queued_attention_requires_idle_orchestrator_to_avoid_duplicate_rekick() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Running)]); - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - app.refresh_orchestrator_work_set(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - } - - fn test_app(live: Vec) -> MultiPodApp { - app_with_list(PodList::from_sources( - PodVisibilitySource::ResumePicker, - vec![], - live, - None, - 10, - )) - } - - fn companion_app(live: Vec, status: CompanionPanelStatus) -> MultiPodApp { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.companion = Some(CompanionPanelState::new("yoi", status, None)); - app_with_panel( - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), - panel, - ) - } - - fn ticket_enabled_app(live: Vec) -> MultiPodApp { - ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live) - } - - fn ticket_enabled_app_with_orchestrator( - live: Vec, - orchestrator_status: OrchestratorPanelStatus, - ) -> MultiPodApp { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); - panel.header.companion = Some(CompanionPanelState::new( - "yoi", - CompanionPanelStatus::Live, - None, - )); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - orchestrator_status, - None, - )); - app_with_panel( - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), - panel, - ) - } - - fn app_with_list(list: PodList) -> MultiPodApp { - app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test"))) - } - - fn empty_test_list() -> PodList { - PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10) - } - - fn panel_with_orchestrator( - status: OrchestratorPanelStatus, - detail: Option<&str>, - ) -> WorkspacePanelViewModel { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - status, - detail.map(str::to_string), - )); - if let Some(detail) = detail { - panel.header.diagnostics.push(detail.to_string()); - } - panel - } - - fn app_with_panel(list: PodList, panel: WorkspacePanelViewModel) -> MultiPodApp { - let last_companion_lifecycle_failure = companion_lifecycle_failure_from_panel(&panel); - let last_orchestrator_lifecycle_failure = orchestrator_lifecycle_failure_from_panel(&panel); - let mut app = MultiPodApp { - list, - panel, - input: InputBuffer::new(), - selected_row: None, - row_hit_boxes: Vec::new(), - composer_target: ComposerTarget::Companion, - notice: None, - panel_diagnostic: None, - panel_diagnostic_open: false, - sending: false, - refreshing: false, - enter_reload: None, - runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), - last_companion_lifecycle_failure, - last_orchestrator_lifecycle_failure, - orchestrator_work_set: OrchestratorWorkSet::default(), - orchestrator_queue_attention: None, - }; - app.ensure_selection_visible(); - app.ensure_composer_target_available(); - app.refresh_orchestrator_work_set(); - app.apply_orchestrator_work_set_detail(); - app - } - - fn panel_test_ticket_row( - id: &str, - title: &str, - priority: ActionPriority, - next_action: NextUserAction, - state: &str, - ) -> PanelRow { - let ticket = crate::workspace_panel::TicketPanelEntry { - id: id.to_string(), - title: title.to_string(), - priority: "P2".to_string(), - workflow_state: TicketWorkflowState::parse(state) - .unwrap_or(TicketWorkflowState::Planning), - workflow_state_explicit: true, - orchestration_overlay: None, - next_action: Some(next_action), - updated_at: None, - latest_event_kind: Some("implementation_report".to_string()), - latest_event_excerpt: Some("latest event stays out of the primary row".to_string()), - blocked_reason: None, - related_pods: Vec::new(), - local_claim: None, - intake_pods: Vec::new(), - }; - PanelRow { - key: PanelRowKey::Ticket(ticket.id.clone()), - kind: crate::workspace_panel::PanelRowKind::Ticket, - title: title.to_string(), - subtitle: Some("id · priority · latest event".to_string()), - status: state.to_string(), - priority, - next_action: Some(next_action), - ticket: Some(ticket), - related_pods: Vec::new(), - disabled_reason: None, - key_hint: Some("Enter".to_string()), - } - } - - fn panel_test_intake_child_row( - ticket_id: &str, - pod_name: &str, - status: TicketLocalClaimStatus, - next_action: Option, - ) -> PanelRow { - PanelRow { - key: PanelRowKey::TicketIntakePod { - ticket_id: ticket_id.to_string(), - pod_name: pod_name.to_string(), - }, - kind: PanelRowKind::TicketIntakePod, - title: format!("Intake Pod: {pod_name}"), - subtitle: Some(format!("Intake claim for Ticket {ticket_id}")), - status: status.label().to_string(), - priority: match status { - TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => { - ActionPriority::ActiveWork - } - TicketLocalClaimStatus::Stale => ActionPriority::Background, - }, - next_action, - ticket: None, - related_pods: vec![pod_name.to_string()], - disabled_reason: (status == TicketLocalClaimStatus::Stale) - .then(|| "claim metadata is stale".to_string()), - key_hint: Some(format!("Ticket {ticket_id} Intake Pod {pod_name}")), - } - } - - fn closed_list(count: usize, selected: Option<&str>) -> PodList { - PodList::from_sources( - PodVisibilitySource::ResumePicker, - (0..count) - .map(|index| { - stopped_info_with_updated_at(&format!("closed-{index}"), 100 - index as u64) - }) - .collect(), - vec![], - selected.map(str::to_string), - count.max(1), - ) - } - - fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo { - live_info_with_updated_at(pod_name, status, 0) - } - - fn unreachable_live_info(pod_name: &str) -> LivePodInfo { - let mut live = live_info(pod_name, PodStatus::Idle); - live.reachable = false; - live.status = None; - live - } - - fn live_info_with_updated_at( - pod_name: &str, - status: PodStatus, - updated_at: u64, - ) -> LivePodInfo { - LivePodInfo { - pod_name: pod_name.to_string(), - socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")), - status: Some(status), - reachable: true, - segment_id: None, - summary: PodEntrySummary { - active_session_id: None, - active_segment_id: None, - updated_at, - preview: None, - }, - } - } - - fn stopped_info(pod_name: &str) -> StoredPodInfo { - stopped_info_with_updated_at(pod_name, 10) - } - - fn stopped_info_with_updated_at(pod_name: &str, updated_at: u64) -> StoredPodInfo { - StoredPodInfo { - pod_name: pod_name.to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at, - workspace_root: None, - preview: None, - } - } - - fn section_names<'a>(list: &'a PodList, section: &MultiPodSection) -> Vec<&'a str> { - section - .entries - .iter() - .map(|index| list.entries[*index].name.as_str()) - .collect() - } - - #[test] - fn ticket_action_error_records_f2_diagnostic_details() { - let mut app = MultiPodApp::loading(PodRuntimeCommand::for_executable("/tmp/yoi")); - let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/multi_pod.rs"; - - app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string()))); - - assert!(app.notice.as_deref().unwrap().contains("F2 details")); - let diagnostic = app.panel_diagnostic.as_ref().expect("diagnostic"); - assert_eq!(diagnostic.title, "Ticket action rejected"); - assert_eq!(diagnostic.details, long_error); - assert!(!app.panel_diagnostic_open); - - assert!(matches!( - app.handle_key(key(KeyCode::F(2))), - MultiPodAction::None - )); - assert!(app.panel_diagnostic_open); - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - MultiPodAction::None - )); - assert!(!app.panel_diagnostic_open); - } - - fn plain_line(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - } - - fn display_column(text: &str, needle: &str) -> usize { - let byte_index = text.find(needle).unwrap(); - text[..byte_index].width() - } - - fn input_text(app: &MultiPodApp) -> String { - Segment::flatten_to_text(&app.input.submit_segments()) - } - - fn key(code: KeyCode) -> KeyEvent { - modified_key(code, KeyModifiers::NONE) - } - - fn left_click(column: u16, row: u16) -> MouseEvent { - MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column, - row, - modifiers: KeyModifiers::NONE, - } - } - - fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { - KeyEvent::new(code, modifiers) - } -} +mod tests; diff --git a/crates/tui/src/dashboard/render.rs b/crates/tui/src/dashboard/render.rs new file mode 100644 index 00000000..e631cc4f --- /dev/null +++ b/crates/tui/src/dashboard/render.rs @@ -0,0 +1,1025 @@ +use super::*; + +pub(super) fn draw(frame: &mut Frame<'_>, app: &mut DashboardApp) { + let area = frame.area(); + let input_content_width = area.width.saturating_sub(2).max(1); + let mut input_render = app.input.render(input_content_width); + let input_height = input_area_height(&input_render, area.height); + app.input + .apply_cursor_viewport(&mut input_render, input_height); + let layout = dashboard_layout(area, input_height); + + draw_title(frame, app, layout.title); + draw_list(frame, app, layout.list); + draw_separator(frame, layout.boundary); + draw_target_status(frame, app, layout.target_status); + draw_input(frame, &input_render, layout.input); + draw_actionbar(frame, app, layout.actionbar); + if app.panel_diagnostic_open { + render_panel_diagnostic(frame, app, area); + } +} + +pub(super) fn panel_diagnostic_area(area: Rect) -> Rect { + let width = if area.width <= 20 { + area.width + } else { + area.width.saturating_sub(4).min(100).max(20) + }; + let height = if area.height <= 8 { + area.height + } else { + area.height.saturating_sub(4).min(24).max(8) + }; + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + Rect::new(x, y, width, height) +} + +pub(super) fn render_panel_diagnostic(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { + let Some(diagnostic) = app.panel_diagnostic.as_ref() else { + return; + }; + let popup_area = panel_diagnostic_area(area); + let title = format!(" {} ", diagnostic.title); + let text = format!("{}\n\nF2/Esc: close", diagnostic.details); + let paragraph = Paragraph::new(text) + .block(Block::default().title(title).borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + frame.render_widget(Clear, popup_area); + frame.render_widget(paragraph, popup_area); +} + +pub(super) fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { + let needed = render.lines.len().max(1) as u16; + let cap = (terminal_height / 3).max(1).min(10); + needed.clamp(1, cap) +} + +pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { + let guidance = if app + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + " Row selection: blank Enter opens/dispatches · text Enter uses target · Tab target" + } else if app.panel.header.ticket_configured { + " Row selection: blank Enter opens/dispatches · text Enter sends to Companion" + } else { + " Pod-centric view · Row selection: blank Enter opens · text Enter sends to Companion" + }; + let mut spans = vec![ + Span::styled( + "workspace dashboard", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(guidance, Style::default().fg(Color::DarkGray)), + ]; + if let Some(companion) = &app.panel.header.companion { + spans.push(Span::styled( + " · companion ", + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled( + companion.status.label(), + companion_status_style(companion.status), + )); + if let Some(detail) = companion.detail.as_deref() { + spans.push(Span::styled( + format!(" ({detail})"), + Style::default().fg(Color::DarkGray), + )); + } + } + if let Some(orchestrator) = &app.panel.header.orchestrator { + spans.push(Span::styled( + " · orchestrator ", + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled( + orchestrator.status.label(), + orchestrator_status_style(orchestrator.status), + )); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style { + match status { + CompanionPanelStatus::Live + | CompanionPanelStatus::Restored + | CompanionPanelStatus::Spawned => Style::default().fg(Color::Green), + CompanionPanelStatus::Stopped | CompanionPanelStatus::Missing => { + Style::default().fg(Color::Yellow) + } + CompanionPanelStatus::Unavailable => Style::default().fg(Color::Red), + } +} + +pub(super) fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style { + match status { + OrchestratorPanelStatus::Live + | OrchestratorPanelStatus::Restored + | OrchestratorPanelStatus::Spawned => Style::default().fg(Color::Green), + OrchestratorPanelStatus::Stopped | OrchestratorPanelStatus::Missing => { + Style::default().fg(Color::Yellow) + } + OrchestratorPanelStatus::Unavailable => Style::default().fg(Color::Red), + } +} + +pub(super) fn draw_list(frame: &mut Frame<'_>, app: &mut DashboardApp, area: Rect) { + if area.width == 0 || area.height == 0 { + app.row_hit_boxes.clear(); + return; + } + let rows = list_rows(app, area.width, area.height); + app.set_row_hit_boxes(&rows, area); + let lines = rows.into_iter().map(|row| row.line).collect::>(); + Paragraph::new(lines).render(area, frame.buffer_mut()); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PanelListRow { + pub(super) line: Line<'static>, + pub(super) key: Option, +} + +impl PanelListRow { + fn inert(line: Line<'static>) -> Self { + Self { line, key: None } + } + + fn selectable(line: Line<'static>, key: PanelRowKey) -> Self { + Self { + line, + key: Some(key), + } + } +} + +#[cfg(test)] +pub(super) fn list_lines(app: &DashboardApp, width: u16, height: u16) -> Vec> { + list_rows(app, width, height) + .into_iter() + .map(|row| row.line) + .collect() +} + +pub(super) fn list_rows(app: &DashboardApp, width: u16, height: u16) -> Vec { + let sections = sectioned_entries(&app.list); + let selected = app.selected_row.as_ref(); + let diagnostic_rows = panel_diagnostic_lines(&app.panel, width) + .into_iter() + .map(PanelListRow::inert) + .collect::>(); + let action_rows = panel_action_rows(&app.panel, selected, width); + let live_rows = sections + .iter() + .filter(|section| section.kind != DashboardSectionKind::Closed) + .flat_map(|section| section_rows(&app.list, section, selected, width)) + .collect::>(); + let closed_rows = sections + .iter() + .find(|section| section.kind == DashboardSectionKind::Closed) + .map(|section| section_rows(&app.list, section, selected, width)) + .unwrap_or_default(); + + let available = height as usize; + let diagnostic_len = diagnostic_rows.len().min(available); + let remaining_after_diagnostics = available.saturating_sub(diagnostic_len); + let action_len = action_rows.len().min(remaining_after_diagnostics); + let remaining_after_actions = remaining_after_diagnostics.saturating_sub(action_len); + let closed_len = closed_rows.len().min(remaining_after_actions); + let live_len = live_rows + .len() + .min(remaining_after_actions.saturating_sub(closed_len)); + let spacer_len = available.saturating_sub(diagnostic_len + action_len + live_len + closed_len); + + let mut rows = Vec::with_capacity(available); + rows.extend(diagnostic_rows.into_iter().take(diagnostic_len)); + rows.extend(action_rows.into_iter().take(action_len)); + rows.extend(live_rows.into_iter().take(live_len)); + rows.extend( + std::iter::repeat_with(|| PanelListRow::inert(Line::from(Span::raw("")))).take(spacer_len), + ); + rows.extend(closed_rows.into_iter().take(closed_len)); + rows +} + +pub(super) fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec { + if area.width == 0 || area.height == 0 { + return Vec::new(); + } + + let mut hit_boxes: Vec = Vec::new(); + for (offset, row) in rows.iter().enumerate() { + let Some(key) = row.key.clone() else { + continue; + }; + let Some(y) = area.y.checked_add(offset as u16) else { + continue; + }; + if y >= area.y.saturating_add(area.height) { + continue; + } + if let Some(last) = hit_boxes.last_mut() { + if last.key == key + && last.rect.x == area.x + && last.rect.width == area.width + && last.rect.y.saturating_add(last.rect.height) == y + { + last.rect.height = last.rect.height.saturating_add(1); + continue; + } + } + hit_boxes.push(PanelRowHitBox { + rect: Rect::new(area.x, y, area.width, 1), + key, + }); + } + hit_boxes +} + +pub(super) fn panel_diagnostic_lines( + panel: &WorkspacePanelViewModel, + width: u16, +) -> Vec> { + panel + .header + .diagnostics + .iter() + .map(|diagnostic| { + Line::from(vec![ + Span::styled("⚠ ", Style::default().fg(Color::Yellow)), + Span::styled( + truncate_with_ellipsis(diagnostic, width.saturating_sub(2) as usize), + Style::default().fg(Color::Yellow), + ), + ]) + }) + .collect() +} + +pub(super) fn panel_action_rows( + panel: &WorkspacePanelViewModel, + selected: Option<&PanelRowKey>, + width: u16, +) -> Vec { + let rows = panel + .rows + .iter() + .filter(|row| row.is_ticket_section_row()) + .collect::>(); + if rows.is_empty() { + return Vec::new(); + } + let mut lines = Vec::with_capacity((rows.len() * 2) + 1); + lines.push(PanelListRow::inert(panel_action_header_line( + rows.len(), + width, + ))); + for row in rows { + for line in panel_row_lines(row, selected == Some(&row.key), width) { + lines.push(PanelListRow::selectable(line, row.key.clone())); + } + } + lines +} + +pub(super) fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { + let detail = if total == 1 { + " 1 row".to_string() + } else { + format!(" {total} rows") + }; + let text = truncate_with_ellipsis(&format!("--tickets{detail}---"), width as usize); + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + )) +} + +pub(super) const TICKET_STATE_COLUMN_WIDTH: usize = 10; +pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18; + +pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec> { + if row.kind == PanelRowKind::TicketIntakePod { + vec![panel_intake_child_line(row, selected, width)] + } else { + vec![ + panel_row_title_line(row, selected, width), + panel_row_detail_line(row, selected, width), + ] + } +} + +pub(super) fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { + let title_style = if selected { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Magenta) + }; + let mut spans = Vec::new(); + let mut remaining = width as usize; + + push_ticket_primary_marker_span(&mut spans, selected, &mut remaining); + push_column_span( + &mut spans, + &row.status, + TICKET_STATE_COLUMN_WIDTH, + panel_priority_style(row.priority), + &mut remaining, + ); + push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); + + Line::from(spans) +} + +pub(super) fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { + let title_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let mut spans = Vec::new(); + let mut remaining = width as usize; + + push_intake_child_marker_span(&mut spans, selected, &mut remaining); + push_column_span( + &mut spans, + &row.status, + TICKET_STATE_COLUMN_WIDTH, + intake_status_style(&row.status), + &mut remaining, + ); + push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); + + Line::from(spans) +} + +pub(super) fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { + let mut spans = Vec::new(); + let mut remaining = width as usize; + + push_ticket_detail_marker_span(&mut spans, selected, &mut remaining); + push_bounded_span( + &mut spans, + "meta ", + Style::default().fg(Color::DarkGray), + &mut remaining, + ); + push_bounded_span( + &mut spans, + &panel_ticket_detail(row), + ticket_detail_style(row), + &mut remaining, + ); + + Line::from(spans) +} + +pub(super) fn push_ticket_primary_marker_span( + spans: &mut Vec>, + selected: bool, + remaining: &mut usize, +) { + let (marker, style) = if selected { + ( + "▶ ", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ) + } else { + (" ", Style::default().fg(Color::DarkGray)) + }; + push_bounded_span(spans, marker, style, remaining); +} + +pub(super) fn push_ticket_detail_marker_span( + spans: &mut Vec>, + selected: bool, + remaining: &mut usize, +) { + let (marker, style) = if selected { + ( + "│ ", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ) + } else { + (" ", Style::default().fg(Color::DarkGray)) + }; + push_bounded_span(spans, marker, style, remaining); +} + +pub(super) fn push_intake_child_marker_span( + spans: &mut Vec>, + selected: bool, + remaining: &mut usize, +) { + let (marker, style) = if selected { + ( + " ▶ ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + (" └ ", Style::default().fg(Color::DarkGray)) + }; + push_bounded_span(spans, marker, style, remaining); +} + +pub(super) fn panel_ticket_detail(row: &PanelRow) -> String { + if row.kind == PanelRowKind::InvalidTicket { + let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()]; + if let Some(reason) = panel_ticket_reason(row) { + parts.push(format!("Reason: {reason}")); + } + return parts.join(" · "); + } + + if row.kind == PanelRowKind::TicketIntakePod { + let mut parts = row + .subtitle + .as_ref() + .map(|subtitle| vec![subtitle.clone()]) + .unwrap_or_else(|| vec![panel_ticket_reference(row)]); + if let Some(action) = row.next_action { + parts.push(format!("Action: {}", action.label())); + } + if let Some(reason) = panel_ticket_reason(row) { + parts.push(format!("Reason: {reason}")); + } + return parts.join(" · "); + } + + let mut parts = vec![panel_ticket_reference(row)]; + if let Some(overlay_detail) = panel_ticket_overlay_detail(row) { + parts.push(overlay_detail); + } + if let Some(blocked_reason) = row + .ticket + .as_ref() + .and_then(|ticket| ticket.blocked_reason.as_deref()) + { + parts.push(format!("Gate: waiting for {blocked_reason}")); + } else { + parts.push("Gate: clear".to_string()); + } + if let Some(action) = row.next_action { + parts.push(format!( + "Action: {}", + panel_ticket_action_label(row, action) + )); + } + if let Some(reason) = panel_ticket_reason(row) { + parts.push(format!("Reason: {reason}")); + } + parts.join(" · ") +} + +pub(super) fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str { + if action == NextUserAction::Wait + && row + .ticket + .as_ref() + .and_then(|ticket| ticket.blocked_reason.as_ref()) + .is_some() + { + "queue disabled" + } else { + action.label() + } +} + +pub(super) fn panel_ticket_overlay_detail(row: &PanelRow) -> Option { + let ticket = row.ticket.as_ref()?; + let overlay = ticket.orchestration_overlay.as_ref()?; + let mut detail = format!( + "Overlay: local {} · {} {}", + ticket.workflow_state.as_str(), + overlay.source, + overlay.workflow_state.as_str() + ); + if matches!( + overlay.workflow_state, + TicketWorkflowState::Done | TicketWorkflowState::Closed + ) { + detail.push_str(" · merge pending"); + } + Some(detail) +} + +pub(super) fn panel_ticket_reason(row: &PanelRow) -> Option<&str> { + row.disabled_reason + .as_deref() + .or_else(|| row.key_hint.as_deref()) +} + +pub(super) fn ticket_detail_style(row: &PanelRow) -> Style { + if row.kind == PanelRowKind::InvalidTicket { + return Style::default().fg(Color::Yellow); + } + if row + .ticket + .as_ref() + .and_then(|ticket| ticket.blocked_reason.as_ref()) + .is_some() + { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + } +} + +pub(super) fn panel_ticket_reference(row: &PanelRow) -> String { + row.ticket + .as_ref() + .map(|ticket| ticket.id.clone()) + .unwrap_or_else(|| match &row.key { + PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), + PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(), + PanelRowKey::Pod(name) => name.clone(), + }) +} + +pub(super) fn push_column_span( + spans: &mut Vec>, + value: &str, + column_width: usize, + style: Style, + remaining: &mut usize, +) { + if *remaining == 0 { + return; + } + let mut content = padded_cell(value, column_width); + content.push(' '); + push_bounded_span(spans, &content, style, remaining); +} + +pub(super) fn push_bounded_span( + spans: &mut Vec>, + value: &str, + style: Style, + remaining: &mut usize, +) { + if *remaining == 0 || value.is_empty() { + return; + } + let content = truncate_with_ellipsis(value, *remaining); + *remaining = remaining.saturating_sub(content.width()); + spans.push(Span::styled(content, style)); +} + +pub(super) fn padded_cell(value: &str, width: usize) -> String { + let mut cell = truncate_with_ellipsis(value, width); + let padding = width.saturating_sub(cell.width()); + cell.extend(std::iter::repeat_n(' ', padding)); + cell +} + +pub(super) fn panel_priority_style(priority: ActionPriority) -> Style { + match priority { + ActionPriority::ReadyForQueue => Style::default().fg(Color::Green), + ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), + ActionPriority::Background => Style::default().fg(Color::DarkGray), + } +} + +pub(super) fn intake_status_style(status: &str) -> Style { + match status { + "live" => Style::default().fg(Color::Green), + "restorable" => Style::default().fg(Color::Yellow), + "stale" => Style::default().fg(Color::DarkGray), + _ => Style::default().fg(Color::Cyan), + } +} + +pub(super) fn section_rows( + list: &PodList, + section: &DashboardSection, + selected: Option<&PanelRowKey>, + width: u16, +) -> Vec { + let visible = visible_section_indices(section); + if visible.is_empty() { + return Vec::new(); + } + + let mut rows = Vec::with_capacity(visible.len() + 1); + rows.push(PanelListRow::inert(section_header_line( + section.kind, + section.entries.len(), + section.hidden_count(), + width, + ))); + for index in visible { + if let Some(entry) = list.entries.get(index) { + let key = PanelRowKey::Pod(entry.name.clone()); + let selected = selected == Some(&key); + rows.push(PanelListRow::selectable( + row_line(entry, selected, width), + key, + )); + } + } + rows +} + +pub(super) fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> { + let marker = if selected { "▶ " } else { " " }; + let name_style = if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + let (status, status_style) = row_status_label(entry); + let mut spans = Vec::new(); + let mut remaining = width as usize; + + push_bounded_span( + &mut spans, + marker, + if selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }, + &mut remaining, + ); + push_column_span( + &mut spans, + status, + POD_STATUS_COLUMN_WIDTH, + status_style, + &mut remaining, + ); + push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining); + + Line::from(spans) +} + +pub(super) fn draw_separator(frame: &mut Frame<'_>, area: Rect) { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "─".repeat(area.width as usize), + Style::default().fg(Color::DarkGray), + ))), + area, + ); +} + +pub(super) fn draw_target_status(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { + frame.render_widget(Paragraph::new(target_status_line(app)), area); +} + +pub(super) fn target_status_line(app: &DashboardApp) -> Line<'static> { + if !app.composer_is_blank() { + return Line::from(vec![ + Span::styled("composer target ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · draft Enter ", Style::default().fg(Color::DarkGray)), + Span::styled( + composer_enter_status_text(app), + Style::default().fg(Color::Green), + ), + Span::styled( + " · row selection waits until composer is blank", + Style::default().fg(Color::DarkGray), + ), + ]); + } + + if let Some(row) = app + .selected_panel_row() + .filter(|row| row.is_ticket_action()) + { + let action = row + .next_action + .map(|action| panel_ticket_action_label(row, action)) + .unwrap_or("View"); + let mut spans = vec![ + Span::styled("composer target ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · selected Ticket ", Style::default().fg(Color::DarkGray)), + Span::styled(row.status.clone(), panel_priority_style(row.priority)), + Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)), + Span::styled(action, Style::default().fg(Color::Magenta)), + ]; + if let Some(reason) = panel_ticket_reason(row) { + spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + truncate_with_ellipsis(reason, 100), + ticket_detail_style(row), + )); + } + Line::from(spans) + } else if let Some(row) = app + .selected_panel_row() + .filter(|row| row.kind == PanelRowKind::TicketIntakePod) + { + let ticket_id = panel_ticket_reference(row); + let action = if row.next_action == Some(NextUserAction::OpenPod) { + "open/attach" + } else { + "unavailable" + }; + Line::from(vec![ + Span::styled("composer target ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " · selected Intake Pod ", + Style::default().fg(Color::DarkGray), + ), + Span::styled(row.status.clone(), intake_status_style(&row.status)), + Span::styled( + format!(" · Ticket {ticket_id} · blank Enter {action}"), + Style::default().fg(Color::DarkGray), + ), + ]) + } else if let Some(entry) = app.selected_pod_entry() { + let (status, status_style) = row_status_label(entry); + Line::from(vec![ + Span::styled("composer target ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · selected Pod ", Style::default().fg(Color::DarkGray)), + Span::styled(status.to_string(), status_style), + Span::styled( + " · blank Enter open/attach", + Style::default().fg(Color::DarkGray), + ), + ]) + } else { + Line::from(vec![ + Span::styled("composer target ", Style::default().fg(Color::DarkGray)), + Span::styled( + app.composer_target().label(), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + " · no row selected · ↑/↓ selects a row", + Style::default().fg(Color::DarkGray), + ), + ]) + } +} + +pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { + let mut lines: Vec> = Vec::with_capacity(render.lines.len()); + for (i, src) in render.lines.iter().enumerate() { + let absolute_row = render.viewport_start_row as usize + i; + let prefix = if absolute_row == 0 { "> " } else { " " }; + let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))]; + spans.extend(src.spans.iter().cloned()); + lines.push(Line::from(spans)); + } + frame.render_widget(Paragraph::new(lines), area); + + let cursor_x = area.x + 2 + render.cursor_col; + let cursor_y = area.y + render.cursor_row; + if cursor_y < area.y + area.height { + frame.set_cursor_position(Position::new(cursor_x, cursor_y)); + } +} + +pub(super) fn composer_enter_status_text(app: &DashboardApp) -> String { + match app.composer_target() { + ComposerTarget::Companion => companion_enter_status_text(app), + ComposerTarget::TicketIntake + if app.selected_ticket_action() == Some(NextUserAction::Queue) => + { + "return selected ready Ticket to planning".to_string() + } + ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(), + } +} + +pub(super) fn composer_enter_actionbar_text(app: &DashboardApp) -> String { + match app.composer_target() { + ComposerTarget::Companion => companion_enter_actionbar_text(app), + ComposerTarget::TicketIntake if app.selected_ticket_action() == Some(NextUserAction::Queue) => { + "Ticket Intake target: Enter records instructions and returns selected ready Ticket to planning".to_string() + } + ComposerTarget::TicketIntake => { + "Ticket Intake target: Enter launches Intake with composer text".to_string() + } + } +} + +pub(super) fn companion_enter_status_text(app: &DashboardApp) -> String { + match companion_send_availability(app) { + CompanionSendAvailability::Ready => "send composer text to workspace Companion".to_string(), + CompanionSendAvailability::Unavailable(reason) => format!("keep draft; {reason}"), + } +} + +pub(super) fn companion_enter_actionbar_text(app: &DashboardApp) -> String { + match companion_send_availability(app) { + CompanionSendAvailability::Ready => { + "Companion target: Enter sends composer text to workspace Companion".to_string() + } + CompanionSendAvailability::Unavailable(reason) => { + format!("Companion target: Enter keeps draft; {reason}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CompanionSendAvailability { + Ready, + Unavailable(String), +} + +pub(super) fn companion_send_availability(app: &DashboardApp) -> CompanionSendAvailability { + let Some(companion) = app.panel.header.companion.as_ref() else { + return CompanionSendAvailability::Unavailable( + "workspace Companion is unavailable".to_string(), + ); + }; + if matches!( + companion.status, + CompanionPanelStatus::Unavailable + | CompanionPanelStatus::Missing + | CompanionPanelStatus::Stopped + ) { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion is {}", + companion.status.label() + )); + } + let Some(entry) = app + .list + .entries + .iter() + .find(|entry| entry.name == companion.pod_name) + else { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is not in the Pod list", + companion.pod_name + )); + }; + let Some(live) = entry.live.as_ref() else { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is stopped", + companion.pod_name + )); + }; + if !live.reachable { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is unreachable", + companion.pod_name + )); + } + if live.status == Some(PodStatus::Running) { + return CompanionSendAvailability::Unavailable(format!( + "workspace Companion `{}` is running", + companion.pod_name + )); + } + CompanionSendAvailability::Ready +} + +pub(super) fn actionbar_left_text(app: &DashboardApp) -> String { + if app.sending && app.composer_target() == ComposerTarget::TicketIntake { + "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 !app.composer_is_blank() { + composer_enter_actionbar_text(app) + } else if let Some(notice) = app.notice.as_deref() { + notice.to_string() + } else if let Some(reason) = app.selected_open_disabled_reason() { + reason + } else { + match app.composer_target() { + ComposerTarget::Companion => { + "Composer target: Companion; type text to send, or use ↑/↓ then blank Enter for rows" + .to_string() + } + ComposerTarget::TicketIntake => { + if app.selected_ticket_action() == Some(NextUserAction::Queue) { + "Composer target: Ticket Intake; text + Enter returns selected ready Ticket to planning".to_string() + } else { + "Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string() + } + } + } + } +} + +pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str { + if app.panel_diagnostic_open { + "F2/Esc close details Ctrl+C quit" + } else if app.panel_diagnostic.is_some() { + "F2 details ↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit" + } else if !app.composer_is_blank() { + if app + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + "↑/↓ draft lines Left/Right cursor Enter composer target Tab target Esc clear selection Ctrl+C quit" + } else { + "↑/↓ draft lines Left/Right cursor Enter composer target Esc clear selection Ctrl+C quit" + } + } else if app + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + "↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit" + } else { + "↑/↓ select row Enter selected row Esc clear selection Left/Right cursor Ctrl+C quit" + } +} + +pub(super) fn draw_actionbar(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { + let left = actionbar_left_text(app); + let right = actionbar_right_text(app); + let left_width = area + .width + .saturating_sub(right.width() as u16) + .saturating_sub(2) as usize; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + truncate_with_ellipsis(&left, left_width), + Style::default().fg(Color::DarkGray), + ))), + area, + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + right, + Style::default().fg(Color::DarkGray), + ))) + .alignment(ratatui::layout::Alignment::Right), + area, + ); +} + +pub(super) fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if s.width() <= max_width { + return s.to_string(); + } + if max_width == 1 { + return "…".to_string(); + } + let mut out = String::new(); + let mut width = 0usize; + for c in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); + if width + cw > max_width - 1 { + break; + } + out.push(c); + width += cw; + } + out.push('…'); + out +} diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs new file mode 100644 index 00000000..dd911f36 --- /dev/null +++ b/crates/tui/src/dashboard/tests.rs @@ -0,0 +1,3176 @@ +use super::render::*; +use super::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +#[test] +fn orchestration_worktree_layout_is_stable_under_original_workspace_root() { + let root = Path::new("/tmp/Yoi Workspace"); + let layout = orchestration_worktree_layout(root); + assert_eq!( + layout.path, + PathBuf::from("/tmp/Yoi Workspace/.worktree/orchestration") + ); + assert_eq!(layout.branch, "orchestration"); +} + +#[test] +fn orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace() { + let original = PathBuf::from("/repo/yoi"); + let orchestration = original + .join(".worktree") + .join("orchestration") + .join("yoi-orchestrator"); + let context = build_orchestrator_launch_context(&original, &orchestration, "yoi-orchestrator"); + assert_eq!(context.workspace_root, orchestration); + assert_eq!( + context.original_workspace_root.as_deref(), + Some(original.as_path()) + ); + assert_eq!( + context.target_workspace_root.as_deref(), + Some(original.as_path()) + ); +} + +#[test] +fn invalid_existing_orchestration_path_is_diagnostic_not_cleanup() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(&root).unwrap(); + let layout = orchestration_worktree_layout(&root); + std::fs::create_dir_all(&layout.path).unwrap(); + std::fs::write(layout.path.join("keep.txt"), "do not delete").unwrap(); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("not a Git worktree")); + assert!(layout.path.join("keep.txt").exists()); +} + +#[test] +fn ensure_orchestration_worktree_creates_and_reuses_git_worktree() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(&root).unwrap(); + run_test_git(&root, &["init"]).unwrap(); + std::fs::write(root.join("README.md"), "repo").unwrap(); + run_test_git(&root, &["add", "README.md"]).unwrap(); + run_test_git( + &root, + &[ + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=Yoi Test", + "commit", + "-m", + "init", + ], + ) + .unwrap(); + + let created = ensure_orchestration_worktree(&root).unwrap(); + assert_eq!(created.status, OrchestrationWorktreeStatus::Created); + assert!(created.layout.path.exists()); + assert!(git_inside_worktree(&created.layout.path)); + + let reused = ensure_orchestration_worktree(&root).unwrap(); + assert_eq!(reused.status, OrchestrationWorktreeStatus::Reused); + assert_eq!(reused.layout, created.layout); + + std::fs::write(created.layout.path.join("dirty.txt"), "dirty").unwrap(); + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("dirty")); + assert!(created.layout.path.join("dirty.txt").exists()); +} + +#[test] +fn ensure_and_restore_use_configured_orchestration_layout() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/custom-panel" +worktree_dir = "custom-worktrees" +worktree_name = "panel" +"#, + ); + run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); + + let resolved = resolved_orchestration_worktree_layout(&root).unwrap(); + assert_eq!(resolved.branch, "orchestration/custom-panel"); + assert!(resolved.path.ends_with("custom-worktrees/panel")); + + let created = ensure_orchestration_worktree(&root).unwrap(); + assert_eq!(created.status, OrchestrationWorktreeStatus::Created); + assert_eq!(created.layout, resolved); + let branch = run_test_git_output(&created.layout.path, &["branch", "--show-current"]).unwrap(); + assert_eq!(branch.trim(), "orchestration/custom-panel"); + + let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); + assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); + assert_eq!(restored.layout, created.layout); +} + +#[test] +fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(&root).unwrap(); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/bad:branch" +"#, + ); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("failed to load ticket config")); + assert!(err.contains("git branch name")); + assert!(!root.join(".worktree").exists()); +} + +#[test] +fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/custom-panel" +"#, + ); + run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); + let layout = resolved_orchestration_worktree_layout(&root).unwrap(); + run_test_git( + &root, + &[ + "worktree", + "add", + &layout.path.display().to_string(), + "-b", + "orchestration/other-panel", + "HEAD", + ], + ) + .unwrap(); + + let err = prepare_orchestration_worktree_for_restore(&root).unwrap_err(); + assert!(err.contains("expected orchestration/custom-panel")); + let branch = run_test_git_output(&layout.path, &["branch", "--show-current"]).unwrap(); + assert_eq!(branch.trim(), "orchestration/other-panel"); +} + +#[test] +fn restore_uses_existing_orchestration_worktree_even_when_dirty() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + let created = ensure_orchestration_worktree(&root).unwrap(); + std::fs::write(created.layout.path.join("orchestrator-notes.txt"), "dirty").unwrap(); + + let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); + + assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); + assert_eq!(restored.layout.path, created.layout.path); + assert_ne!(restored.layout.path, root); + assert!(restored.layout.path.ends_with(".worktree/orchestration")); +} + +#[test] +fn existing_wrong_branch_worktree_is_rejected_without_cleanup() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + let layout = orchestration_worktree_layout(&root); + run_test_git( + &root, + &[ + "worktree", + "add", + &layout.path.display().to_string(), + "-b", + "wrong-branch", + "HEAD", + ], + ) + .unwrap(); + std::fs::write(layout.path.join("keep.txt"), "keep").unwrap(); + run_test_git(&layout.path, &["add", "keep.txt"]).unwrap(); + run_test_git( + &layout.path, + &[ + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=Yoi Test", + "commit", + "-m", + "keep", + ], + ) + .unwrap(); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("expected orchestration")); + assert!(layout.path.join("keep.txt").exists()); +} + +#[test] +fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + let layout = orchestration_worktree_layout(&root); + std::fs::create_dir_all(&layout.path).unwrap(); + init_test_repo(&layout.path); + run_test_git(&layout.path, &["checkout", "-b", &layout.branch]).unwrap(); + std::fs::write(layout.path.join("unrelated.txt"), "keep").unwrap(); + run_test_git(&layout.path, &["add", "unrelated.txt"]).unwrap(); + run_test_git( + &layout.path, + &[ + "-c", + "user.email=test@example.invalid", + "-c", + "user.name=Yoi Test", + "commit", + "-m", + "unrelated", + ], + ) + .unwrap(); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("different Git repository")); + assert!(layout.path.join("unrelated.txt").exists()); +} + +fn write_test_ticket_config(root: &Path, content: &str) { + let config_dir = root.join(".yoi"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap(); +} + +fn init_test_repo(root: &Path) { + std::fs::create_dir_all(root).unwrap(); + run_test_git(root, &["init"]).unwrap(); + run_test_git(root, &["config", "user.email", "test@example.invalid"]).unwrap(); + run_test_git(root, &["config", "user.name", "Yoi Test"]).unwrap(); + std::fs::write(root.join("README.md"), "repo").unwrap(); + run_test_git(root, &["add", "README.md"]).unwrap(); + run_test_git(root, &["commit", "-m", "init"]).unwrap(); +} + +#[test] +fn inherited_parent_worktree_directory_is_rejected_without_cleanup() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + let layout = orchestration_worktree_layout(&root); + run_test_git(&root, &["checkout", "-b", &layout.branch]).unwrap(); + std::fs::create_dir_all(&layout.path).unwrap(); + std::fs::write(layout.path.join("plain.txt"), "keep").unwrap(); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("not the worktree root")); + assert!(layout.path.join("plain.txt").exists()); +} + +fn run_test_git(root: &Path, args: &[&str]) -> Result<(), String> { + let mut command = Command::new("git"); + command.arg("-C").arg(root).args(args); + run_git_command(command, "run test git") +} + +fn run_test_git_output(root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .map_err(|error| format!("could not run test git: {error}"))?; + if !output.status.success() { + return Err(format!( + "git failed to run test git: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; +use std::fs; +use tempfile::TempDir; +use ticket::{ + LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, TicketEventKind, + TicketWorkflowState, +}; + +fn ticket_workspace( + title: &str, + state: TicketWorkflowState, + configure: impl FnOnce(&mut NewTicket), +) -> (TempDir, String, LocalTicketBackend) { + let temp = TempDir::new().unwrap(); + fs::create_dir_all(temp.path().join(".yoi")).unwrap(); + fs::write( + temp.path().join(".gitignore"), + ".worktree/\n.yoi/tickets/.ticket-backend.lock\n", + ) + .unwrap(); + fs::write( + temp.path().join(".yoi/.gitignore"), + "tickets/.ticket-backend.lock\n", + ) + .unwrap(); + fs::write( + temp.path().join(".yoi/ticket.config.toml"), + "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + ) + .unwrap(); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let mut input = NewTicket::new(title); + input.body = MarkdownText::from("Ready for panel action"); + input.workflow_state = Some(state); + configure(&mut input); + let ticket = backend.create(input).unwrap(); + (temp, ticket.id, backend) +} + +fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { + ticket_workspace(title, TicketWorkflowState::Ready, |_| {}) +} + +fn ready_ticket_git_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { + let (temp, ticket_id, backend) = ready_ticket_workspace(title); + run_test_git(temp.path(), &["init"]).unwrap(); + run_test_git( + temp.path(), + &["config", "user.email", "test@example.invalid"], + ) + .unwrap(); + run_test_git(temp.path(), &["config", "user.name", "Yoi Test"]).unwrap(); + run_test_git(temp.path(), &["add", "."]).unwrap(); + run_test_git(temp.path(), &["commit", "-m", "seed tickets"]).unwrap(); + ensure_orchestration_worktree(temp.path()).unwrap(); + (temp, ticket_id, backend) +} + +fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { + ticket_workspace(title, TicketWorkflowState::Done, |_| {}) +} + +fn request_for(temp: &TempDir, ticket_id: String, action: NextUserAction) -> TicketActionRequest { + TicketActionRequest { + workspace_root: temp.path().to_path_buf(), + ticket_id, + action, + orchestrator: None, + } +} + +fn planning_return_request( + temp: &TempDir, + ticket_id: String, + instruction: &str, +) -> ReadyTicketPlanningReturnRequest { + ReadyTicketPlanningReturnRequest { + workspace_root: temp.path().to_path_buf(), + ticket_id, + user_instruction: instruction.to_string(), + followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { + pod_name: "stale-intake".to_string(), + }, + } +} + +#[tokio::test] +async fn ready_ticket_planning_return_records_instruction_and_returns_to_planning_without_queueing() +{ + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-ready"); + + let outcome = dispatch_ready_ticket_planning_return(planning_return_request( + &temp, + ticket_id.clone(), + "please add acceptance detail before queueing", + )) + .await + .unwrap(); + + assert!(outcome.notice.contains("returned to planning")); + assert!(outcome.notice.contains("instruction was recorded")); + assert!(matches!( + outcome.followup, + ReadyTicketPlanningReturnAfterMutation::None + )); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); + assert!(ticket.meta.queued_by.is_none()); + assert!(ticket.meta.queued_at.is_none()); + let state_change = ticket + .events + .iter() + .find(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("state") + && event.from.as_deref() == Some("ready") + && event.to.as_deref() == Some("planning") + }) + .expect("ready -> planning state_changed event is recorded"); + assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); + assert!( + state_change + .body + .as_str() + .contains("please add acceptance detail") + ); + assert!(state_change.body.as_str().contains("not Queue routing")); + assert!( + state_change + .body + .as_str() + .contains("must not start implementation") + ); +} + +#[tokio::test] +async fn ready_ticket_planning_return_rejects_stale_non_ready_ticket() { + let (temp, ticket_id, backend) = + ticket_workspace("panel-refine-stale", TicketWorkflowState::Planning, |_| {}); + + let error = dispatch_ready_ticket_planning_return(planning_return_request( + &temp, + ticket_id.clone(), + "refine please", + )) + .await + .unwrap_err(); + + assert!(error.to_string().contains("expected ready")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); + assert!( + ticket + .events + .iter() + .all(|event| !(event.kind == TicketEventKind::StateChanged + && event.from.as_deref() == Some("ready") + && event.to.as_deref() == Some("planning"))) + ); +} + +#[test] +fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launch() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); + panel.rows.push(panel_test_ticket_row( + "20260608-000123-ready", + "Ready Ticket", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let mut app = app_with_panel(empty_test_list(), panel); + app.cycle_composer_target(); + app.input.insert_str("clarify expected behavior"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + DashboardAction::ReturnReadyTicketToPlanning(request) => request, + _ => panic!("ready Ticket row with Ticket Intake text should return to planning"), + }; + + assert_eq!(request.ticket_id, "20260608-000123-ready"); + assert_eq!(request.user_instruction, "clarify expected behavior"); + assert!(matches!( + request.followup, + ReadyTicketPlanningReturnFollowup::LaunchIntake(_) + )); + assert!(app.sending); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Returning ready Ticket") + ); + assert_eq!(input_text(&app), "clarify expected behavior"); +} + +#[tokio::test] +async fn planning_return_with_launch_followup_changes_state_before_launch_followup() { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-launch"); + let request = ReadyTicketPlanningReturnRequest { + workspace_root: temp.path().to_path_buf(), + ticket_id: ticket_id.clone(), + user_instruction: "launch intake after state change".to_string(), + followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { + context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake), + runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), + peer_registration: IntakePeerRegistrationRequest::Skip { + reason: "test".to_string(), + }, + registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: temp.path().join(".yoi/local-role-sessions"), + ticket_id: ticket_id.clone(), + ticket_slug: None, + }, + }), + }; + + let outcome = dispatch_ready_ticket_planning_return(request) + .await + .unwrap(); + + assert!(matches!( + outcome.followup, + ReadyTicketPlanningReturnAfterMutation::LaunchIntake(_) + )); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); +} + +#[tokio::test] +async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() { + let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue"); + let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap(); + + let outcome = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) + .await + .unwrap(); + + let root_head_after = git_rev_parse(temp.path(), "HEAD").unwrap(); + let layout = orchestration_worktree_layout(temp.path()); + let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap(); + assert_ne!(root_head_after, root_head_before); + assert_eq!(orchestration_head, root_head_after); + assert!(outcome.notice.contains("Queued Ticket")); + assert!(outcome.notice.contains(&root_head_after)); + assert!(outcome.notice.contains("root Queue commit")); + assert!(outcome.notice.contains("ff-only synced")); + assert!( + outcome + .notice + .contains("Orchestrator routing is authorized") + ); + assert!(outcome.notice.contains("queued -> inprogress acceptance")); + assert!(!outcome.notice.contains("No implementation was started")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); + assert!(ticket.meta.queued_at.is_some()); + let state_change = ticket + .events + .iter() + .find(|event| { + event.kind == TicketEventKind::StateChanged + && event.state_field.as_deref() == Some("state") + && event.from.as_deref() == Some("ready") + && event.to.as_deref() == Some("queued") + }) + .expect("queue state_changed event is recorded"); + assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); + let orchestration_backend = LocalTicketBackend::new(layout.path.join(".yoi/tickets")); + let orchestration_ticket = orchestration_backend + .show(TicketIdOrSlug::Id(ticket_id)) + .unwrap(); + assert_eq!( + orchestration_ticket.meta.workflow_state, + TicketWorkflowState::Queued + ); +} + +#[tokio::test] +async fn ticket_queue_action_allows_unrelated_dirty_root_changes() { + let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-root"); + fs::write(temp.path().join("README.md"), "dirty root notes\n").unwrap(); + fs::write(temp.path().join("dirty.txt"), "dirty").unwrap(); + + let outcome = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) + .await + .unwrap(); + + assert!(outcome.notice.contains("Queued Ticket")); + assert!(temp.path().join("dirty.txt").is_file()); + let root_status = git_status_porcelain(temp.path()).unwrap(); + assert!(root_status.iter().any(|line| line.contains("README.md"))); + assert!(root_status.iter().any(|line| line.contains("dirty.txt"))); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); +} + +#[tokio::test] +async fn ticket_queue_action_blocks_preexisting_target_ticket_changes() { + let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-ticket"); + fs::write( + backend.root().join(&ticket_id).join("thread.md"), + "local uncommitted ticket edit\n", + ) + .unwrap(); + + let error = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) + .await + .unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("root-ticket-clean")); + assert!(message.contains(&ticket_id)); + assert!(message.contains("pre-existing changes")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); + assert!(ticket.meta.queued_by.is_none()); +} + +#[tokio::test] +async fn ticket_queue_action_merges_orchestration_branch_before_queue() { + let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-diverged"); + let layout = orchestration_worktree_layout(temp.path()); + fs::write(layout.path.join("orchestrator-only.txt"), "diverged").unwrap(); + run_test_git(&layout.path, &["add", "orchestrator-only.txt"]).unwrap(); + run_test_git(&layout.path, &["commit", "-m", "orchestrator-only"]).unwrap(); + let orchestration_commit = git_rev_parse(&layout.path, "HEAD").unwrap(); + + let outcome = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) + .await + .unwrap(); + + let root_head = git_rev_parse(temp.path(), "HEAD").unwrap(); + let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap(); + assert_eq!(root_head, orchestration_head); + assert!(temp.path().join("orchestrator-only.txt").is_file()); + assert!(outcome.notice.contains("merged orchestration branch")); + assert!(outcome.notice.contains(&orchestration_commit)); + assert!(outcome.notice.contains("root Queue commit")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); +} + +#[tokio::test] +async fn ticket_queue_action_blocks_conflicting_orchestration_merge_without_mutation() { + let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-conflict"); + let layout = orchestration_worktree_layout(temp.path()); + fs::write(temp.path().join("README.md"), "root change\n").unwrap(); + run_test_git(temp.path(), &["add", "README.md"]).unwrap(); + run_test_git(temp.path(), &["commit", "-m", "root-change"]).unwrap(); + fs::write(layout.path.join("README.md"), "orchestration change\n").unwrap(); + run_test_git(&layout.path, &["add", "README.md"]).unwrap(); + run_test_git(&layout.path, &["commit", "-m", "orchestration-change"]).unwrap(); + let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap(); + + let error = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) + .await + .unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("root-orchestration-merge")); + assert!(message.contains("merge was aborted")); + assert!(message.contains(&ticket_id)); + assert_eq!( + git_rev_parse(temp.path(), "HEAD").unwrap(), + root_head_before + ); + assert!(git_status_porcelain(temp.path()).unwrap().is_empty()); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); + assert!(ticket.meta.queued_by.is_none()); +} + +#[tokio::test] +async fn ticket_close_action_blocks_non_done_ticket_without_mutation() { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-not-done"); + + let error = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("state is ready")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); + assert!(ticket.resolution.is_none()); +} + +#[tokio::test] +async fn ticket_action_rejects_stale_absent_config_without_mutation() { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config"); + fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap(); + + let error = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("Ticket config is absent")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); + assert!(ticket.meta.queued_by.is_none()); + assert!(!ticket.events.iter().any(|event| { + event.kind == TicketEventKind::StateChanged && event.state_field.as_deref() == Some("state") + })); +} + +#[tokio::test] +async fn ticket_close_action_closes_done_ticket_with_deterministic_resolution() { + let (temp, ticket_id, backend) = done_ticket_workspace("panel-close"); + + let outcome = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) + .await + .unwrap(); + + assert!(outcome.notice.contains("Closed Ticket")); + assert!(outcome.notice.contains("state was already done")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); + let resolution = ticket + .resolution + .as_ref() + .expect("Dashboard Close records resolution.md") + .as_str(); + assert!(resolution.contains("state: done")); + assert!(resolution.contains("No implementation work")); + assert!(resolution.contains("state change")); + assert!(resolution.contains("worker invocation")); + assert!(ticket.events.iter().any(|event| { + event.kind == TicketEventKind::Close && event.body.as_str().contains("workspace Dashboard") + })); +} + +#[tokio::test] +async fn ticket_close_action_blocks_existing_resolution_without_moving_ticket() { + let (temp, ticket_id, backend) = done_ticket_workspace("panel-close-resolution"); + fs::write( + temp.path() + .join(".yoi/tickets") + .join(&ticket_id) + .join("resolution.md"), + "Already resolved\n", + ) + .unwrap(); + + let error = + dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("resolution.md already exists")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert_eq!( + ticket.resolution.as_ref().unwrap().as_str(), + "Already resolved\n" + ); +} + +#[tokio::test] +async fn ticket_review_action_does_not_silently_approve() { + let (temp, ticket_id, backend) = ready_ticket_workspace("panel-review"); + backend + .add_event( + TicketIdOrSlug::Id(ticket_id.clone()), + NewTicketEvent::new(TicketEventKind::ImplementationReport, "implemented"), + ) + .unwrap(); + + let error = dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Wait)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("current action is Queue")); + let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); + assert!( + !ticket + .events + .iter() + .any(|event| event.kind == TicketEventKind::Review) + ); +} + +#[test] +fn ticket_queue_notification_message_carries_routing_contract() { + let row = panel_test_ticket_row( + "00001KTTW04W2", + "Route queued\nTicket", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "queued", + ); + let ticket = row.ticket.as_ref().unwrap(); + + let message = orchestrator_queue_notification_message(ticket); + + assert!(message.contains("Ticket `00001KTTW04W2`, title `Route queued Ticket`")); + assert!(message.contains("human authorized Orchestrator routing")); + assert!(message.contains("not an unattended scheduler")); + assert!(message.contains("Read the Ticket")); + assert!(message.contains("inspect current Orchestrator workspace state")); + assert!(message.contains("transition state queued -> inprogress")); + assert!(message.contains("before any worktree/SpawnPod implementation side effects")); + assert!(message.contains("After inprogress acceptance")); + assert!(message.contains("worktree-workflow")); + assert!(message.contains("`.worktree/`")); + assert!(message.contains("tracked `.yoi` project records visible")); + assert!( + message.contains( + "`.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded" + ) + ); + assert!(message.contains("multi-agent-workflow")); + assert!(message.contains("sibling coder/reviewer Pods")); + assert!(message.contains("coder narrow child-worktree write scope")); + assert!(message.contains("reviewer read-only by default")); + assert!(message.contains( + "integrate the implementation branch into the orchestration branch automatically" + )); + assert!(message.contains("validate in the Orchestrator worktree")); + assert!(message.contains("clean up only child implementation worktrees/branches")); + assert!(message.contains("Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace")); + assert!(message.contains("If blocked, record a concise reason")); + assert!(message.contains("leave the Ticket queued or return it to planning")); + assert!(!message.contains("Do not start implementation directly")); +} + +#[tokio::test] +async fn ticket_queue_notification_sends_notify_when_socket_available() { + let temp = TempDir::new().unwrap(); + let socket_path = temp.path().join("orchestrator.sock"); + let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, writer) = stream.into_split(); + let mut reader = JsonLineReader::new(reader); + let mut writer = JsonLineWriter::new(writer); + writer + .write(&Event::Snapshot { + entries: Vec::new(), + greeting: protocol::Greeting { + pod_name: "test-orchestrator".to_string(), + cwd: temp.path().display().to_string(), + provider: "test".to_string(), + model: "test".to_string(), + scope_summary: "test".to_string(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + }) + .await + .unwrap(); + reader.next::().await.unwrap().unwrap() + }); + + send_notify_only(&socket_path, "Dashboard Queue".to_string(), true) + .await + .unwrap(); + let method = server.await.unwrap(); + assert!(matches!( + method, + Method::Notify { message, auto_run: true } if message == "Dashboard Queue" + )); +} + +#[tokio::test] +async fn send_notify_only_can_deliver_weak_notification_without_auto_run() { + let temp = TempDir::new().unwrap(); + let socket_path = temp.path().join("companion.sock"); + let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, writer) = stream.into_split(); + let mut reader = JsonLineReader::new(reader); + let mut writer = JsonLineWriter::new(writer); + writer + .write(&Event::Snapshot { + entries: Vec::new(), + greeting: protocol::Greeting { + pod_name: "yoi".to_string(), + cwd: temp.path().display().to_string(), + provider: "test".to_string(), + model: "test".to_string(), + scope_summary: "test".to_string(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + }) + .await + .unwrap(); + reader.next::().await.unwrap().unwrap() + }); + + send_notify_only(&socket_path, "Dashboard progress".to_string(), false) + .await + .unwrap(); + let method = server.await.unwrap(); + assert!(matches!( + method, + Method::Notify { message, auto_run: false } if message == "Dashboard progress" + )); +} + +#[test] +fn no_ticket_selection_keeps_enter_pod_centric() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::Open + )); + assert!(app.prepare_ticket_action_dispatch().is_none()); + assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected.")); +} + +#[test] +fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() { + let temp = TempDir::new().unwrap(); + fs::create_dir_all(temp.path().join(".yoi")).unwrap(); + fs::write( + temp.path().join(".yoi/ticket.config.toml"), + "[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", + ) + .unwrap(); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let mut ticket = NewTicket::new("Ready Ticket"); + ticket.workflow_state = Some(TicketWorkflowState::Ready); + backend.create(ticket).unwrap(); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("idle", PodStatus::Idle)], + None, + 10, + ); + let panel = build_workspace_panel(temp.path(), &list); + let mut app = app_with_panel(list, panel); + + assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket"); + assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); + let lines = list_lines(&app, 100, 6) + .into_iter() + .map(|line| plain_line(&line)) + .collect::>(); + let ticket_line = lines + .iter() + .position(|line| line.contains("Ready Ticket")) + .unwrap(); + let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); + assert!(ticket_line < pod_line); + + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "idle"); + assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); + let open = app.prepare_open().unwrap(); + assert_eq!(open.pod_name, "idle"); + assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock"))); + + app.input.insert_str("draft after ticket row"); + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::None + )); + assert!(!app.sending); + assert_eq!(input_text(&app), "draft after ticket row"); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Workspace Companion is unavailable") + ); +} + +#[test] +fn row_hit_testing_maps_only_visible_selectable_rows() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_ticket_row( + "TICKET-1", + "Ready", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + panel.rows.push(panel_test_ticket_row( + "TICKET-2", + "Queued", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("alpha", PodStatus::Idle)], + None, + 10, + ); + let app = app_with_panel(list, panel); + + let rows = list_rows(&app, 80, 8); + let boxes = row_hit_boxes(&rows, Rect::new(3, 5, 80, 8)); + + assert_eq!(boxes.len(), 3); + assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into())); + assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 2)); + assert_eq!(boxes[1].key, PanelRowKey::Ticket("TICKET-2".into())); + assert_eq!(boxes[1].rect, Rect::new(3, 8, 80, 2)); + assert_eq!(boxes[2].key, PanelRowKey::Pod("alpha".into())); + assert_eq!(boxes[2].rect, Rect::new(3, 11, 80, 1)); + assert!(boxes.iter().all(|hit| !hit.contains(2, hit.rect.y))); +} + +#[test] +fn mouse_click_selects_panel_row_for_blank_enter_action() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_ticket_row( + "TICKET-1", + "Ready", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + panel.rows.push(panel_test_ticket_row( + "TICKET-2", + "Queued", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )); + let mut app = app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), + panel, + ); + let rows = list_rows(&app, 80, 6); + app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6)); + + assert!(app.handle_mouse_event(left_click(2, 3))); + assert_eq!( + app.selected_row, + Some(PanelRowKey::Ticket("TICKET-2".into())) + ); + app.selected_row = None; + assert!(app.handle_mouse_event(left_click(2, 4))); + assert_eq!( + app.selected_row, + Some(PanelRowKey::Ticket("TICKET-2".into())) + ); + assert_eq!(app.selected_panel_row().unwrap().title, "Queued"); + assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait)); + assert!(plain_line(&target_status_line(&app)).contains("blank Enter Wait")); + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2" + )); +} + +#[test] +fn mouse_non_row_click_is_noop_and_preserves_composer_draft() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_ticket_row( + "TICKET-1", + "Ready", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let mut app = app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), + panel, + ); + let rows = list_rows(&app, 80, 6); + app.set_row_hit_boxes(&rows, Rect::new(10, 4, 80, 6)); + app.input.insert_paste("draft".into()); + let selected = app.selected_row.clone(); + + assert!(!app.handle_mouse_event(left_click(9, 5))); + assert_eq!(app.selected_row, selected); + assert_eq!(input_text(&app), "draft"); +} + +#[test] +fn mouse_click_does_not_override_existing_composer_keyboard_behavior() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_ticket_row( + "TICKET-1", + "Ready", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + panel.rows.push(panel_test_ticket_row( + "TICKET-2", + "Queued", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )); + let mut app = app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), + panel, + ); + let rows = list_rows(&app, 80, 6); + app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6)); + + assert!(app.handle_mouse_event(left_click(2, 4))); + assert_eq!( + app.selected_row, + Some(PanelRowKey::Ticket("TICKET-2".into())) + ); + app.input.insert_paste("hello".into()); + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::None + )); + assert_eq!(input_text(&app), "hello"); + assert!(matches!( + app.handle_key(key(KeyCode::Esc)), + DashboardAction::None + )); + assert_eq!(app.selected_row, None); + assert_eq!(input_text(&app), "hello"); + assert!(matches!( + app.handle_key(key(KeyCode::Down)), + DashboardAction::None + )); + assert_eq!(app.selected_row, None); +} + +#[test] +fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.companion = Some(CompanionPanelState::new( + "yoi", + CompanionPanelStatus::Live, + None, + )); + panel.rows.push(panel_test_ticket_row( + "00001KTWPE3KQ", + "Queue Me", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("yoi", PodStatus::Idle)], + None, + 10, + ); + let mut app = app_with_panel(list, panel); + app.input.insert_str("draft to companion"); + + assert_eq!( + app.selected_ticket_action(), + Some(NextUserAction::Queue), + "selected row remains a Ticket action row" + ); + let actionbar_left = actionbar_left_text(&app); + let actionbar_right = actionbar_right_text(&app); + let target_status = plain_line(&target_status_line(&app)); + + assert!(actionbar_left.contains("Companion target: Enter sends composer text")); + assert!(actionbar_right.contains("Enter composer target")); + assert!(!actionbar_left.contains("Queue")); + assert!(!actionbar_right.contains("selected row")); + assert!(target_status.contains("composer target Companion")); + assert!(target_status.contains("draft Enter send composer text to workspace Companion")); + assert!(target_status.contains("row selection waits until composer is blank")); + assert!(!target_status.contains("blank Enter Queue")); +} + +#[test] +fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank() { + let mut app = test_app(vec![ + live_info("alpha", PodStatus::Idle), + live_info("beta", PodStatus::Idle), + ]); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + + for c in ['j', 'k', 'o', 'r'] { + assert!(matches!( + app.handle_key(key(KeyCode::Char(c))), + DashboardAction::None + )); + } + + assert_eq!(input_text(&app), "jkor"); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + + assert!(matches!( + app.handle_key(key(KeyCode::Down)), + DashboardAction::None + )); + assert_eq!(input_text(&app), "jkor"); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + + app.input.clear(); + assert!(matches!( + app.handle_key(key(KeyCode::Down)), + DashboardAction::None + )); + assert_eq!(input_text(&app), ""); + assert_eq!(app.list.selected_entry().unwrap().name, "beta"); + + assert!(matches!( + app.handle_key(key(KeyCode::Up)), + DashboardAction::None + )); + assert_eq!(input_text(&app), ""); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); +} + +#[test] +fn dashboard_selection_changes_preserve_composer_contents() { + let mut app = test_app(vec![ + live_info("alpha", PodStatus::Idle), + live_info("beta", PodStatus::Idle), + ]); + app.input.insert_str("draft message"); + let before = input_text(&app); + + app.select_next(); + + assert_eq!(input_text(&app), before); + assert_eq!(app.list.selected_entry().unwrap().name, "beta"); +} + +#[test] +fn dashboard_poll_reload_preserves_selection_composer_and_notice() { + let mut app = test_app(vec![ + live_info_with_updated_at("alpha", PodStatus::Idle, 10), + live_info_with_updated_at("beta", PodStatus::Idle, 20), + ]); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + app.input.insert_str("draft survives polling"); + app.notice = Some("keep this notice".to_string()); + let refreshed = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![ + live_info_with_updated_at("gamma", PodStatus::Idle, 60), + live_info_with_updated_at("alpha", PodStatus::Running, 50), + live_info_with_updated_at("beta", PodStatus::Idle, 40), + ], + None, + 10, + ); + + app.apply_reloaded_list(refreshed); + + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert_eq!( + app.list + .selected_entry() + .unwrap() + .live + .as_ref() + .unwrap() + .status, + Some(PodStatus::Running) + ); + assert_eq!(input_text(&app), "draft survives polling"); + assert_eq!(app.notice.as_deref(), Some("keep this notice")); +} + +#[test] +fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() { + let mut app = test_app(vec![ + live_info_with_updated_at("alpha", PodStatus::Idle, 10), + live_info_with_updated_at("beta", PodStatus::Running, 20), + ]); + assert_eq!(app.list.selected_entry().unwrap().name, "beta"); + let refreshed = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![stopped_info_with_updated_at("closed", 30)], + vec![live_info_with_updated_at("alpha", PodStatus::Idle, 40)], + None, + 10, + ); + + app.apply_reloaded_list(refreshed); + + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert_eq!(visible_entry_indices(&app.list), vec![0, 1]); +} + +#[test] +fn dashboard_poll_reload_error_keeps_previous_list_and_composer() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("keep draft"); + + app.apply_reload_result(Err(DashboardError::Io(io::Error::other("boom")))); + + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert_eq!(input_text(&app), "keep draft"); + let notice = app.notice.as_deref().unwrap(); + assert!(notice.contains("Refresh failed")); + assert!(notice.contains("boom")); +} + +#[test] +fn dashboard_orchestrator_failure_persists_over_plain_observe_missing() { + let detail = "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), + ); + + app.apply_reloaded_snapshot(DashboardSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); + assert_eq!(orchestrator.detail.as_deref(), Some(detail)); + assert_eq!( + app.panel + .header + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.as_str() == detail) + .count(), + 1 + ); +} + +#[test] +fn dashboard_orchestrator_plain_missing_remains_when_no_prior_failure_exists() { + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + ); + + app.apply_reloaded_snapshot(DashboardSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); + assert!(orchestrator.detail.is_none()); + assert!(app.panel.header.diagnostics.is_empty()); +} + +#[test] +fn dashboard_orchestrator_failure_clears_after_live_lifecycle() { + let detail = "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), + ); + + app.apply_reloaded_snapshot(DashboardSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Live, None), + }); + assert_eq!( + app.panel.header.orchestrator.as_ref().unwrap().status, + OrchestratorPanelStatus::Live + ); + + app.apply_reloaded_snapshot(DashboardSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); + assert!(orchestrator.detail.is_none()); +} + +#[test] +fn dashboard_orchestrator_failure_supersedes_prior_failure() { + let old_detail = "could not spawn workspace Orchestrator: old scope conflict"; + let new_detail = "could not restore workspace Orchestrator: socket refused"; + let mut app = app_with_panel( + empty_test_list(), + panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(old_detail)), + ); + + app.apply_reloaded_snapshot(DashboardSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(new_detail)), + }); + app.apply_reloaded_snapshot(DashboardSnapshot { + list: empty_test_list(), + panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), + }); + + let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); + assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); + assert_eq!(orchestrator.detail.as_deref(), Some(new_detail)); + assert!( + !app.panel + .header + .diagnostics + .iter() + .any(|diagnostic| diagnostic == old_detail) + ); + assert!( + app.panel + .header + .diagnostics + .iter() + .any(|diagnostic| diagnostic == new_detail) + ); +} + +#[tokio::test] +async fn dashboard_poll_reload_does_not_overlap_in_flight_reload() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + let mut pending = PendingReload::default(); + + assert!(pending.start_with_handle(tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(10)).await; + Err(DashboardError::Io(io::Error::other("boom"))) + }))); + assert!(!pending.start_with_handle(tokio::spawn(async { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("beta", PodStatus::Idle)], + None, + 10, + ); + Ok(DashboardSnapshot { + panel: WorkspacePanelViewModel::empty(Path::new("test")), + list, + }) + }))); + assert!(pending.finish_if_ready().await.is_none()); + + tokio::time::sleep(Duration::from_millis(20)).await; + let result = pending.finish_if_ready().await.unwrap(); + app.apply_reload_result(result); + + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert!(app.notice.as_deref().unwrap().contains("Refresh failed")); +} + +#[tokio::test] +async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() { + struct DropFlag(Arc); + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let reload_cancelled = Arc::new(AtomicBool::new(false)); + let notice_cancelled = Arc::new(AtomicBool::new(false)); + let mut pending_reload = PendingReload::default(); + let mut pending_notice = PendingQueueAttentionNotice::default(); + let (reload_started_tx, reload_started_rx) = tokio::sync::oneshot::channel(); + let (notice_started_tx, notice_started_rx) = tokio::sync::oneshot::channel(); + + let reload_flag = Arc::clone(&reload_cancelled); + assert!(pending_reload.start_with_handle(tokio::spawn(async move { + let _drop_flag = DropFlag(reload_flag); + let _ = reload_started_tx.send(()); + std::future::pending::<()>().await; + Err(DashboardError::Io(io::Error::other( + "unreachable reload completion", + ))) + }))); + + let notice_flag = Arc::clone(¬ice_cancelled); + assert!(pending_notice.start_with_handle(tokio::spawn(async move { + let _drop_flag = DropFlag(notice_flag); + let _ = notice_started_tx.send(()); + std::future::pending::<()>().await; + OrchestratorQueueAttentionNoticeResult::failed( + "unreachable".to_string(), + "unreachable notice completion", + ) + }))); + reload_started_rx.await.expect("reload task should start"); + notice_started_rx.await.expect("notice task should start"); + + tokio::time::timeout(Duration::from_millis(20), async { + abort_panel_background_work_for_quit(&mut pending_reload, &mut pending_notice); + }) + .await + .expect("quit abort should not wait for background task completion"); + + tokio::time::timeout(Duration::from_millis(100), async { + while !(reload_cancelled.load(Ordering::SeqCst) && notice_cancelled.load(Ordering::SeqCst)) + { + tokio::task::yield_now().await; + } + }) + .await + .expect("quit abort should cancel reload and notice tasks"); + + assert!(pending_reload.finish_if_ready().await.is_none()); + assert!(pending_notice.finish_if_ready().await.is_none()); +} + +#[test] +fn dashboard_idle_live_selected_target_is_open_eligible() { + let app = test_app(vec![live_info("idle", PodStatus::Idle)]); + + assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); + assert!(app.selected_open_disabled_reason().is_none()); +} + +#[test] +fn dashboard_status_label_for_live_without_reported_status_is_softened() { + let mut live = live_info("probing", PodStatus::Idle); + live.status = None; + let app = test_app(vec![live]); + + let (label, _) = row_status_label(app.list.selected_entry().unwrap()); + + assert_eq!(label, "live"); +} + +#[test] +fn dashboard_status_labels_preserve_explicit_live_statuses() { + for (status, expected_label) in [ + (PodStatus::Idle, "live idle"), + (PodStatus::Running, "live running"), + (PodStatus::Paused, "live paused"), + ] { + let app = test_app(vec![live_info("pod", status)]); + let (label, _) = row_status_label(app.list.selected_entry().unwrap()); + + assert_eq!(label, expected_label); + } +} + +#[test] +fn panel_ticket_rows_render_state_title_then_detail_line() { + let row = panel_test_ticket_row( + "00001KTX1QMG9", + "Workspace Dashboard composer targets", + ActionPriority::ActiveWork, + NextUserAction::Wait, + "inprogress", + ); + + let lines = panel_row_lines(&row, true, 160); + let (title, detail) = (&lines[0], &lines[1]); + let title_line = plain_line(&title); + let detail_line = plain_line(&detail); + let state_start = 2; + let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; + let row_id = row.ticket.as_ref().unwrap().id.as_str(); + + assert!(title_line.starts_with("▶ ")); + assert!(detail_line.starts_with("│ meta ")); + assert!(!title_line.contains(row_id)); + assert_eq!(display_column(&title_line, "inprogress"), state_start); + assert_eq!( + display_column(&title_line, "Workspace Dashboard composer targets"), + title_start + ); + assert!(detail_line.contains(row_id)); + assert!(detail_line.contains("Gate: clear")); + assert!(detail_line.contains("Action: Wait")); +} + +#[test] +fn panel_ticket_non_selected_rows_align_with_selected_marker_space() { + let row = panel_test_ticket_row( + "00001KTTB479X", + "Long Ticket title that should be rendered after short columns", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + ); + + let lines = panel_row_lines(&row, false, 160); + let (title, detail) = (&lines[0], &lines[1]); + let title_line = plain_line(&title); + let detail_line = plain_line(&detail); + let state_start = 2; + let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; + + assert!(title_line.starts_with(" ready")); + assert!(detail_line.starts_with(" meta 00001KTTB479X")); + assert_eq!(display_column(&title_line, "ready"), state_start); + assert_eq!( + display_column(&title_line, "Long Ticket title"), + title_start + ); +} + +#[test] +fn panel_ticket_title_truncates_after_state_column() { + let row = panel_test_ticket_row( + "00001KTTB479X", + "Very long Ticket title that should truncate only after the state column", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + ); + + let lines = panel_row_lines(&row, false, 42); + let (title, detail) = (&lines[0], &lines[1]); + let title_line = plain_line(&title); + let detail_line = plain_line(&detail); + let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1; + + assert_eq!(title_line.width(), 42); + assert_eq!(display_column(&title_line, "Very long Ticket"), title_start); + assert!(title_line.ends_with('…')); + assert_eq!(detail_line.width(), 42); + assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear")); + assert!(detail_line.ends_with('…')); +} + +#[test] +fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() { + let mut row = panel_test_ticket_row( + "00001OVERLAY", + "Overlay column regression", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + ); + row.kind = PanelRowKind::Review; + row.status = "q→done".to_string(); + row.disabled_reason = Some( + "orchestration worktree overlay shows Ticket state done; local state remains queued" + .to_string(), + ); + row.ticket.as_mut().unwrap().orchestration_overlay = + Some(crate::workspace_panel::TicketStateOverlay { + source: "orchestration".to_string(), + workflow_state: TicketWorkflowState::Done, + }); + + let lines = panel_row_lines(&row, false, 160); + let title_line = plain_line(&lines[0]); + let detail_line = plain_line(&lines[1]); + let state_start = 2; + let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; + + assert!(row.status.width() <= TICKET_STATE_COLUMN_WIDTH); + assert_eq!(display_column(&title_line, "q→done"), state_start); + assert_eq!( + display_column(&title_line, "Overlay column regression"), + title_start + ); + assert!(!title_line.contains("orchestration")); + assert!(detail_line.contains("Overlay: local queued · orchestration done · merge pending")); +} + +#[test] +fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() { + let mut row = panel_test_ticket_row( + "00001WAITING", + "Ready but gated", + ActionPriority::Background, + NextUserAction::Wait, + "ready", + ); + row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string()); + row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string()); + + let lines = panel_row_lines(&row, true, 160); + let detail = &lines[1]; + let detail_line = plain_line(&detail); + + assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on")); + assert!(detail_line.contains("Action: queue disabled")); + assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1")); +} + +#[test] +fn panel_ticket_intake_child_rows_render_as_indented_single_line() { + let row = panel_test_intake_child_row( + "00001TICKET", + "intake-live", + TicketLocalClaimStatus::Live, + Some(NextUserAction::OpenPod), + ); + + let lines = panel_row_lines(&row, false, 160); + assert_eq!(lines.len(), 1); + let line = plain_line(&lines[0]); + let status_start = 4; + let title_start = status_start + TICKET_STATE_COLUMN_WIDTH + 1; + + assert!(line.starts_with(" └ live")); + assert_eq!(display_column(&line, "live"), status_start); + assert_eq!( + display_column(&line, "Intake Pod: intake-live"), + title_start + ); + assert!(!line.starts_with(" live")); + + let selected_line = plain_line(&panel_row_lines(&row, true, 160)[0]); + assert!(selected_line.starts_with(" ▶ live")); + assert!(selected_line.contains("Intake Pod: intake-live")); +} + +#[test] +fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod() { + let ticket_id = "00001TICKET"; + let pod_name = "intake-live"; + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_intake_child_row( + ticket_id, + pod_name, + TicketLocalClaimStatus::Live, + Some(NextUserAction::OpenPod), + )); + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info(pod_name, PodStatus::Idle)], + None, + 10, + ); + let mut app = app_with_panel(list, panel); + app.select_panel_key(PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.to_string(), + pod_name: pod_name.to_string(), + }); + + let status = plain_line(&target_status_line(&app)); + + assert!(status.contains("selected Intake Pod live")); + assert!(status.contains("Ticket 00001TICKET")); + assert!(status.contains("blank Enter open/attach")); + assert!(!status.contains("selected Ticket")); + assert!(!status.contains("selected Pod live")); +} + +#[test] +fn panel_pod_rows_use_aligned_columns_before_pod_name() { + let app = test_app(vec![ + live_info("companion", PodStatus::Idle), + live_info("very-long-background-worker-name", PodStatus::Running), + ]); + let idle = app + .list + .entries + .iter() + .find(|entry| entry.name == "companion") + .unwrap(); + let running = app + .list + .entries + .iter() + .find(|entry| entry.name == "very-long-background-worker-name") + .unwrap(); + + let idle_line = plain_line(&row_line(idle, false, 120)); + let running_line = plain_line(&row_line(running, false, 120)); + let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; + + assert!(!running_line.starts_with(" very-long-background-worker-name")); + assert_eq!(display_column(&idle_line, "live idle"), 2); + assert_eq!(display_column(&running_line, "live running"), 2); + assert_eq!(display_column(&idle_line, "companion"), name_start); + assert_eq!( + display_column(&running_line, "very-long-background-worker-name"), + name_start + ); +} + +#[test] +fn panel_pod_name_truncates_after_status() { + let app = test_app(vec![live_info( + "very-long-background-worker-name-that-keeps-going", + PodStatus::Running, + )]); + let entry = app.list.selected_entry().unwrap(); + + let line = plain_line(&row_line(entry, false, 58)); + let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; + + assert_eq!(line.width(), 58); + assert_eq!(display_column(&line, "live running"), 2); + assert_eq!(display_column(&line, "very-long"), name_start); + assert!(line.ends_with('…')); +} + +#[test] +fn dashboard_running_paused_and_stopped_targets_are_open_eligible() { + let mut app = test_app(vec![ + live_info("running", PodStatus::Running), + live_info("paused", PodStatus::Paused), + ]); + let stopped = stopped_info("stopped"); + app.list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![stopped], + vec![ + live_info_with_updated_at("running", PodStatus::Running, 30), + live_info_with_updated_at("paused", PodStatus::Paused, 20), + ], + Some("running".to_string()), + 10, + ); + app.selected_row = None; + app.ensure_selection_visible(); + + assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); + assert!(app.selected_open_disabled_reason().is_none()); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "paused"); + assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); + assert!(app.selected_open_disabled_reason().is_none()); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "stopped"); + assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); + assert!(app.selected_open_disabled_reason().is_none()); +} + +#[test] +fn dashboard_sections_classify_pending_working_and_closed() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![stopped_info_with_updated_at("closed", 60)], + vec![ + live_info_with_updated_at("idle", PodStatus::Idle, 50), + live_info_with_updated_at("running", PodStatus::Running, 40), + live_info_with_updated_at("paused", PodStatus::Paused, 30), + ], + Some("idle".to_string()), + 10, + ); + + let sections = sectioned_entries(&list); + + assert_eq!(section_names(&list, §ions[0]), vec!["idle"]); + assert_eq!( + section_names(&list, §ions[1]), + vec!["running", "paused"] + ); + assert_eq!(section_names(&list, §ions[2]), vec!["closed"]); +} + +#[test] +fn dashboard_closed_section_is_limited_to_three_visible_rows() { + let list = closed_list(5, Some("closed-0")); + let visible = visible_entry_indices(&list) + .into_iter() + .map(|index| list.entries[index].name.clone()) + .collect::>(); + let sections = sectioned_entries(&list); + let closed = sections + .iter() + .find(|section| section.kind == DashboardSectionKind::Closed) + .unwrap(); + let app = app_with_list(list); + let lines = list_lines(&app, 80, 8) + .into_iter() + .map(|line| plain_line(&line)) + .collect::>(); + + assert_eq!(visible, vec!["closed-0", "closed-1", "closed-2"]); + assert_eq!(closed.hidden_count(), 2); + assert!( + lines + .iter() + .any(|line| line.contains("closed 5 total, +2 hidden")) + ); + assert!(lines.iter().any(|line| line.contains("closed-2"))); + assert!(!lines.iter().any(|line| line.contains("closed-3"))); +} + +#[test] +fn dashboard_selection_follows_visible_section_order_without_hidden_closed_rows() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + (0..5) + .map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index)) + .collect(), + vec![ + live_info_with_updated_at("running", PodStatus::Running, 70), + live_info_with_updated_at("idle", PodStatus::Idle, 60), + ], + Some("idle".to_string()), + 20, + ); + let mut app = app_with_list(list); + + assert_eq!(app.list.selected_entry().unwrap().name, "idle"); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "running"); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "closed-0"); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "closed-1"); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); + app.select_next(); + assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); +} + +#[test] +fn dashboard_selection_does_not_default_to_orchestrator_only_row() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("test-orchestrator", PodStatus::Idle)], + None, + 10, + ); + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + OrchestratorPanelStatus::Live, + None, + )); + let app = app_with_panel(list, panel); + + assert!(app.selected_row.is_none()); + assert!(app.list.selected_name.is_none()); + assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); +} + +#[test] +fn dashboard_selection_prefers_non_orchestrator_pod_by_default() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![ + live_info_with_updated_at("test-orchestrator", PodStatus::Idle, 80), + live_info_with_updated_at("worker", PodStatus::Idle, 70), + ], + None, + 10, + ); + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + OrchestratorPanelStatus::Live, + None, + )); + let app = app_with_panel(list, panel); + + assert_eq!(app.list.selected_entry().unwrap().name, "worker"); +} + +#[test] +fn dashboard_list_renders_workspace_diagnostics_before_rows() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel + .header + .diagnostics + .push("Ticket config is unusable".to_string()); + let app = app_with_panel( + PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("idle", PodStatus::Idle)], + None, + 10, + ), + panel, + ); + let lines = list_lines(&app, 80, 4) + .into_iter() + .map(|line| plain_line(&line)) + .collect::>(); + + assert!(lines[0].contains("Ticket config is unusable")); + assert!(lines.iter().any(|line| line.contains("idle"))); +} + +#[test] +fn dashboard_list_pins_closed_section_below_live_flexible_area() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + (0..3) + .map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index)) + .collect(), + vec![ + live_info_with_updated_at("running", PodStatus::Running, 70), + live_info_with_updated_at("idle", PodStatus::Idle, 60), + ], + Some("idle".to_string()), + 20, + ); + let app = app_with_list(list); + let lines = list_lines(&app, 80, 12) + .into_iter() + .map(|line| plain_line(&line)) + .collect::>(); + + assert!(lines[0].contains("pending")); + assert!(lines[2].contains("working")); + assert!(lines[4].is_empty()); + assert!(lines[8].contains("closed")); + assert!(lines[11].contains("closed-2")); +} + +#[test] +fn dashboard_layout_uses_single_boundary_separator_between_list_and_composer() { + let layout = dashboard_layout(Rect::new(0, 0, 80, 24), 1); + + assert_eq!(layout.boundary.height, 1); + assert!(!layout.list_draws_own_separator); + assert_eq!(layout.boundary.y, layout.list.y + layout.list.height); + assert_eq!( + layout.target_status.y, + layout.boundary.y + layout.boundary.height + ); +} + +#[test] +fn dashboard_companion_submit_routes_to_workspace_companion_not_selected_pod() { + let mut app = companion_app( + vec![ + live_info("alpha", PodStatus::Idle), + live_info("yoi", PodStatus::Idle), + ], + CompanionPanelStatus::Live, + ); + let alpha_index = app + .list + .entries + .iter() + .position(|entry| entry.name == "alpha") + .unwrap(); + app.list.select_index(alpha_index); + app.input.insert_str("send to companion"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + DashboardAction::SendCompanion(request) => request, + _ => panic!("Companion target should send to the workspace Companion"), + }; + + assert_eq!(request.pod_name, "yoi"); + assert_eq!(request.socket_path, PathBuf::from("/tmp/yoi.sock")); + assert!(app.sending); + assert_eq!(input_text(&app), "send to companion"); + assert!(app.notice.as_deref().unwrap().contains("Companion yoi")); +} + +#[test] +fn dashboard_companion_submit_unavailable_keeps_composer_contents() { + let mut app = companion_app(vec![], CompanionPanelStatus::Missing); + app.input.insert_str("keep me"); + let before = input_text(&app); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::None + )); + + assert_eq!(input_text(&app), before); + assert!(!app.sending); + assert!(app.notice.as_deref().unwrap().contains("draft kept")); +} + +#[test] +fn dashboard_companion_submit_empty_reports_empty_composer() { + let mut app = companion_app( + vec![live_info("yoi", PodStatus::Idle)], + CompanionPanelStatus::Live, + ); + + assert!(app.prepare_companion_send().is_none()); + + assert_eq!(input_text(&app), ""); + assert!(!app.sending); + assert_eq!(app.notice.as_deref(), Some("Composer is empty.")); +} + +#[test] +fn dashboard_companion_finish_success_clears_composer() { + let mut app = companion_app( + vec![live_info("yoi", PodStatus::Idle)], + CompanionPanelStatus::Live, + ); + app.input.insert_str("done"); + app.sending = true; + + app.finish_companion_send(Ok(CompanionSendOutcome { + notice: "Sent to Companion yoi.".to_string(), + })); + + assert_eq!(input_text(&app), ""); + assert!(!app.sending); + assert_eq!(app.notice.as_deref(), Some("Sent to Companion yoi.")); +} + +#[test] +fn dashboard_open_request_keeps_dashboard_state_for_nested_console() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("draft survives open"); + + let request = app.prepare_open().unwrap(); + + assert_eq!(request.pod_name, "alpha"); + assert_eq!( + request.socket_override, + Some(PathBuf::from("/tmp/alpha.sock")) + ); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert_eq!(input_text(&app), "draft survives open"); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Attaching to alpha") + ); +} + +#[test] +fn dashboard_open_failure_keeps_composer_and_sets_notice() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("keep this draft"); + let before = input_text(&app); + let error = io::Error::other("boom"); + + app.finish_open("alpha", Err(&error)); + + assert_eq!(input_text(&app), before); + assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Open failed for alpha") + ); + assert!(app.refreshing); + assert!(matches!( + app.enter_reload, + Some(OrchestratorLifecycleMode::Observe) + )); +} + +#[test] +fn dashboard_loading_app_defers_initial_snapshot_to_enter_reload() { + let app = DashboardApp::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 dashboard_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] +fn dashboard_open_disabled_target_stays_in_dashboard() { + let mut live = live_info("unreachable", PodStatus::Idle); + live.reachable = false; + live.status = None; + let mut app = test_app(vec![live]); + + assert!(app.prepare_open().is_none()); + assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); +} + +#[test] +fn dashboard_empty_enter_uses_open_action() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::Open + )); + let request = app.prepare_open().unwrap(); + + assert_eq!(request.pod_name, "alpha"); + assert_eq!( + request.socket_override, + Some(PathBuf::from("/tmp/alpha.sock")) + ); + assert_eq!(input_text(&app), ""); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Attaching to alpha") + ); +} + +#[test] +fn dashboard_whitespace_only_enter_uses_open_action() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str(" \n\t"); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::Open + )); + let request = app.prepare_open().unwrap(); + + assert_eq!(request.pod_name, "alpha"); + assert_eq!(input_text(&app), " \n\t"); +} + +#[test] +fn dashboard_non_empty_enter_reports_companion_unavailable() { + let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("keep this draft"); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::None + )); + + assert_eq!(input_text(&app), "keep this draft"); + assert!(!app.sending); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Workspace Companion is unavailable") + ); +} + +#[test] +fn dashboard_alt_enter_inserts_newline_without_companion_send() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("first line"); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), + DashboardAction::None + )); + + assert_eq!(input_text(&app), "first line\n"); + assert!(!app.sending); + assert!(app.notice.is_none()); +} + +#[test] +fn dashboard_alt_enter_on_blank_pod_selection_inserts_newline_without_opening() { + let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]); + let selected_before = app.selected_row.clone(); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), + DashboardAction::None + )); + + assert_eq!(input_text(&app), "\n"); + assert_eq!(app.selected_row, selected_before); + assert!(app.notice.is_none()); +} + +#[test] +fn dashboard_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.rows.push(panel_test_ticket_row( + "TICKET-1", + "Ready", + ActionPriority::ReadyForQueue, + NextUserAction::Queue, + "ready", + )); + let mut app = app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10), + panel, + ); + let selected_before = app.selected_row.clone(); + assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue)); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), + DashboardAction::None + )); + + assert_eq!(input_text(&app), "\n"); + assert_eq!(app.selected_row, selected_before); + assert!(!app.sending); + assert!(app.notice.is_none()); +} + +#[test] +fn dashboard_composer_shared_word_motion_and_delete_keys() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("hello world"); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Left, KeyModifiers::CONTROL)), + DashboardAction::None + )); + assert!(matches!( + app.handle_key(key(KeyCode::Char('!'))), + DashboardAction::None + )); + assert_eq!(input_text(&app), "hello !world"); + + assert!(matches!( + app.handle_key(modified_key(KeyCode::Right, KeyModifiers::CONTROL)), + DashboardAction::None + )); + assert!(matches!( + app.handle_key(modified_key(KeyCode::Char('w'), KeyModifiers::CONTROL)), + DashboardAction::None + )); + assert_eq!(input_text(&app), "hello !"); +} + +#[test] +fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() { + let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]); + app.input.insert_str("draft message"); + + assert!(app.selected_row.is_some()); + assert!(matches!( + app.handle_key(key(KeyCode::Esc)), + DashboardAction::None + )); + assert!(app.selected_row.is_none()); + assert_eq!(input_text(&app), "draft message"); + assert!( + app.notice + .as_deref() + .unwrap() + .contains("Row selection cleared") + ); + assert!(matches!( + app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)), + DashboardAction::Quit + )); +} + +#[test] +fn dashboard_composer_target_switch_preserves_typed_text() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("draft intake request"); + + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + let selected_before = app.selected_row.clone(); + assert!(matches!( + app.handle_key(key(KeyCode::Tab)), + DashboardAction::None + )); + + assert!(matches!( + app.composer_target(), + ComposerTarget::TicketIntake + )); + assert_eq!(app.selected_row, selected_before); + assert_eq!(input_text(&app), "draft intake request"); +} + +#[test] +fn dashboard_ctrl_t_does_not_switch_composer_target() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("draft intake request"); + + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + assert!(matches!( + app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)), + DashboardAction::None + )); + + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + assert_eq!(input_text(&app), "draft intake request"); +} + +#[test] +fn dashboard_no_ticket_workspace_exposes_only_companion_target() { + let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]); + app.input.insert_str("draft message"); + + app.cycle_composer_target(); + + assert_eq!( + app.panel.composer.available_targets, + vec![ComposerTarget::Companion] + ); + assert!(matches!(app.composer_target(), ComposerTarget::Companion)); + assert_eq!(input_text(&app), "draft message"); + assert!(app.notice.as_deref().unwrap().contains("unavailable")); +} + +#[test] +fn dashboard_blank_ticket_intake_enter_uses_selected_row_and_preserves_input() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str(" \n\t"); + + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::Open + )); + + assert!(matches!( + app.composer_target(), + ComposerTarget::TicketIntake + )); + assert!(!app.sending); + assert_eq!(input_text(&app), " \n\t"); + assert!( + !app.notice + .as_deref() + .unwrap_or_default() + .contains("input is empty") + ); +} + +#[test] +fn dashboard_ticket_intake_enter_builds_launch_request_not_direct_send() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str("please intake this work"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + DashboardAction::LaunchIntake(request) => request, + _ => panic!("Ticket Intake target should launch Intake"), + }; + + assert_eq!(request.context.role, TicketRole::Intake); + assert_eq!( + request.context.user_instruction.as_deref(), + Some("please intake this work") + ); + assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi")); + assert_eq!( + request.context.intake_handoff, + Some(TicketIntakeHandoff::new("test-orchestrator", "test")) + ); + assert_eq!( + request.peer_registration, + IntakePeerRegistrationRequest::Register { + orchestrator_pod: "test-orchestrator".to_string() + } + ); + assert!(app.sending); + assert!(app.notice.as_deref().unwrap().contains("Launching")); + assert_eq!(input_text(&app), "please intake this work"); +} + +#[test] +fn dashboard_ticket_intake_handoff_skips_peer_registration_when_orchestrator_not_live() { + let mut app = ticket_enabled_app_with_orchestrator( + vec![live_info("idle", PodStatus::Idle)], + OrchestratorPanelStatus::Unavailable, + ); + app.cycle_composer_target(); + app.input.insert_str("please intake this work"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + DashboardAction::LaunchIntake(request) => request, + _ => panic!("Ticket Intake target should launch Intake"), + }; + + assert_eq!( + request.context.intake_handoff, + Some(TicketIntakeHandoff::new("test-orchestrator", "test")) + ); + match request.peer_registration { + IntakePeerRegistrationRequest::Skip { reason } => { + assert!(reason.contains("test-orchestrator")); + assert!(reason.contains("unavailable")); + } + other => panic!("expected peer registration skip, got {other:?}"), + } +} + +#[test] +fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str("please intake this work"); + app.sending = true; + + app.finish_intake_launch(Ok(IntakeLaunchOutcome { + launch: TicketRoleLaunchResult { + plan: client::ticket_role::TicketRoleLaunchPlan { + workspace_root: PathBuf::from("/tmp/workspace"), + cwd: None, + original_workspace_root: PathBuf::from("/tmp/workspace"), + target_workspace_root: PathBuf::from("/tmp/workspace"), + implementation_worktree_root: PathBuf::from("/tmp/workspace/.worktree"), + role: TicketRole::Intake, + pod_name: "intake-pod".to_string(), + profile: "builtin:default".to_string(), + workflow: "ticket-intake-workflow".to_string(), + launch_prompt_ref: None, + run_segments: vec![], + }, + ready: client::SpawnReady { + pod_name: "intake-pod".to_string(), + socket_path: PathBuf::from("/tmp/intake.sock"), + }, + pre_run_warnings: vec![], + }, + peer_registration: IntakePeerRegistrationStatus::Registered { + orchestrator_pod: "test-orchestrator".to_string(), + }, + registry_warning: None, + })); + + assert!(!app.sending); + assert_eq!(input_text(&app), ""); + let notice = app.notice.as_deref().unwrap(); + assert!(notice.contains("intake-pod")); + assert!(notice.contains("Handoff peer registered")); +} + +#[test] +fn dashboard_ticket_intake_finish_failure_keeps_composer() { + let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); + app.cycle_composer_target(); + app.input.insert_str("please keep this"); + app.sending = true; + + app.finish_intake_launch(Err(TicketRoleLaunchError::EmptyPodName)); + + assert!(!app.sending); + assert_eq!(input_text(&app), "please keep this"); + assert!(app.notice.as_deref().unwrap().contains("composer kept")); +} + +#[test] +fn intake_registry_update_claim_is_durable_only_after_commit() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("registry"); + let store = PanelRegistryStore::from_root(root.clone()); + let update = IntakeRegistryUpdate::ClaimTicket { + registry_root: root, + ticket_id: "20260608-000000-existing".to_string(), + ticket_slug: Some("existing".to_string()), + pod_name: "existing-intake".to_string(), + }; + + assert!( + store + .claim_for_ticket("20260608-000000-existing") + .unwrap() + .is_none(), + "holding a pending Intake registry update must not persist a Ticket claim" + ); + + assert!(commit_intake_registry_update(update.clone(), None).is_none()); + assert!( + store + .claim_for_ticket("20260608-000000-existing") + .unwrap() + .is_some(), + "the claim is persisted only by the post-acceptance commit step" + ); + + assert!(commit_intake_registry_update(update, None).is_none()); + let snapshot = store.snapshot().unwrap(); + assert_eq!(snapshot.claims.len(), 1); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].pod_name, "existing-intake"); + assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); + assert_eq!(snapshot.sessions[0].related_tickets.len(), 1); + assert_eq!( + snapshot.sessions[0].related_tickets[0].id, + "20260608-000000-existing" + ); +} + +#[test] +fn intake_registry_claims_launched_ticket_with_accepted_pod_name() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("registry"); + let store = PanelRegistryStore::from_root(root.clone()); + let update = IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: root, + ticket_id: "20260608-000000-ready".to_string(), + ticket_slug: None, + }; + + assert!(commit_intake_registry_update(update, Some("launched-intake")).is_none()); + + let claim = store + .claim_for_ticket("20260608-000000-ready") + .unwrap() + .expect("launched Intake Pod is claimed after accepted launch"); + assert_eq!(claim.pod_name, "launched-intake"); + let snapshot = store.snapshot().unwrap(); + assert_eq!(snapshot.claims.len(), 1); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); + assert_eq!(snapshot.sessions[0].pod_name, "launched-intake"); +} + +#[test] +fn intake_registry_launched_ticket_claim_without_pod_name_is_diagnostic() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("registry"); + let store = PanelRegistryStore::from_root(root.clone()); + + let warning = commit_intake_registry_update( + IntakeRegistryUpdate::ClaimLaunchedTicket { + registry_root: root, + ticket_id: "20260608-000000-ready".to_string(), + ticket_slug: None, + }, + None, + ) + .expect("missing launched Pod name should be diagnostic"); + + assert!(warning.contains("missing launched Pod name")); + assert!( + store + .claim_for_ticket("20260608-000000-ready") + .unwrap() + .is_none() + ); +} + +#[test] +fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("registry"); + let store = PanelRegistryStore::from_root(root.clone()); + store + .claim_ticket( + "20260608-000001-existing", + Some("existing"), + "first-intake", + TicketRole::Intake.as_str(), + ) + .unwrap(); + + let warning = commit_intake_registry_update( + IntakeRegistryUpdate::ClaimTicket { + registry_root: root, + ticket_id: "20260608-000001-existing".to_string(), + ticket_slug: Some("existing".to_string()), + pod_name: "second-intake".to_string(), + }, + None, + ) + .expect("conflicting post-success claim should be reported"); + + assert!(warning.contains("could not be committed")); + let claim = store + .claim_for_ticket("20260608-000001-existing") + .unwrap() + .unwrap(); + assert_eq!(claim.pod_name, "first-intake"); + let snapshot = store.snapshot().unwrap(); + assert_eq!(snapshot.claims.len(), 1); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].pod_name, "first-intake"); +} + +#[test] +fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() { + let mut app = test_app(vec![unreachable_live_info("unreachable")]); + assert!(matches!( + app.handle_key(key(KeyCode::Enter)), + DashboardAction::Open + )); + assert!(app.prepare_open().is_none()); + + assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); +} + +#[test] +fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() { + let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); + app.panel.rows = vec![panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )]; + app.refresh_orchestrator_work_set(); + + let request = app + .prepare_orchestrator_queue_attention_notice() + .expect("idle orchestrator should receive queued-work attention"); + + assert_eq!(request.pod_name, "test-orchestrator"); + assert!(request.notice.message.contains("00001QUEUE")); + assert!(request.notice.message.contains("new_queued")); + assert!(request.notice.message.contains("queued -> inprogress")); +} + +#[test] +fn active_inprogress_suppresses_queued_attention_and_retains_waiting_reason() { + let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); + app.panel.rows = vec![ + panel_test_ticket_row( + "00001ACTIVE", + "Active work", + ActionPriority::Background, + NextUserAction::Wait, + "inprogress", + ), + panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + ), + ]; + app.refresh_orchestrator_work_set(); + app.apply_orchestrator_work_set_detail(); + + assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); + let queued = app + .orchestrator_work_set + .queued + .iter() + .find(|item| item.id == "00001QUEUE") + .expect("queued item retained"); + assert_eq!( + queued.classification, + OrchestratorQueuedClassification::PlannedQueued + ); + assert!( + queued + .waiting_reason + .as_deref() + .unwrap() + .contains("active_inprogress") + ); + assert!( + app.panel + .header + .orchestrator + .as_ref() + .unwrap() + .detail + .as_deref() + .unwrap() + .contains("suppressed") + ); +} + +#[test] +fn planned_queued_prompts_when_active_work_clears() { + let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); + app.panel.rows = vec![ + panel_test_ticket_row( + "00001ACTIVE", + "Active work", + ActionPriority::Background, + NextUserAction::Wait, + "inprogress", + ), + panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + ), + ]; + app.refresh_orchestrator_work_set(); + assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); + + app.panel.rows = vec![panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )]; + app.refresh_orchestrator_work_set(); + let request = app + .prepare_orchestrator_queue_attention_notice() + .expect("planned queued work should prompt after active work clears"); + + assert!(request.notice.message.contains("planned_queued")); + assert!( + !request + .notice + .message + .contains("waiting for active_inprogress") + ); +} + +#[test] +fn queued_attention_is_suppressed_when_existing_claim_prevents_duplicate_start() { + let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); + let mut row = panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + ); + row.ticket.as_mut().unwrap().local_claim = + Some(crate::workspace_panel::TicketLocalClaimEntry { + pod_name: "coder-00001QUEUE".to_string(), + role: "coder".to_string(), + status: TicketLocalClaimStatus::Live, + }); + row.related_pods.push("reviewer-00001QUEUE".to_string()); + app.panel.rows = vec![row]; + app.refresh_orchestrator_work_set(); + app.apply_orchestrator_work_set_detail(); + + assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); + let waiting = app.orchestrator_work_set.queued[0] + .waiting_reason + .as_deref() + .unwrap(); + assert!(waiting.contains("duplicate start")); + assert!(waiting.contains("coder-00001QUEUE")); +} + +#[test] +fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() { + let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]); + app.orchestrator_work_set = OrchestratorWorkSet::default(); + app.panel.rows = vec![panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )]; + + let request = app + .prepare_orchestrator_queue_attention_notice() + .expect("queued ticket state should be rediscovered safely"); + + assert!(request.notice.message.contains("new_queued")); + assert!(request.notice.message.contains("00001QUEUE")); +} + +#[test] +fn queued_attention_requires_idle_orchestrator_to_avoid_duplicate_rekick() { + let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Running)]); + app.panel.rows = vec![panel_test_ticket_row( + "00001QUEUE", + "Queued work", + ActionPriority::Background, + NextUserAction::Wait, + "queued", + )]; + app.refresh_orchestrator_work_set(); + + assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); +} + +fn test_app(live: Vec) -> DashboardApp { + app_with_list(PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + live, + None, + 10, + )) +} + +fn companion_app(live: Vec, status: CompanionPanelStatus) -> DashboardApp { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.companion = Some(CompanionPanelState::new("yoi", status, None)); + app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), + panel, + ) +} + +fn ticket_enabled_app(live: Vec) -> DashboardApp { + ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live) +} + +fn ticket_enabled_app_with_orchestrator( + live: Vec, + orchestrator_status: OrchestratorPanelStatus, +) -> DashboardApp { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); + panel.header.companion = Some(CompanionPanelState::new( + "yoi", + CompanionPanelStatus::Live, + None, + )); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + orchestrator_status, + None, + )); + app_with_panel( + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), + panel, + ) +} + +fn app_with_list(list: PodList) -> DashboardApp { + app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test"))) +} + +fn empty_test_list() -> PodList { + PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10) +} + +fn panel_with_orchestrator( + status: OrchestratorPanelStatus, + detail: Option<&str>, +) -> WorkspacePanelViewModel { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + status, + detail.map(str::to_string), + )); + if let Some(detail) = detail { + panel.header.diagnostics.push(detail.to_string()); + } + panel +} + +fn app_with_panel(list: PodList, panel: WorkspacePanelViewModel) -> DashboardApp { + let last_companion_lifecycle_failure = companion_lifecycle_failure_from_panel(&panel); + let last_orchestrator_lifecycle_failure = orchestrator_lifecycle_failure_from_panel(&panel); + let mut app = DashboardApp { + list, + panel, + input: InputBuffer::new(), + selected_row: None, + row_hit_boxes: Vec::new(), + composer_target: ComposerTarget::Companion, + notice: None, + panel_diagnostic: None, + panel_diagnostic_open: false, + sending: false, + refreshing: false, + enter_reload: None, + runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"), + last_companion_lifecycle_failure, + last_orchestrator_lifecycle_failure, + orchestrator_work_set: OrchestratorWorkSet::default(), + orchestrator_queue_attention: None, + }; + app.ensure_selection_visible(); + app.ensure_composer_target_available(); + app.refresh_orchestrator_work_set(); + app.apply_orchestrator_work_set_detail(); + app +} + +fn panel_test_ticket_row( + id: &str, + title: &str, + priority: ActionPriority, + next_action: NextUserAction, + state: &str, +) -> PanelRow { + let ticket = crate::workspace_panel::TicketPanelEntry { + id: id.to_string(), + title: title.to_string(), + priority: "P2".to_string(), + workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning), + workflow_state_explicit: true, + orchestration_overlay: None, + next_action: Some(next_action), + updated_at: None, + latest_event_kind: Some("implementation_report".to_string()), + latest_event_excerpt: Some("latest event stays out of the primary row".to_string()), + blocked_reason: None, + related_pods: Vec::new(), + local_claim: None, + intake_pods: Vec::new(), + }; + PanelRow { + key: PanelRowKey::Ticket(ticket.id.clone()), + kind: crate::workspace_panel::PanelRowKind::Ticket, + title: title.to_string(), + subtitle: Some("id · priority · latest event".to_string()), + status: state.to_string(), + priority, + next_action: Some(next_action), + ticket: Some(ticket), + related_pods: Vec::new(), + disabled_reason: None, + key_hint: Some("Enter".to_string()), + } +} + +fn panel_test_intake_child_row( + ticket_id: &str, + pod_name: &str, + status: TicketLocalClaimStatus, + next_action: Option, +) -> PanelRow { + PanelRow { + key: PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.to_string(), + pod_name: pod_name.to_string(), + }, + kind: PanelRowKind::TicketIntakePod, + title: format!("Intake Pod: {pod_name}"), + subtitle: Some(format!("Intake claim for Ticket {ticket_id}")), + status: status.label().to_string(), + priority: match status { + TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => { + ActionPriority::ActiveWork + } + TicketLocalClaimStatus::Stale => ActionPriority::Background, + }, + next_action, + ticket: None, + related_pods: vec![pod_name.to_string()], + disabled_reason: (status == TicketLocalClaimStatus::Stale) + .then(|| "claim metadata is stale".to_string()), + key_hint: Some(format!("Ticket {ticket_id} Intake Pod {pod_name}")), + } +} + +fn closed_list(count: usize, selected: Option<&str>) -> PodList { + PodList::from_sources( + PodVisibilitySource::ResumePicker, + (0..count) + .map(|index| { + stopped_info_with_updated_at(&format!("closed-{index}"), 100 - index as u64) + }) + .collect(), + vec![], + selected.map(str::to_string), + count.max(1), + ) +} + +fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo { + live_info_with_updated_at(pod_name, status, 0) +} + +fn unreachable_live_info(pod_name: &str) -> LivePodInfo { + let mut live = live_info(pod_name, PodStatus::Idle); + live.reachable = false; + live.status = None; + live +} + +fn live_info_with_updated_at(pod_name: &str, status: PodStatus, updated_at: u64) -> LivePodInfo { + LivePodInfo { + pod_name: pod_name.to_string(), + socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")), + status: Some(status), + reachable: true, + segment_id: None, + summary: PodEntrySummary { + active_session_id: None, + active_segment_id: None, + updated_at, + preview: None, + }, + } +} + +fn stopped_info(pod_name: &str) -> StoredPodInfo { + stopped_info_with_updated_at(pod_name, 10) +} + +fn stopped_info_with_updated_at(pod_name: &str, updated_at: u64) -> StoredPodInfo { + StoredPodInfo { + pod_name: pod_name.to_string(), + metadata_state: StoredMetadataState::Present, + active_session_id: None, + active_segment_id: None, + updated_at, + workspace_root: None, + preview: None, + } +} + +fn section_names<'a>(list: &'a PodList, section: &DashboardSection) -> Vec<&'a str> { + section + .entries + .iter() + .map(|index| list.entries[*index].name.as_str()) + .collect() +} + +#[test] +fn ticket_action_error_records_f2_diagnostic_details() { + let mut app = DashboardApp::loading(PodRuntimeCommand::for_executable("/tmp/yoi")); + let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/dashboard.rs"; + + app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string()))); + + assert!(app.notice.as_deref().unwrap().contains("F2 details")); + let diagnostic = app.panel_diagnostic.as_ref().expect("diagnostic"); + assert_eq!(diagnostic.title, "Ticket action rejected"); + assert_eq!(diagnostic.details, long_error); + assert!(!app.panel_diagnostic_open); + + assert!(matches!( + app.handle_key(key(KeyCode::F(2))), + DashboardAction::None + )); + assert!(app.panel_diagnostic_open); + assert!(matches!( + app.handle_key(key(KeyCode::Esc)), + DashboardAction::None + )); + assert!(!app.panel_diagnostic_open); +} + +fn plain_line(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +fn display_column(text: &str, needle: &str) -> usize { + let byte_index = text.find(needle).unwrap(); + text[..byte_index].width() +} + +fn input_text(app: &DashboardApp) -> String { + Segment::flatten_to_text(&app.input.submit_segments()) +} + +fn key(code: KeyCode) -> KeyEvent { + modified_key(code, KeyModifiers::NONE) +} + +fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } +} + +fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index bfd3321d..b19694ba 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -4,18 +4,18 @@ mod cache; mod command; mod composer_history; mod composer_keys; +mod console; +mod dashboard; #[cfg(feature = "e2e-test")] mod e2e_observer; mod input; pub mod keys; mod markdown; -mod multi_pod; mod picker; mod pod_list; mod role_session_registry; mod scroll; pub mod setup_model; -mod single_pod; mod spawn; mod task; mod text_selection; @@ -64,7 +64,7 @@ pub enum LaunchMode { id: SegmentId, pod_name: Option, }, - /// `yoi panel`: open the workspace panel from the current workspace. + /// `yoi panel`: open the workspace Dashboard from the current workspace. Panel, } @@ -95,17 +95,17 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { let result = match mode { LaunchMode::Spawn { pod_name, profile } => { - single_pod::run_spawn(None, pod_name, profile, runtime_command).await + console::run_spawn(None, pod_name, profile, runtime_command).await } LaunchMode::PodName { pod_name, socket_override, - } => single_pod::run_pod_name(pod_name, socket_override, runtime_command).await, - LaunchMode::Resume => single_pod::run_resume(runtime_command).await, + } => console::run_pod_name(pod_name, socket_override, runtime_command).await, + LaunchMode::Resume => console::run_resume(runtime_command).await, LaunchMode::ResumeWithSession { id, pod_name } => { - single_pod::run_spawn(Some(id), pod_name, None, runtime_command).await + console::run_spawn(Some(id), pod_name, None, runtime_command).await } - LaunchMode::Panel => single_pod::run_panel(runtime_command).await, + LaunchMode::Panel => dashboard::launch(runtime_command).await, }; // Always restore the terminal first so any pending eprintln below diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index d3df30ae..3cbdf524 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -671,7 +671,11 @@ coder = "profiles/coder.lua" .unwrap(); let (choices, default_index) = profile_choices_for_cwd(&project); - assert_eq!(default_index, 1); + let default_choice = choices + .iter() + .position(|choice| choice.selector.as_deref() == Some("project:coder")) + .expect("project default choice is present"); + assert_eq!(default_index, default_choice); let selected = &choices[default_index]; assert_eq!(selected.selector.as_deref(), Some("project:coder")); assert_eq!(selected.label, "project:coder (default)"); @@ -701,9 +705,19 @@ description = "Project coder" choices[0].label, "builtin:default — Bundled default Yoi coding profile" ); - assert_eq!(default_index, 1); - assert_eq!(choices[1].selector.as_deref(), Some("project:coder")); - assert_eq!(choices[1].label, "project:coder (default) — Project coder"); + let project_index = choices + .iter() + .position(|choice| choice.selector.as_deref() == Some("project:coder")) + .expect("project default choice is present"); + assert_eq!(default_index, project_index); + assert_eq!( + choices[project_index].selector.as_deref(), + Some("project:coder") + ); + assert_eq!( + choices[project_index].label, + "project:coder (default) — Project coder" + ); } #[test] diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index b3efec3d..933d4e39 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -623,7 +623,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -973,6 +973,18 @@ mod tests { } } + #[test] + fn parse_dashboard_word_remains_a_pod_console_name_not_an_alias() { + let config = parse_args_from(["dashboard"]).unwrap(); + match config { + Mode::Tui { + mode: LaunchMode::PodName { pod_name, .. }, + .. + } => assert_eq!(pod_name, "dashboard"), + other => panic!("expected PodName TUI mode, got {other:?}"), + } + } + #[test] fn parse_multi_flag_is_not_a_launch_alias() { let err = parse_args_from(["--multi"]).unwrap_err(); diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index d6e96a27..96f0b668 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -1125,7 +1125,8 @@ mod tests { assert!(config.contains("# [ticket]\n# language = \"Japanese\"")); for role in TicketRole::ALL { assert!(config.contains(&format!( - "[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"", + "[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", + role.default_profile(), role.default_workflow() ))); } diff --git a/docs/development/work-items.md b/docs/development/work-items.md index ea8cb3c8..4cbe2e70 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -22,7 +22,7 @@ A Ticket may represent a feature, bug, cleanup, design decision, investigation, Use the highest-level interface that matches the work: -- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace UI and role-launch actions. +- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions. - Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links. - Inside Pods, use typed Ticket tools to create, inspect, comment, review, and close Tickets. - For multi-step work, follow the Ticket Intake, Orchestrator Routing, planning/requirements-sync, and Multi-agent workflows. @@ -268,9 +268,9 @@ Before closing, verify concrete evidence: Close with a resolution that summarizes what changed, key commits, validation, review state, and remaining follow-ups. -## Workspace panel Ticket role actions +## Workspace Dashboard Ticket role actions -`yoi panel` is the active Ticket/Intake/Orchestrator UI. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Pod TUI no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command. +`yoi panel` is the active Ticket/Intake/Orchestrator Dashboard. It owns fixed Ticket role-launch actions and uses the shared client Ticket role launcher. The single-Pod Console no longer supports `:ticket ...` commands; typing them in command mode is treated like any other unknown command. Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml`: @@ -279,24 +279,24 @@ Role actions map to the same fixed roles configured in `.yoi/ticket.config.toml` - implement launches the coder role for an implementation assignment. - review launches the reviewer role for review. -All actions are explicit and user-triggered. They are not a scheduler, queue, spawned-Pod panel, or automatic maintainer loop. +All actions are explicit and user-triggered. They are not a scheduler, queue, spawned-Pod Dashboard, or automatic maintainer loop. -### Panel execution path +### Dashboard execution path The role-launch path is: ```text User triggers a Ticket action in yoi panel - -> panel builds a TicketRoleLaunchContext + -> Dashboard builds a TicketRoleLaunchContext -> client Ticket role launcher reads .yoi/ticket.config.toml -> launcher selects the role Profile and workflow -> launcher spawns the role Pod -> launcher sends Method::Run with WorkflowInvoke + Text segments -> launcher waits for run-acceptance evidence - -> panel reports success/failure + -> Dashboard reports success/failure ``` -The launched Pod receives dynamic Ticket/action context as its first committed run input. The panel does not inject hidden context, does not write Ticket files directly, and does not construct prompt/workflow segments by hand. +The launched Pod receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt/workflow segments by hand. The first run input contains: @@ -308,9 +308,9 @@ The first run input contains: The selected Profile supplies durable system/role behavior. `ticket.config.toml` does not override system instruction. -### Panel setup +### Dashboard setup -Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using panel role actions: +Because top-level role launches cannot inherit a parent Profile, configure concrete role profiles before using Dashboard role actions: ```toml # .yoi/ticket.config.toml @@ -336,9 +336,9 @@ profile = "project:reviewer" workflow = "multi-agent-workflow" ``` -If a role still uses `profile = "inherit"`, the panel fails closed with a diagnostic explaining that a concrete profile is required. +If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required. -### Panel troubleshooting +### Dashboard troubleshooting - `profile = "inherit"`: configure a concrete role Profile in `.yoi/ticket.config.toml`. - malformed `.yoi/ticket.config.toml`: fix the config and retry. diff --git a/resources/prompts/panel/orchestrator_idle_queue_notice.md b/resources/prompts/panel/orchestrator_idle_queue_notice.md index 3753fba5..0ec48c2c 100644 --- a/resources/prompts/panel/orchestrator_idle_queue_notice.md +++ b/resources/prompts/panel/orchestrator_idle_queue_notice.md @@ -1,5 +1,5 @@ -Workspace panel observed that this Orchestrator Pod is idle while queued Ticket work is present. +Workspace Dashboard observed that this Orchestrator Pod is idle while queued Ticket work is present. This is bounded attention only, not scheduler authority. Do not drain the queue automatically. Before implementation side effects, verify the Ticket state and record the normal `queued -> inprogress` acceptance through Ticket tools.