| 1 | //! Ocean Work Graph surface ownership. |
| 2 | //! |
| 3 | //! This is called the "workbar" or the "work surface". Fresh settings default |
| 4 | //! to `Bottom` (round 3, 2026-09-01); `Top`, `Left`, and `Right` remain |
| 5 | //! supported and `Off` hides it. It is not the header |
| 6 | //! ([`crate::tui::underwater`]) and not the footer. |
| 7 | //! |
| 8 | //! Two settings are orthogonal and are routinely mixed up: |
| 9 | //! |
| 10 | //! - **placement** — where it renders. `Bottom` (fresh default) | `Top` | |
| 11 | //! `Left` | `Right` | `Off`. Drag-resizing the divider persists |
| 12 | //! `work_surface_top_height` (5..=16) or `work_surface_side_width` |
| 13 | //! (26..=80) to `settings.toml`. |
| 14 | //! - **panel** — what it shows. [`RailPanel`]: `Tasks` (default) | `Agents` | |
| 15 | //! `Background` | `Files` | `Notepad` | `Context` | `Git` | `Price`, from |
| 16 | //! the `rail_panel` setting. The legacy `sidebar_focus` key migrates into |
| 17 | //! it. |
| 18 | //! |
| 19 | //! So the word "Pinned" on screen is a PANEL name, not a state. |
| 20 | //! |
| 21 | //! ## Auto-fit by placement |
| 22 | //! |
| 23 | //! Placement changes *which axis is the ceiling*, not the content rule: |
| 24 | //! |
| 25 | //! | Placement | Ceiling | Auto-fit | Empty | |
| 26 | //! |---|---|---|---| |
| 27 | //! | `Top` | `top_height` (rows) | content rows + divider, clamped to ceiling | `height() == 0` | |
| 28 | //! | `Left`/`Right` | `side_width` (cols) | full chat height at that width | no column reserved | |
| 29 | //! | `Off` | — | — | nothing | |
| 30 | //! |
| 31 | //! Shared rules: content drives size; the setting is a ceiling, never padding; |
| 32 | //! empty work is not a rail. Top never paints a chrome panel title (a checklist |
| 33 | //! reads as a checklist); side rails are named by their content's own heading |
| 34 | //! row (`Work · …`, `▾ Subagents N`, `Goal: …`) except Context, which keeps a |
| 35 | //! muted panel title over its fact list. Narrow hosts that cannot fit a side |
| 36 | //! column fall back to Top, where height auto-fit takes over. |
| 37 | //! |
| 38 | //! ## Row lifetime |
| 39 | //! |
| 40 | //! The strip is a standing register of this session's work, not a live-only |
| 41 | //! view. A to-do or sub-agent row appears when the work exists and stays for |
| 42 | //! the rest of the session after it settles — completion is quiet (glyph, |
| 43 | //! tone, frozen receipt), never an eviction, and the active goal title |
| 44 | //! outlives the work under it. Only transient receipts (aggregated file |
| 45 | //! activity, settled operations) expire on the #4688/#4690 lifetimes. |
| 46 | //! Auto-fit and the row budget decide how many rows are *visible* at once; |
| 47 | //! they never decide membership. |
| 48 | //! |
| 49 | //! ## Rows are objects — in every panel |
| 50 | //! |
| 51 | //! Tasks, Agents, and Pinned all render through one row/hitbox pipeline: |
| 52 | //! every visible work row is selectable, hoverable, and clickable, and its |
| 53 | //! primary action opens the row's world (agent transcript / work inspector). |
| 54 | //! Keyboard Enter and mouse click dispatch identically. Context is the one |
| 55 | //! line-list panel; it holds facts, not rows. |
| 56 | //! |
| 57 | //! Height is decided once per frame by [`render::height`]; the row budget it is |
| 58 | //! given comes from `crate::tui::ui::rail_row_budget`, which is its only |
| 59 | //! production caller. |
| 60 | //! |
| 61 | //! Placement, scrolling, selection, and pager ownership remain local to this |
| 62 | //! component. Every visible work row derives from the active-session graph. |
| 63 | |
| 64 | mod input; |
| 65 | mod interaction; |
| 66 | mod model; |
| 67 | mod render; |
| 68 | mod views; |
| 69 | |
| 70 | pub use input::{cycle_view, enter_agents, handle_key, handle_mouse}; |
| 71 | pub(crate) use interaction::{agent_details_closed, release_focus, select_dock_panel}; |
| 72 | pub use model::{RailPanel, WorkSurfacePlacement, WorkSurfaceState}; |
| 73 | pub(crate) use render::collapse_strip; |
| 74 | pub use render::{height, render, split_chat}; |
| 75 | |
| 76 | #[cfg(test)] |
| 77 | mod tests { |
| 78 | use super::WorkSurfacePlacement; |
| 79 | use std::path::PathBuf; |
| 80 | |
| 81 | use crossterm::event::{ |
| 82 | KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, |
| 83 | }; |
| 84 | use ratatui::{Terminal, backend::TestBackend}; |
| 85 | |
| 86 | use crate::config::{ApiProvider, Config}; |
| 87 | use crate::tools::subagent::{ |
| 88 | AgentWorkerStatus, FleetRole, MailboxMessage, SubAgentAssignment, SubAgentResult, |
| 89 | SubAgentStatus, |
| 90 | }; |
| 91 | use crate::tools::todo::TodoStatus; |
| 92 | use crate::tui::app::{ |
| 93 | AgentCurrentActivity, AgentCurrentActivityStatus, App, SidebarRowAction, ToolDetailRecord, |
| 94 | TuiOptions, |
| 95 | }; |
| 96 | use crate::tui::golden_harness::assert_matches_golden; |
| 97 | use crate::tui::history::{ |
| 98 | FileMutationReceipt, GenericToolCell, HistoryCell, PatchSummaryCell, ToolCell, ToolStatus, |
| 99 | }; |
| 100 | use crate::work_graph::{ |
| 101 | AcceptanceRequirement, ChangeCtx, EdgeKind, EvidenceKindTag, NodeKind, NodeState, |
| 102 | OperationBinding, OperationOwnerSnapshot, OwnerState, Provenance, WorkEdge, WorkEdgeId, |
| 103 | WorkGraph, WorkGraphChange, WorkNode, WorkNodeId, |
| 104 | }; |
| 105 | |
| 106 | const SESSION: &str = "work-surface-test"; |
| 107 | |
| 108 | fn app() -> App { |
| 109 | let options = TuiOptions { |
| 110 | use_mouse_capture: true, |
| 111 | max_subagents: 4, |
| 112 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 113 | }; |
| 114 | let mut app = App::new(options, &Config::default()); |
| 115 | app.ui_locale = codewhale_localization::Locale::En; |
| 116 | // Dogfood guard: App::new reads the developer's real settings.toml, |
| 117 | // and the 0.9.4 migration maps a legacy sidebar_focus onto the rail |
| 118 | // panel. These tests exercise the Tasks panel's row machinery, so |
| 119 | // pin it rather than depend on the host file. |
| 120 | app.work_surface.panel = super::RailPanel::Tasks; |
| 121 | // Not an explicit pick: the auto rule opens the agents view when a |
| 122 | // fixture caches a running worker, exactly as the product does. |
| 123 | app.work_surface.explicit_view = false; |
| 124 | // Most tests in this module predate the fresh left-rail default and |
| 125 | // exercise the Top strip's height, divider, overflow, and row layout. |
| 126 | // Pin both requested and effective placement; dedicated placement |
| 127 | // tests override these fields explicitly. |
| 128 | app.work_surface.placement = WorkSurfacePlacement::Top; |
| 129 | app.work_surface.effective_placement = WorkSurfacePlacement::Top; |
| 130 | app |
| 131 | } |
| 132 | |
| 133 | /// The row budget `ui::render` would hand the rail on a terminal of this |
| 134 | /// height with real work on screen. Calls the production formula rather |
| 135 | /// than restating it, so a change to the chrome accounting shows up here |
| 136 | /// instead of silently diverging. The idle-empty budget (where the |
| 137 | /// ambient floor bites) is covered end-to-end in `ui::tests`. |
| 138 | fn working_budget(app: &App, terminal_height: u16) -> u16 { |
| 139 | crate::tui::ui::rail_row_budget(app, 80, terminal_height, false) |
| 140 | } |
| 141 | |
| 142 | /// A budget wide enough never to bind, for tests about something else. |
| 143 | const AMPLE_BUDGET: u16 = u16::MAX; |
| 144 | |
| 145 | fn add_todos(app: &mut App, count: usize) { |
| 146 | let mut todos = app.todos.try_lock().expect("todos"); |
| 147 | for index in 0..count { |
| 148 | todos.add( |
| 149 | format!("work item {index}"), |
| 150 | if index == 0 { |
| 151 | TodoStatus::InProgress |
| 152 | } else { |
| 153 | TodoStatus::Pending |
| 154 | }, |
| 155 | ); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | fn operation_graph(state: NodeState) -> crate::work_graph::WorkGraphSnapshot { |
| 160 | let objective = WorkNodeId::derive(SESSION, "objective"); |
| 161 | let operation = WorkNodeId::derive(SESSION, "operation"); |
| 162 | let ctx = |now| ChangeCtx { |
| 163 | session_id: SESSION.to_string(), |
| 164 | now, |
| 165 | idempotency_key: None, |
| 166 | }; |
| 167 | let node = |id: WorkNodeId, kind, title: &str, now| WorkNode { |
| 168 | id, |
| 169 | kind, |
| 170 | title: title.to_string(), |
| 171 | state: NodeState::Ready, |
| 172 | acceptance: Vec::new(), |
| 173 | binding: None, |
| 174 | evidence: None, |
| 175 | provenance: Provenance::RuntimeReconcile { |
| 176 | source: "test-owner".to_string(), |
| 177 | observed_at: now, |
| 178 | }, |
| 179 | created_at: now, |
| 180 | updated_at: now, |
| 181 | }; |
| 182 | let mut graph = WorkGraph::new(); |
| 183 | graph |
| 184 | .apply( |
| 185 | WorkGraphChange::AddNode { |
| 186 | node: node(objective.clone(), NodeKind::Objective, "Ship v0.9.1", 1), |
| 187 | }, |
| 188 | ctx(1), |
| 189 | ) |
| 190 | .expect("objective"); |
| 191 | graph |
| 192 | .apply( |
| 193 | WorkGraphChange::AddNode { |
| 194 | node: node( |
| 195 | operation.clone(), |
| 196 | NodeKind::Operation, |
| 197 | "Verify installed build", |
| 198 | 2, |
| 199 | ), |
| 200 | }, |
| 201 | ctx(2), |
| 202 | ) |
| 203 | .expect("operation"); |
| 204 | graph |
| 205 | .apply( |
| 206 | WorkGraphChange::AddEdge { |
| 207 | edge: WorkEdge { |
| 208 | id: WorkEdgeId::derive(SESSION, "contains"), |
| 209 | kind: EdgeKind::Contains, |
| 210 | from: objective, |
| 211 | to: operation.clone(), |
| 212 | }, |
| 213 | }, |
| 214 | ctx(3), |
| 215 | ) |
| 216 | .expect("contains"); |
| 217 | graph |
| 218 | .apply( |
| 219 | WorkGraphChange::BindOperation { |
| 220 | node: operation.clone(), |
| 221 | binding: OperationBinding { |
| 222 | external: "shell:shell_1234abcd".to_string(), |
| 223 | durable: false, |
| 224 | last_observation: None, |
| 225 | }, |
| 226 | }, |
| 227 | ctx(4), |
| 228 | ) |
| 229 | .expect("binding"); |
| 230 | if state != NodeState::Ready { |
| 231 | graph |
| 232 | .apply( |
| 233 | WorkGraphChange::UpdateNode { |
| 234 | id: operation, |
| 235 | patch: crate::work_graph::WorkNodePatch { |
| 236 | state: Some(state), |
| 237 | ..crate::work_graph::WorkNodePatch::default() |
| 238 | }, |
| 239 | }, |
| 240 | ctx(5), |
| 241 | ) |
| 242 | .expect("state"); |
| 243 | } |
| 244 | graph.into_snapshot() |
| 245 | } |
| 246 | |
| 247 | fn restore_graph(app: &mut App, graph: &crate::work_graph::WorkGraphSnapshot) { |
| 248 | app.current_session_id = Some(SESSION.to_string()); |
| 249 | app.runtime_services |
| 250 | .work |
| 251 | .as_ref() |
| 252 | .expect("Work Graph runtime") |
| 253 | .restore( |
| 254 | SESSION, |
| 255 | Some(graph), |
| 256 | &crate::work_graph::project_todos(graph), |
| 257 | &crate::work_graph::project_plan(graph), |
| 258 | ) |
| 259 | .expect("restore graph"); |
| 260 | } |
| 261 | |
| 262 | fn restore_saved_graph(app: &mut App, graph: &crate::work_graph::WorkGraphSnapshot) { |
| 263 | app.current_session_id = Some(SESSION.to_string()); |
| 264 | let state = crate::session_manager::SessionWorkState { |
| 265 | graph: Some(graph.clone()), |
| 266 | todos: crate::work_graph::project_todos(graph), |
| 267 | plan: crate::work_graph::project_plan(graph), |
| 268 | }; |
| 269 | app.restore_work_state(SESSION, std::path::Path::new("."), Some(&state)) |
| 270 | .expect("restore saved graph"); |
| 271 | } |
| 272 | |
| 273 | fn render_text(app: &mut App, width: u16, height: u16) -> String { |
| 274 | let backend = TestBackend::new(width, height); |
| 275 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 276 | terminal |
| 277 | .draw(|frame| super::render(frame, frame.area(), app)) |
| 278 | .expect("draw"); |
| 279 | terminal |
| 280 | .backend() |
| 281 | .buffer() |
| 282 | .content() |
| 283 | .iter() |
| 284 | .map(|cell| cell.symbol()) |
| 285 | .collect() |
| 286 | } |
| 287 | |
| 288 | fn render_golden_text(app: &mut App, width: u16, height: u16) -> String { |
| 289 | let backend = TestBackend::new(width, height); |
| 290 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 291 | terminal |
| 292 | .draw(|frame| super::render(frame, frame.area(), app)) |
| 293 | .expect("draw"); |
| 294 | format!("{}\n", terminal_text(&terminal)) |
| 295 | } |
| 296 | |
| 297 | #[test] |
| 298 | fn scheduled_automations_do_not_create_background_work() { |
| 299 | let mut app = |
| 300 | crate::test_support::test_app_with_options(crate::test_support::test_tui_options(".")); |
| 301 | app.automation_panel.active_automations = 2; |
| 302 | assert!(!super::model::background_has_live_work(&mut app)); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn projection_keeps_every_legacy_todo_as_a_graph_row() { |
| 307 | let mut app = app(); |
| 308 | add_todos(&mut app, 4); |
| 309 | |
| 310 | let rows = super::model::project(&mut app); |
| 311 | |
| 312 | assert!( |
| 313 | rows[0].label.starts_with("Work · Running:") |
| 314 | || rows[0] |
| 315 | .label |
| 316 | .starts_with("Work · 1 active · 0 needs input · 3 ready"), |
| 317 | "unexpected heading {}", |
| 318 | rows[0].label |
| 319 | ); |
| 320 | for index in 0..4 { |
| 321 | assert!( |
| 322 | rows.iter() |
| 323 | .any(|row| row.label == format!("work item {index}")) |
| 324 | ); |
| 325 | } |
| 326 | assert!(rows.iter().all(|row| !row.id.0.starts_with("todo:"))); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn coordination_projection_is_one_selectable_work_row_with_shared_details() { |
| 331 | use crate::tools::subagent::CoordinationDetailProjection; |
| 332 | use crate::tools::subagent::coord::{ |
| 333 | CoordinationDetailMetrics, DecisionRecord, DecisionStatus, |
| 334 | }; |
| 335 | |
| 336 | let mut app = app(); |
| 337 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 338 | schema_version: 1, |
| 339 | sequence: 7, |
| 340 | decisions: vec![DecisionRecord { |
| 341 | decision_id: "decision-work".to_string(), |
| 342 | subject: "coordination row".to_string(), |
| 343 | status: DecisionStatus::Accepted, |
| 344 | owner: "release-owner".to_string(), |
| 345 | scope: Vec::new(), |
| 346 | constraints: vec!["PRIVATE-TRANSCRIPT-MARKER".to_string()], |
| 347 | evidence_handles: Vec::new(), |
| 348 | version: 2, |
| 349 | sequence: 7, |
| 350 | }], |
| 351 | write_claims: Vec::new(), |
| 352 | reconciliations: Vec::new(), |
| 353 | context_projections: Vec::new(), |
| 354 | contentions: Vec::new(), |
| 355 | metrics: CoordinationDetailMetrics { |
| 356 | hottest_paths: Vec::new(), |
| 357 | package_or_module_growth: None, |
| 358 | route_or_cost: None, |
| 359 | note: "No active claims".to_string(), |
| 360 | }, |
| 361 | bounded: true, |
| 362 | limit: 24, |
| 363 | process_lock_held: true, |
| 364 | process_lock_note: None, |
| 365 | }); |
| 366 | |
| 367 | let rows = super::model::project(&mut app); |
| 368 | assert_eq!( |
| 369 | rows[0].label, |
| 370 | "Work · 0 active · 0 needs input · 0 ready · 1 recent" |
| 371 | ); |
| 372 | let row = rows |
| 373 | .iter() |
| 374 | .find(|row| row.id.0 == "coordination") |
| 375 | .expect("coordination Work row"); |
| 376 | assert_eq!(row.label, "Coordination Work"); |
| 377 | assert_eq!(row.detail, "1 decisions · 0 contentions · 0 reconciled"); |
| 378 | let Some(SidebarRowAction::InspectWork { title, body, .. }) = row.primary_action.as_ref() |
| 379 | else { |
| 380 | panic!("coordination row must open the shared Work inspector"); |
| 381 | }; |
| 382 | assert_eq!(title, "Coordination Work"); |
| 383 | assert!(body.contains("decision-work · coordination row"), "{body}"); |
| 384 | assert!( |
| 385 | body.contains("status accepted · owner release-owner · version 2"), |
| 386 | "{body}" |
| 387 | ); |
| 388 | assert!(!body.contains("PRIVATE-TRANSCRIPT-MARKER"), "{body}"); |
| 389 | |
| 390 | app.work_surface.placement = WorkSurfacePlacement::Right; |
| 391 | app.work_surface.effective_placement = WorkSurfacePlacement::Right; |
| 392 | let narrow = render_text(&mut app, 32, 4); |
| 393 | assert!(narrow.contains("Coordination Work"), "{narrow}"); |
| 394 | let _ = super::handle_key( |
| 395 | &mut app, |
| 396 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 397 | ); |
| 398 | let action = super::handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) |
| 399 | .expect("Work surface handled Enter") |
| 400 | .expect("coordination inspector action"); |
| 401 | assert!(matches!(action, SidebarRowAction::InspectWork { .. })); |
| 402 | } |
| 403 | |
| 404 | #[test] |
| 405 | fn empty_coordination_projection_does_not_create_work_chrome() { |
| 406 | use crate::tools::subagent::CoordinationDetailProjection; |
| 407 | use crate::tools::subagent::coord::{ContextProjectionReceipt, CoordinationDetailMetrics}; |
| 408 | |
| 409 | let mut app = app(); |
| 410 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 411 | schema_version: 1, |
| 412 | sequence: 3, |
| 413 | decisions: Vec::new(), |
| 414 | write_claims: Vec::new(), |
| 415 | reconciliations: Vec::new(), |
| 416 | context_projections: ["agent-a", "agent-b", "agent-c"] |
| 417 | .into_iter() |
| 418 | .enumerate() |
| 419 | .map(|(index, child_id)| ContextProjectionReceipt { |
| 420 | child_id: child_id.to_string(), |
| 421 | decision_ids: Vec::new(), |
| 422 | projected_bytes: 0, |
| 423 | deduplicated: 0, |
| 424 | omitted: 0, |
| 425 | sequence: u64::try_from(index + 1).expect("small fixture sequence"), |
| 426 | }) |
| 427 | .collect(), |
| 428 | contentions: Vec::new(), |
| 429 | metrics: CoordinationDetailMetrics { |
| 430 | hottest_paths: Vec::new(), |
| 431 | package_or_module_growth: None, |
| 432 | route_or_cost: None, |
| 433 | note: "growth and route/cost stay null when the coordination ledger has no authoritative source".to_string(), |
| 434 | }, |
| 435 | bounded: true, |
| 436 | limit: 24, |
| 437 | process_lock_held: true, |
| 438 | process_lock_note: None, |
| 439 | }); |
| 440 | |
| 441 | let rows = super::model::project(&mut app); |
| 442 | assert!( |
| 443 | rows.is_empty(), |
| 444 | "zero-byte, no-decision coordination receipts must not create Work chrome: {rows:?}" |
| 445 | ); |
| 446 | } |
| 447 | |
| 448 | #[test] |
| 449 | fn nonempty_context_projection_remains_inspectable_work() { |
| 450 | use crate::tools::subagent::CoordinationDetailProjection; |
| 451 | use crate::tools::subagent::coord::{ContextProjectionReceipt, CoordinationDetailMetrics}; |
| 452 | |
| 453 | let mut app = app(); |
| 454 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 455 | schema_version: 1, |
| 456 | sequence: 1, |
| 457 | decisions: Vec::new(), |
| 458 | write_claims: Vec::new(), |
| 459 | reconciliations: Vec::new(), |
| 460 | context_projections: vec![ContextProjectionReceipt { |
| 461 | child_id: "agent-a".to_string(), |
| 462 | decision_ids: vec!["decision-a".to_string()], |
| 463 | projected_bytes: 32, |
| 464 | deduplicated: 0, |
| 465 | omitted: 0, |
| 466 | sequence: 1, |
| 467 | }], |
| 468 | contentions: Vec::new(), |
| 469 | metrics: CoordinationDetailMetrics { |
| 470 | hottest_paths: Vec::new(), |
| 471 | package_or_module_growth: None, |
| 472 | route_or_cost: None, |
| 473 | note: String::new(), |
| 474 | }, |
| 475 | bounded: true, |
| 476 | limit: 24, |
| 477 | process_lock_held: true, |
| 478 | process_lock_note: None, |
| 479 | }); |
| 480 | |
| 481 | let rows = super::model::project(&mut app); |
| 482 | assert!( |
| 483 | rows.iter().any(|row| row.id.0 == "coordination"), |
| 484 | "non-empty context projection must remain inspectable: {rows:?}" |
| 485 | ); |
| 486 | } |
| 487 | |
| 488 | #[test] |
| 489 | fn current_blocked_contention_uses_attention_bucket_mark_and_tone() { |
| 490 | use crate::tools::subagent::CoordinationDetailProjection; |
| 491 | use crate::tools::subagent::coord::{ |
| 492 | CoordinationDetailMetrics, PersistedWriteClaim, WriteContentionDisposition, |
| 493 | WriteContentionReceipt, WriteScopeClaim, |
| 494 | }; |
| 495 | |
| 496 | let mut app = app(); |
| 497 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 498 | schema_version: 1, |
| 499 | sequence: 2, |
| 500 | decisions: Vec::new(), |
| 501 | write_claims: vec![PersistedWriteClaim { |
| 502 | claim: WriteScopeClaim { |
| 503 | owner: "worker-a".to_string(), |
| 504 | roots: vec!["crates/tui".to_string()], |
| 505 | exact_files: Vec::new(), |
| 506 | contracts: vec!["ui-contract".to_string()], |
| 507 | }, |
| 508 | sequence: 1, |
| 509 | isolated_worktree: false, |
| 510 | present_at_claim: Vec::new(), |
| 511 | }], |
| 512 | reconciliations: Vec::new(), |
| 513 | context_projections: Vec::new(), |
| 514 | contentions: vec![WriteContentionReceipt { |
| 515 | claimant: "worker-b".to_string(), |
| 516 | conflicting_owner: "worker-a".to_string(), |
| 517 | roots: vec!["crates/tui".to_string()], |
| 518 | exact_files: Vec::new(), |
| 519 | contracts: vec!["ui-contract".to_string()], |
| 520 | disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization, |
| 521 | resolution_sequence: None, |
| 522 | sequence: 2, |
| 523 | }], |
| 524 | metrics: CoordinationDetailMetrics { |
| 525 | hottest_paths: Vec::new(), |
| 526 | package_or_module_growth: None, |
| 527 | route_or_cost: None, |
| 528 | note: "No authoritative metric source".to_string(), |
| 529 | }, |
| 530 | bounded: true, |
| 531 | limit: 24, |
| 532 | process_lock_held: true, |
| 533 | process_lock_note: None, |
| 534 | }); |
| 535 | |
| 536 | let rows = super::model::project(&mut app); |
| 537 | assert_eq!( |
| 538 | rows[0].label, |
| 539 | "Work · Needs input: Coordination Work · 1 blocked" |
| 540 | ); |
| 541 | let row = rows |
| 542 | .iter() |
| 543 | .find(|row| row.id.0 == "coordination") |
| 544 | .expect("blocked coordination Work row"); |
| 545 | assert_eq!(row.mark, crate::tui::glyphs::ATTENTION); |
| 546 | assert_eq!(row.tone, super::model::WorkTone::Attention); |
| 547 | assert_eq!(row.detail, "0 decisions · 1 contentions · 0 reconciled"); |
| 548 | } |
| 549 | |
| 550 | #[test] |
| 551 | fn todos_share_one_canonical_work_projection_without_a_second_heading() { |
| 552 | let mut app = app(); |
| 553 | { |
| 554 | let mut todos = app.todos.try_lock().expect("todos"); |
| 555 | todos.add("finished".to_string(), TodoStatus::Completed); |
| 556 | todos.add("current".to_string(), TodoStatus::InProgress); |
| 557 | todos.add("next".to_string(), TodoStatus::Pending); |
| 558 | } |
| 559 | |
| 560 | let rows = super::model::project(&mut app); |
| 561 | |
| 562 | assert!( |
| 563 | rows[0].label.starts_with("Work · Running:") |
| 564 | || rows[0].label.starts_with("Work · Ready:"), |
| 565 | "expected actionable title heading, got {}", |
| 566 | rows[0].label |
| 567 | ); |
| 568 | assert_eq!( |
| 569 | rows.iter() |
| 570 | .skip(1) |
| 571 | .map(|row| row.label.as_str()) |
| 572 | .collect::<Vec<_>>(), |
| 573 | ["finished", "current", "next"] |
| 574 | ); |
| 575 | } |
| 576 | |
| 577 | #[test] |
| 578 | fn top_surface_pins_one_progress_receipt_and_numbers_canonical_rows() { |
| 579 | let mut app = app(); |
| 580 | { |
| 581 | let mut todos = app.todos.try_lock().expect("todos"); |
| 582 | todos.add("finished".to_string(), TodoStatus::Completed); |
| 583 | todos.add("current".to_string(), TodoStatus::InProgress); |
| 584 | todos.add("next".to_string(), TodoStatus::Pending); |
| 585 | } |
| 586 | |
| 587 | let text = render_text(&mut app, 80, 7); |
| 588 | let done = format!("1 · {} finished", crate::tui::glyphs::DONE); |
| 589 | let current = format!("2 · {} current", crate::tui::glyphs::SELECTION); |
| 590 | let next = format!("3 · {} next", crate::tui::glyphs::READY); |
| 591 | |
| 592 | assert!(text.contains("To-do · 1/3 · 2 left"), "{text:?}"); |
| 593 | assert_eq!(text.matches("To-do ·").count(), 1, "{text:?}"); |
| 594 | assert!(text.contains(&done), "{text:?}"); |
| 595 | assert!(text.contains(¤t), "{text:?}"); |
| 596 | assert!(text.contains(&next), "{text:?}"); |
| 597 | assert!( |
| 598 | text.find(&done) < text.find(¤t) && text.find(¤t) < text.find(&next), |
| 599 | "canonical order drifted: {text:?}" |
| 600 | ); |
| 601 | assert_eq!(app.work_surface.hitboxes.len(), 3); |
| 602 | assert_eq!(app.work_surface.hitboxes[0].row_y, 2); |
| 603 | } |
| 604 | |
| 605 | #[test] |
| 606 | fn top_strip_auto_fits_step_count_up_to_caps() { |
| 607 | // Two steps need four literal lines, but the readable surface floor |
| 608 | // wins so the same saved size can also seat goal + Agent state. |
| 609 | let mut two_steps = app(); |
| 610 | two_steps.work_surface.top_height = 8; |
| 611 | add_todos(&mut two_steps, 2); |
| 612 | let budget = working_budget(&two_steps, 40); |
| 613 | assert_eq!( |
| 614 | super::height(&mut two_steps, 100, 40, budget), |
| 615 | super::model::TOP_HEIGHT_MIN |
| 616 | ); |
| 617 | |
| 618 | // Ten steps: content wants 12 lines, the default 8-line cap wins. |
| 619 | let mut ten_steps = app(); |
| 620 | ten_steps.work_surface.top_height = 8; |
| 621 | add_todos(&mut ten_steps, 10); |
| 622 | let budget = working_budget(&ten_steps, 40); |
| 623 | assert_eq!(super::height(&mut ten_steps, 100, 40, budget), 8); |
| 624 | |
| 625 | // Short terminal: the transcript's spare rows beat both content and |
| 626 | // the configured cap. A 12-row terminal spends 1 on the header, 1 on |
| 627 | // the phase strip and 3 on the bordered composer, and owes the |
| 628 | // transcript its 3-row floor — so only 4 rows are spare. That is below |
| 629 | // the readable floor, so the whole rail yields rather than painting a |
| 630 | // divider over clipped work. |
| 631 | let mut short_terminal = app(); |
| 632 | short_terminal.work_surface.top_height = 8; |
| 633 | add_todos(&mut short_terminal, 10); |
| 634 | let budget = working_budget(&short_terminal, 12); |
| 635 | assert_eq!(super::height(&mut short_terminal, 100, 12, budget), 0); |
| 636 | |
| 637 | // Nothing to show: no strip at all. |
| 638 | let mut empty = app(); |
| 639 | empty.work_surface.top_height = 8; |
| 640 | assert_eq!(super::height(&mut empty, 100, 40, AMPLE_BUDGET), 0); |
| 641 | } |
| 642 | |
| 643 | /// A strip that reports zero rows is not on screen, so the interaction |
| 644 | /// state describing it must go with it. Stale hitboxes outlive the rows |
| 645 | /// they described: the transcript rows that replaced the strip would keep |
| 646 | /// routing clicks into a panel that is not there. |
| 647 | #[test] |
| 648 | fn a_yielded_strip_drops_its_interaction_state() { |
| 649 | // Each case is a distinct zero-return inside `height`, and every one |
| 650 | // of them has to tear down. `starve` turns a rendered strip into a |
| 651 | // yielded one; the assertions are identical either way. The first two |
| 652 | // are the returns this yield rule introduced — the ones that had no |
| 653 | // teardown at all. |
| 654 | type Starve = fn(&mut App) -> (u16, u16, u16); |
| 655 | let cases: [(&str, Starve); 3] = [ |
| 656 | ("budget starves the Tasks strip", |_app| (100, 40, 0)), |
| 657 | ("budget starves a switched-to panel", |app| { |
| 658 | app.work_surface.panel = super::RailPanel::Tasks; |
| 659 | (100, 40, 0) |
| 660 | }), |
| 661 | ("placement off", |app| { |
| 662 | app.work_surface.placement = WorkSurfacePlacement::Off; |
| 663 | (100, 40, AMPLE_BUDGET) |
| 664 | }), |
| 665 | ]; |
| 666 | |
| 667 | for (label, starve) in cases { |
| 668 | let mut app = app(); |
| 669 | app.work_surface.placement = WorkSurfacePlacement::Top; |
| 670 | // `app()` reads the developer's real settings.toml. Pin the height |
| 671 | // too, or the strip this test renders to earn its hitboxes depends |
| 672 | // on whoever runs the suite. |
| 673 | app.work_surface.top_height = 8; |
| 674 | add_todos(&mut app, 4); |
| 675 | |
| 676 | // Earn a real strip, so the hitboxes under test are the ones the |
| 677 | // renderer actually produces rather than a fixture's guess. |
| 678 | render_text(&mut app, 100, 12); |
| 679 | assert!( |
| 680 | !app.work_surface.hitboxes.is_empty(), |
| 681 | "{label}: setup never rendered a strip to tear down" |
| 682 | ); |
| 683 | app.work_surface.focused = true; |
| 684 | app.work_surface.resizing = true; |
| 685 | app.work_surface.divider_hovered = true; |
| 686 | |
| 687 | let (width, height, budget) = starve(&mut app); |
| 688 | assert_eq!( |
| 689 | super::height(&mut app, width, height, budget), |
| 690 | 0, |
| 691 | "{label}: expected the strip to yield" |
| 692 | ); |
| 693 | assert!( |
| 694 | app.work_surface.hitboxes.is_empty(), |
| 695 | "{label}: left {} stale hitboxes behind", |
| 696 | app.work_surface.hitboxes.len() |
| 697 | ); |
| 698 | assert!( |
| 699 | app.work_surface.last_area.is_none(), |
| 700 | "{label}: stale last_area" |
| 701 | ); |
| 702 | assert!(!app.work_surface.focused, "{label}: focus survived"); |
| 703 | assert!(!app.work_surface.resizing, "{label}: resize drag survived"); |
| 704 | assert!( |
| 705 | !app.work_surface.divider_hovered, |
| 706 | "{label}: divider hover survived" |
| 707 | ); |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | /// `top_height` is a ceiling, not a fixed size. The compact floor must |
| 712 | /// still seat the goal, work progress, and actionable rows; content longer |
| 713 | /// than the ceiling is clamped rather than padded with blank water. |
| 714 | #[test] |
| 715 | fn a_short_top_height_caps_content_rather_than_collapsing() { |
| 716 | let mut capped = app(); |
| 717 | capped.work_surface.placement = WorkSurfacePlacement::Top; |
| 718 | capped.work_surface.panel = super::RailPanel::Tasks; |
| 719 | capped.work_surface.top_height = super::model::TOP_HEIGHT_MIN; |
| 720 | capped.composer_border = true; |
| 721 | // Goal + several checklist rows: content wants more than the readable |
| 722 | // floor, so the cap wins without hiding every actionable row. |
| 723 | capped.goal.objective = Some("ship the release".to_string()); |
| 724 | add_todos(&mut capped, 6); |
| 725 | let budget = working_budget(&capped, 40); |
| 726 | assert_eq!( |
| 727 | super::height(&mut capped, 100, 40, budget), |
| 728 | super::model::TOP_HEIGHT_MIN, |
| 729 | "short top_height is a cap the strip must fit under, not a cliff" |
| 730 | ); |
| 731 | |
| 732 | // Content shorter than the cap shrinks to the readable floor rather |
| 733 | // than padding all the way out to the saved 8-row cap. |
| 734 | let mut short = app(); |
| 735 | short.work_surface.placement = WorkSurfacePlacement::Top; |
| 736 | short.work_surface.panel = super::RailPanel::Tasks; |
| 737 | short.work_surface.top_height = 8; |
| 738 | short.goal.objective = Some("one goal only".to_string()); |
| 739 | let budget = working_budget(&short, 40); |
| 740 | let h = super::height(&mut short, 100, 40, budget); |
| 741 | assert_eq!(h, super::model::TOP_HEIGHT_MIN); |
| 742 | } |
| 743 | |
| 744 | /// Non-Tasks Top panels auto-fit the same way Tasks always did: content |
| 745 | /// rows + divider, never a fixed four-row chrome band. An active goal |
| 746 | /// adds exactly one title row (not a panel name). |
| 747 | #[test] |
| 748 | fn top_panel_auto_fits_content_like_tasks() { |
| 749 | let mut pinned = app(); |
| 750 | pinned.work_surface.placement = WorkSurfacePlacement::Top; |
| 751 | pinned.work_surface.panel = super::RailPanel::Tasks; |
| 752 | pinned.work_surface.top_height = 12; |
| 753 | pinned.goal.objective = Some("goal".to_string()); |
| 754 | add_todos(&mut pinned, 3); |
| 755 | let budget = working_budget(&pinned, 40); |
| 756 | let h = super::height(&mut pinned, 100, 40, budget); |
| 757 | // goal title + 3 checklist + divider ≈ 5; must not be the old fixed 4, |
| 758 | // and must not pad out to the 12-row cap. |
| 759 | assert!( |
| 760 | (4..=8).contains(&h), |
| 761 | "Pinned should auto-fit checklist content, got {h}" |
| 762 | ); |
| 763 | |
| 764 | // Empty Pinned collapses entirely. |
| 765 | let mut empty = app(); |
| 766 | empty.work_surface.placement = WorkSurfacePlacement::Top; |
| 767 | empty.work_surface.panel = super::RailPanel::Tasks; |
| 768 | empty.work_surface.top_height = 12; |
| 769 | assert_eq!( |
| 770 | super::height(&mut empty, 100, 40, AMPLE_BUDGET), |
| 771 | 0, |
| 772 | "empty Pinned is not a panel" |
| 773 | ); |
| 774 | |
| 775 | // Empty Agents collapses too (no "No agents" chrome strip). |
| 776 | let mut agents = app(); |
| 777 | agents.work_surface.placement = WorkSurfacePlacement::Top; |
| 778 | agents.work_surface.panel = super::RailPanel::Agents; |
| 779 | agents.work_surface.top_height = 12; |
| 780 | assert_eq!( |
| 781 | super::height(&mut agents, 100, 40, AMPLE_BUDGET), |
| 782 | 0, |
| 783 | "empty Agents is not a panel" |
| 784 | ); |
| 785 | } |
| 786 | |
| 787 | /// A chosen panel remains usable when only one content row fits. |
| 788 | #[test] |
| 789 | fn compact_explicit_view_keeps_content_ahead_of_goal_chrome() { |
| 790 | let mut app = app(); |
| 791 | app.goal.objective = Some("ship the release".to_string()); |
| 792 | super::select_dock_panel(&mut app, super::RailPanel::Agents); |
| 793 | let text = render_text(&mut app, 40, 3); |
| 794 | assert!(text.contains("no agents have run this session"), "{text:?}"); |
| 795 | assert!(app.work_surface.focused); |
| 796 | assert!( |
| 797 | super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_some() |
| 798 | ); |
| 799 | assert!(!app.work_surface.explicit_view); |
| 800 | } |
| 801 | |
| 802 | /// Top titles only when a live goal is set — never the panel name. |
| 803 | #[test] |
| 804 | fn top_title_is_goal_only_never_panel_chrome() { |
| 805 | // With a goal: title is "Goal: …". |
| 806 | let mut with_goal = app(); |
| 807 | with_goal.work_surface.placement = WorkSurfacePlacement::Top; |
| 808 | with_goal.work_surface.panel = super::RailPanel::Tasks; |
| 809 | with_goal.work_surface.top_height = 8; |
| 810 | with_goal.goal.objective = Some("ship 0.9.4".to_string()); |
| 811 | let text = render_text(&mut with_goal, 80, 8); |
| 812 | assert!( |
| 813 | text.contains("Goal: ship 0.9.4"), |
| 814 | "active goal must be the Top title: {text:?}" |
| 815 | ); |
| 816 | assert!( |
| 817 | !render_rows(&mut with_goal, 80, 8) |
| 818 | .iter() |
| 819 | .skip(1) |
| 820 | .any(|row| row.contains("Pinned")), |
| 821 | "panel name is not a Top title: {text:?}" |
| 822 | ); |
| 823 | |
| 824 | // Without a goal, only checklist: no Goal title, no Pinned chrome. |
| 825 | let mut no_goal = app(); |
| 826 | no_goal.work_surface.placement = WorkSurfacePlacement::Top; |
| 827 | no_goal.work_surface.panel = super::RailPanel::Tasks; |
| 828 | no_goal.work_surface.top_height = 8; |
| 829 | add_todos(&mut no_goal, 2); |
| 830 | let text = render_text(&mut no_goal, 80, 6); |
| 831 | assert!( |
| 832 | !text.contains("Goal:"), |
| 833 | "no live goal → no Goal title: {text:?}" |
| 834 | ); |
| 835 | assert!( |
| 836 | !render_rows(&mut no_goal, 80, 6) |
| 837 | .iter() |
| 838 | .skip(1) |
| 839 | .any(|row| row.contains("Pinned")), |
| 840 | "panel name is never a Top title: {text:?}" |
| 841 | ); |
| 842 | } |
| 843 | |
| 844 | /// Tasks with only a goal (no todos/agents) still shows a strip. |
| 845 | #[test] |
| 846 | fn top_tasks_goal_alone_still_renders_a_strip() { |
| 847 | let mut app = app(); |
| 848 | app.work_surface.placement = WorkSurfacePlacement::Top; |
| 849 | app.work_surface.panel = super::RailPanel::Tasks; |
| 850 | app.work_surface.top_height = 8; |
| 851 | app.goal.objective = Some("only a goal".to_string()); |
| 852 | let budget = working_budget(&app, 40); |
| 853 | let h = super::height(&mut app, 100, 40, budget); |
| 854 | assert!(h >= 2, "goal alone must reserve title + divider, got {h}"); |
| 855 | let text = render_text(&mut app, 80, h); |
| 856 | assert!( |
| 857 | text.contains("Goal: only a goal"), |
| 858 | "goal-alone strip must paint the title: {text:?}" |
| 859 | ); |
| 860 | } |
| 861 | |
| 862 | /// Side rails share the empty-collapse rule: no content → no column. |
| 863 | /// Width stays the configured ceiling when content exists. |
| 864 | #[test] |
| 865 | fn side_rail_collapses_when_empty_and_reserves_when_contentful() { |
| 866 | let area = ratatui::layout::Rect::new(0, 0, 120, 32); |
| 867 | |
| 868 | // Empty Pinned: no side column. |
| 869 | let mut empty = app(); |
| 870 | empty.work_surface.placement = WorkSurfacePlacement::Right; |
| 871 | empty.work_surface.panel = super::RailPanel::Tasks; |
| 872 | empty.work_surface.side_width = 30; |
| 873 | assert_eq!( |
| 874 | super::split_chat(&mut empty, area, 0), |
| 875 | (area, None), |
| 876 | "empty Pinned must not reserve a side column" |
| 877 | ); |
| 878 | |
| 879 | // Contentful Pinned: full-height column at configured width. |
| 880 | let mut full = app(); |
| 881 | full.work_surface.placement = WorkSurfacePlacement::Right; |
| 882 | full.work_surface.panel = super::RailPanel::Tasks; |
| 883 | full.work_surface.side_width = 30; |
| 884 | full.goal.objective = Some("ship it".to_string()); |
| 885 | let (chat, rail) = super::split_chat(&mut full, area, 0); |
| 886 | let rail = rail.expect("contentful Pinned reserves a side rail"); |
| 887 | assert_eq!(rail.width, 30); |
| 888 | assert_eq!(chat.width, area.width - 30); |
| 889 | assert_eq!(rail.height, area.height); |
| 890 | } |
| 891 | |
| 892 | #[test] |
| 893 | fn minimum_top_surface_keeps_a_numbered_todo_selectable() { |
| 894 | let mut app = app(); |
| 895 | add_todos(&mut app, 2); |
| 896 | |
| 897 | let text = render_text(&mut app, 40, 5); |
| 898 | |
| 899 | assert!(text.contains("1 ·"), "{text:?}"); |
| 900 | assert!(!app.work_surface.hitboxes.is_empty()); |
| 901 | assert_eq!(app.work_surface.hitboxes[0].row_y, 2); |
| 902 | } |
| 903 | |
| 904 | #[test] |
| 905 | fn compact_progress_window_reveals_current_without_reordering() { |
| 906 | let mut app = app(); |
| 907 | { |
| 908 | let mut todos = app.todos.try_lock().expect("todos"); |
| 909 | todos.add("finished".to_string(), TodoStatus::Completed); |
| 910 | todos.add("current".to_string(), TodoStatus::InProgress); |
| 911 | todos.add("next".to_string(), TodoStatus::Pending); |
| 912 | } |
| 913 | |
| 914 | // The current item must win the compact window while retaining its |
| 915 | // canonical ordinal. |
| 916 | let text = render_text(&mut app, 80, 6); |
| 917 | |
| 918 | assert!(text.contains("To-do · 1/3 · 2 left"), "{text:?}"); |
| 919 | assert!( |
| 920 | text.contains(&format!("2 · {} current", crate::tui::glyphs::SELECTION)), |
| 921 | "{text:?}" |
| 922 | ); |
| 923 | assert_eq!(app.work_surface.hitboxes[0].row_y, 2); |
| 924 | } |
| 925 | |
| 926 | #[test] |
| 927 | fn settled_file_tools_aggregate_once_and_keep_only_safe_targets() { |
| 928 | let mut app = app(); |
| 929 | app.current_session_id = Some(SESSION.to_string()); |
| 930 | app.workspace = PathBuf::from("/workspace/project"); |
| 931 | for (id, name, input, status) in [ |
| 932 | ( |
| 933 | "read-1", |
| 934 | "read_file", |
| 935 | serde_json::json!({"path": "/workspace/project/src/lib.rs"}), |
| 936 | ToolStatus::Success, |
| 937 | ), |
| 938 | ( |
| 939 | "search-1", |
| 940 | "grep_files", |
| 941 | serde_json::json!({"pattern": "WorkSurfaceState"}), |
| 942 | ToolStatus::Success, |
| 943 | ), |
| 944 | ( |
| 945 | "write-1", |
| 946 | "edit_file", |
| 947 | serde_json::json!({"path": "src/lib.rs"}), |
| 948 | ToolStatus::Success, |
| 949 | ), |
| 950 | ( |
| 951 | "read-external", |
| 952 | "read_file", |
| 953 | serde_json::json!({"path": "/Users/alice/private.txt"}), |
| 954 | ToolStatus::Failed, |
| 955 | ), |
| 956 | ] { |
| 957 | app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 958 | name: name.to_string(), |
| 959 | status, |
| 960 | input_summary: None, |
| 961 | output: Some("done".to_string()), |
| 962 | prompts: None, |
| 963 | spillover_path: None, |
| 964 | output_summary: None, |
| 965 | is_diff: false, |
| 966 | }))); |
| 967 | let index = app.history.len() - 1; |
| 968 | app.tool_details_by_cell.insert( |
| 969 | index, |
| 970 | ToolDetailRecord { |
| 971 | tool_id: id.to_string(), |
| 972 | tool_name: name.to_string(), |
| 973 | input, |
| 974 | output: Some("done".to_string()), |
| 975 | }, |
| 976 | ); |
| 977 | } |
| 978 | |
| 979 | let rows = super::model::project(&mut app); |
| 980 | let activity = rows |
| 981 | .iter() |
| 982 | .find(|row| row.id.0 == "activity:aggregate") |
| 983 | .expect("aggregated activity row"); |
| 984 | assert!( |
| 985 | activity.label.contains("Read 1 files") |
| 986 | && activity.label.contains("Searched 1 patterns") |
| 987 | && activity.label.contains("Wrote 1 files"), |
| 988 | "aggregated label: {}", |
| 989 | activity.label |
| 990 | ); |
| 991 | assert!(!activity.detail.contains("/Users/alice")); |
| 992 | assert!(!activity.label.contains("WorkSurfaceState")); |
| 993 | } |
| 994 | |
| 995 | #[test] |
| 996 | fn agent_rows_show_role_assignment_and_open_the_agent_transcript() { |
| 997 | let mut app = app(); |
| 998 | app.current_session_id = Some(SESSION.to_string()); |
| 999 | app.subagent_cache.push(SubAgentResult { |
| 1000 | usage: None, |
| 1001 | name: "agent_worker".to_string(), |
| 1002 | agent_id: "agent_worker".to_string(), |
| 1003 | context_mode: "fresh".to_string(), |
| 1004 | fork_context: false, |
| 1005 | workspace: None, |
| 1006 | git_branch: None, |
| 1007 | agent_type: FleetRole::Builder, |
| 1008 | assignment: SubAgentAssignment { |
| 1009 | objective: "Wire settled file activity".to_string(), |
| 1010 | role: Some("general".to_string()), |
| 1011 | }, |
| 1012 | model: "test-model".to_string(), |
| 1013 | nickname: Some("Blue Whale".to_string()), |
| 1014 | status: SubAgentStatus::Running, |
| 1015 | worker_status: Some(AgentWorkerStatus::RunningTool), |
| 1016 | runtime_permissions: None, |
| 1017 | parent_run_id: None, |
| 1018 | spawn_depth: 1, |
| 1019 | child_route: None, |
| 1020 | result: None, |
| 1021 | steps_taken: 2, |
| 1022 | checkpoint: None, |
| 1023 | needs_input: None, |
| 1024 | duration_ms: 50, |
| 1025 | started_at: None, |
| 1026 | from_prior_session: false, |
| 1027 | }); |
| 1028 | app.agent_progress_meta.insert( |
| 1029 | "agent_worker".to_string(), |
| 1030 | crate::tui::app::AgentProgressMeta { |
| 1031 | current_activity: Some(AgentCurrentActivity::bounded( |
| 1032 | AgentCurrentActivityStatus::RunningTool, |
| 1033 | None, |
| 1034 | Some("File.apply_patch".to_string()), |
| 1035 | Some(2), |
| 1036 | )), |
| 1037 | current_tool: Some("apply_patch".to_string()), |
| 1038 | files_touched: 2, |
| 1039 | ..crate::tui::app::AgentProgressMeta::default() |
| 1040 | }, |
| 1041 | ); |
| 1042 | |
| 1043 | let rows = super::model::project(&mut app); |
| 1044 | let row = rows |
| 1045 | .iter() |
| 1046 | .find(|row| row.id.0 == "worker:agent_worker") |
| 1047 | .expect("agent work row"); |
| 1048 | // The identity column leads with the agent's nickname and keeps the |
| 1049 | // fleet role as the fallback spelling. It is never the raw agent id |
| 1050 | // (#36), and carries no `(+N)` while the agent is childless. |
| 1051 | assert_eq!(row.label, "Blue Whale"); |
| 1052 | let facts = row.agent.as_ref().expect("agent row facts"); |
| 1053 | assert_eq!(facts.role_label, "general"); |
| 1054 | assert_eq!(facts.objective, "Wire settled file activity"); |
| 1055 | assert_eq!(facts.elapsed_secs, Some(0)); |
| 1056 | // No usage envelope has been seen, so there is no token figure at all. |
| 1057 | assert_eq!(facts.tokens, None); |
| 1058 | assert!(row.detail.contains("Wire settled file activity")); |
| 1059 | assert!(row.detail.contains("using File.apply_patch")); |
| 1060 | assert!(row.detail.contains("step 2")); |
| 1061 | assert!(row.detail.contains("2 files changed")); |
| 1062 | // One agent, one destination (v0.9.7): activation opens the agent's |
| 1063 | // transcript directly; Agent Details is the secondary action. |
| 1064 | assert_eq!( |
| 1065 | row.primary_action, |
| 1066 | Some(SidebarRowAction::OpenAgentTranscript { |
| 1067 | agent_id: "agent_worker".to_string(), |
| 1068 | }) |
| 1069 | ); |
| 1070 | } |
| 1071 | |
| 1072 | fn cached_worker( |
| 1073 | id: &str, |
| 1074 | role: &str, |
| 1075 | nickname: Option<&str>, |
| 1076 | parent_run_id: Option<&str>, |
| 1077 | status: SubAgentStatus, |
| 1078 | ) -> SubAgentResult { |
| 1079 | SubAgentResult { |
| 1080 | // `name` is the raw session id in production snapshots — the |
| 1081 | // strip must never render it (#36). |
| 1082 | usage: None, |
| 1083 | name: id.to_string(), |
| 1084 | agent_id: id.to_string(), |
| 1085 | context_mode: "fresh".to_string(), |
| 1086 | fork_context: false, |
| 1087 | workspace: None, |
| 1088 | git_branch: None, |
| 1089 | agent_type: FleetRole::Builder, |
| 1090 | assignment: SubAgentAssignment { |
| 1091 | objective: format!("objective for {id}"), |
| 1092 | role: Some(role.to_string()), |
| 1093 | }, |
| 1094 | model: "test-model".to_string(), |
| 1095 | nickname: nickname.map(str::to_string), |
| 1096 | status, |
| 1097 | worker_status: None, |
| 1098 | runtime_permissions: None, |
| 1099 | parent_run_id: parent_run_id.map(str::to_string), |
| 1100 | spawn_depth: u32::from(parent_run_id.is_some()) + 1, |
| 1101 | child_route: None, |
| 1102 | result: None, |
| 1103 | steps_taken: 1, |
| 1104 | checkpoint: None, |
| 1105 | needs_input: None, |
| 1106 | duration_ms: 50, |
| 1107 | started_at: None, |
| 1108 | from_prior_session: false, |
| 1109 | } |
| 1110 | } |
| 1111 | |
| 1112 | #[test] |
| 1113 | fn agent_rows_identify_by_fleet_role_and_never_leak_raw_ids() { |
| 1114 | // #36: the strip identifies an agent by its fleet role; the raw agent |
| 1115 | // id hash is noise and must never render as the "name". Flat fan-outs |
| 1116 | // carry no nesting chrome. |
| 1117 | let mut app = app(); |
| 1118 | app.current_session_id = Some(SESSION.to_string()); |
| 1119 | app.subagent_cache.push(cached_worker( |
| 1120 | "agent_e0b2dcf1", |
| 1121 | "builder", |
| 1122 | None, |
| 1123 | None, |
| 1124 | SubAgentStatus::Running, |
| 1125 | )); |
| 1126 | app.subagent_cache.push(cached_worker( |
| 1127 | "agent_99aa77bb", |
| 1128 | "scout", |
| 1129 | None, |
| 1130 | None, |
| 1131 | SubAgentStatus::Running, |
| 1132 | )); |
| 1133 | |
| 1134 | let rows = super::model::project(&mut app); |
| 1135 | let first = rows |
| 1136 | .iter() |
| 1137 | .find(|row| row.id.0 == "worker:agent_e0b2dcf1") |
| 1138 | .expect("first agent row"); |
| 1139 | let second = rows |
| 1140 | .iter() |
| 1141 | .find(|row| row.id.0 == "worker:agent_99aa77bb") |
| 1142 | .expect("second agent row"); |
| 1143 | assert_eq!(first.label, "builder"); |
| 1144 | assert_eq!(second.label, "scout"); |
| 1145 | assert!(first.detail.starts_with("running"), "{}", first.detail); |
| 1146 | for row in rows.iter().filter(|row| row.id.0.starts_with("worker:")) { |
| 1147 | assert!(!row.label.contains("agent_e0b2dcf1"), "{}", row.label); |
| 1148 | assert!(!row.label.contains("agent_99aa77bb"), "{}", row.label); |
| 1149 | assert!( |
| 1150 | !row.label.contains('↳'), |
| 1151 | "flat fan-out must not show nesting chrome: {}", |
| 1152 | row.label |
| 1153 | ); |
| 1154 | } |
| 1155 | } |
| 1156 | |
| 1157 | #[test] |
| 1158 | fn agent_rows_order_and_indent_nested_spawns_under_their_parent() { |
| 1159 | // #36: nesting is visible only when actually present — the child |
| 1160 | // renders directly under its parent with a `↳` indent, and the parent |
| 1161 | // advertises the child it spawned as `(+1)`. |
| 1162 | let mut app = app(); |
| 1163 | app.current_session_id = Some(SESSION.to_string()); |
| 1164 | app.subagent_cache.push(cached_worker( |
| 1165 | "agent_child", |
| 1166 | "scout", |
| 1167 | None, |
| 1168 | Some("agent_parent"), |
| 1169 | SubAgentStatus::Running, |
| 1170 | )); |
| 1171 | app.subagent_cache.push(cached_worker( |
| 1172 | "agent_parent", |
| 1173 | "builder", |
| 1174 | None, |
| 1175 | None, |
| 1176 | SubAgentStatus::Running, |
| 1177 | )); |
| 1178 | |
| 1179 | let rows = super::model::project(&mut app); |
| 1180 | let worker_labels = rows |
| 1181 | .iter() |
| 1182 | .filter(|row| row.id.0.starts_with("worker:")) |
| 1183 | .map(|row| row.label.as_str()) |
| 1184 | .collect::<Vec<_>>(); |
| 1185 | let parent_pos = worker_labels |
| 1186 | .iter() |
| 1187 | .position(|label| *label == "builder (+1)") |
| 1188 | .expect("parent row label with child count"); |
| 1189 | let child_pos = worker_labels |
| 1190 | .iter() |
| 1191 | .position(|label| *label == "↳ scout") |
| 1192 | .expect("indented child row label"); |
| 1193 | assert!( |
| 1194 | child_pos == parent_pos + 1, |
| 1195 | "child must render directly under its parent: {worker_labels:?}" |
| 1196 | ); |
| 1197 | } |
| 1198 | |
| 1199 | #[test] |
| 1200 | fn agent_rows_completed_agents_render_quietly_without_spawn_metadata() { |
| 1201 | // #36: quiet completion — a finished agent keeps status + objective; |
| 1202 | // in-flight metadata (tool, step counters, file tallies) must not |
| 1203 | // linger as a receipt dump. |
| 1204 | let mut app = app(); |
| 1205 | app.current_session_id = Some(SESSION.to_string()); |
| 1206 | app.subagent_cache.push(cached_worker( |
| 1207 | "agent_done", |
| 1208 | "builder", |
| 1209 | None, |
| 1210 | None, |
| 1211 | SubAgentStatus::Completed, |
| 1212 | )); |
| 1213 | app.agent_progress_meta.insert( |
| 1214 | "agent_done".to_string(), |
| 1215 | crate::tui::app::AgentProgressMeta { |
| 1216 | current_activity: Some(AgentCurrentActivity::bounded( |
| 1217 | AgentCurrentActivityStatus::Done, |
| 1218 | Some("apply_patch finished".to_string()), |
| 1219 | Some("File.apply_patch".to_string()), |
| 1220 | Some(7), |
| 1221 | )), |
| 1222 | current_tool: Some("apply_patch".to_string()), |
| 1223 | files_touched: 4, |
| 1224 | ..crate::tui::app::AgentProgressMeta::default() |
| 1225 | }, |
| 1226 | ); |
| 1227 | |
| 1228 | let rows = super::model::project(&mut app); |
| 1229 | let row = rows |
| 1230 | .iter() |
| 1231 | .find(|row| row.id.0 == "worker:agent_done") |
| 1232 | .expect("completed agent row"); |
| 1233 | assert!(row.detail.contains("completed"), "{}", row.detail); |
| 1234 | assert!( |
| 1235 | row.detail.contains("objective for agent_done"), |
| 1236 | "{}", |
| 1237 | row.detail |
| 1238 | ); |
| 1239 | assert!(!row.detail.contains("using "), "{}", row.detail); |
| 1240 | assert!(!row.detail.contains("step 7"), "{}", row.detail); |
| 1241 | assert!(!row.detail.contains("files changed"), "{}", row.detail); |
| 1242 | } |
| 1243 | |
| 1244 | // ---- Fleet row layout ------------------------------------------------- |
| 1245 | |
| 1246 | /// Painted lines, one per terminal row, trailing padding removed. |
| 1247 | fn render_rows(app: &mut App, width: u16, height: u16) -> Vec<String> { |
| 1248 | let backend = TestBackend::new(width, height); |
| 1249 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 1250 | terminal |
| 1251 | .draw(|frame| super::render(frame, frame.area(), app)) |
| 1252 | .expect("draw"); |
| 1253 | let buffer = terminal.backend().buffer().clone(); |
| 1254 | (0..height) |
| 1255 | .map(|y| { |
| 1256 | (0..width) |
| 1257 | .map(|x| buffer[(x, y)].symbol()) |
| 1258 | .collect::<String>() |
| 1259 | .trim_end() |
| 1260 | .to_string() |
| 1261 | }) |
| 1262 | .collect() |
| 1263 | } |
| 1264 | |
| 1265 | fn fleet_row(rows: &[String]) -> String { |
| 1266 | rows.iter() |
| 1267 | .find(|line| line.contains("Streaming")) |
| 1268 | .cloned() |
| 1269 | .unwrap_or_else(|| panic!("no fleet row in {rows:?}")) |
| 1270 | } |
| 1271 | |
| 1272 | fn fleet_worker( |
| 1273 | id: &str, |
| 1274 | role: &str, |
| 1275 | objective: &str, |
| 1276 | duration_ms: u64, |
| 1277 | status: SubAgentStatus, |
| 1278 | ) -> SubAgentResult { |
| 1279 | let mut agent = cached_worker(id, role, None, None, status); |
| 1280 | agent.assignment.objective = objective.to_string(); |
| 1281 | agent.duration_ms = duration_ms; |
| 1282 | agent |
| 1283 | } |
| 1284 | |
| 1285 | /// Seed a live fleet of one, with a reported token spend. |
| 1286 | fn fleet_app(tokens: Option<u64>) -> App { |
| 1287 | let mut app = app(); |
| 1288 | app.current_session_id = Some(SESSION.to_string()); |
| 1289 | app.subagent_cache.push(fleet_worker( |
| 1290 | "agent_stream", |
| 1291 | "general-purpose", |
| 1292 | "Streaming dead-code removal", |
| 1293 | 753_000, |
| 1294 | SubAgentStatus::Running, |
| 1295 | )); |
| 1296 | app.agent_progress_meta.insert( |
| 1297 | "agent_stream".to_string(), |
| 1298 | crate::tui::app::AgentProgressMeta { |
| 1299 | received_tokens: tokens, |
| 1300 | ..crate::tui::app::AgentProgressMeta::default() |
| 1301 | }, |
| 1302 | ); |
| 1303 | app |
| 1304 | } |
| 1305 | |
| 1306 | #[test] |
| 1307 | fn fleet_row_lays_out_type_objective_and_a_right_aligned_receipt() { |
| 1308 | let mut app = fleet_app(Some(111_900)); |
| 1309 | let rows = render_rows(&mut app, 100, 4); |
| 1310 | |
| 1311 | assert_eq!( |
| 1312 | fleet_row(&rows), |
| 1313 | " ▸ general-purpose running Streaming dead-code removal \ |
| 1314 | 12m 33s · ↓ 111.9k tokens" |
| 1315 | ); |
| 1316 | // The group header the strip already had stays put. |
| 1317 | assert!( |
| 1318 | rows.iter().any(|line| line.contains("Subagents 1")), |
| 1319 | "{rows:?}" |
| 1320 | ); |
| 1321 | } |
| 1322 | |
| 1323 | #[test] |
| 1324 | fn focused_worker_row_carries_the_left_marker_and_queued_follow_ups() { |
| 1325 | let mut app = fleet_app(Some(111_900)); |
| 1326 | // No focus, nothing queued: the row is exactly as before. |
| 1327 | let plain = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1328 | assert!(!plain.starts_with("❯"), "{plain}"); |
| 1329 | assert!(!plain.contains("queued"), "{plain}"); |
| 1330 | |
| 1331 | crate::tui::agent_focus::focus_agent(&mut app, "agent_stream"); |
| 1332 | app.agent_queued_follow_ups |
| 1333 | .insert("agent_stream".to_string(), 1); |
| 1334 | let focused = fleet_row(&render_rows(&mut app, 110, 4)); |
| 1335 | assert!( |
| 1336 | focused.trim_start().starts_with("❯ ▸ general-purpose"), |
| 1337 | "left-edge marker names the addressed fork: {focused}" |
| 1338 | ); |
| 1339 | assert!(focused.ends_with("· 1 queued"), "{focused}"); |
| 1340 | |
| 1341 | // The counter is the runtime's truth: once the child takes the input |
| 1342 | // the next AgentList refresh clears it and the suffix disappears. |
| 1343 | app.agent_queued_follow_ups.clear(); |
| 1344 | let drained = fleet_row(&render_rows(&mut app, 110, 4)); |
| 1345 | assert!(!drained.contains("queued"), "{drained}"); |
| 1346 | // Leaving focus removes the gutter again. |
| 1347 | crate::tui::agent_focus::exit_focus(&mut app); |
| 1348 | let back = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1349 | assert_eq!(back, plain); |
| 1350 | } |
| 1351 | |
| 1352 | #[test] |
| 1353 | fn fleet_row_repaints_resolved_model_and_each_distinct_usage_total() { |
| 1354 | let mut app = fleet_app(None); |
| 1355 | crate::tui::ui::record_agent_spawned_route(&mut app, "agent_stream", "deepseek-v4-pro"); |
| 1356 | let launched = fleet_row(&render_rows(&mut app, 120, 4)); |
| 1357 | assert!(launched.contains("deepseek-v4-pro"), "{launched}"); |
| 1358 | assert!(!launched.contains("tokens"), "{launched}"); |
| 1359 | |
| 1360 | let route = crate::cost_status::EffectiveRouteEnvelope::capture( |
| 1361 | None, |
| 1362 | ApiProvider::Deepseek, |
| 1363 | ApiProvider::Deepseek.as_str(), |
| 1364 | "deepseek-v4-pro", |
| 1365 | Some(ApiProvider::Deepseek.default_base_url()), |
| 1366 | chrono::Utc::now(), |
| 1367 | ); |
| 1368 | let usage = |source_id: &str, input_tokens, output_tokens| MailboxMessage::TokenUsage { |
| 1369 | agent_id: "agent_stream".to_string(), |
| 1370 | source_id: source_id.to_string(), |
| 1371 | route: Box::new(route.clone()), |
| 1372 | usage: codewhale_models::Usage { |
| 1373 | input_tokens, |
| 1374 | output_tokens, |
| 1375 | ..Default::default() |
| 1376 | }, |
| 1377 | }; |
| 1378 | |
| 1379 | crate::tui::subagent_routing::handle_subagent_mailbox( |
| 1380 | &mut app, |
| 1381 | 99, |
| 1382 | &usage("response-1", 10_000, 1_000), |
| 1383 | ); |
| 1384 | let first = fleet_row(&render_rows(&mut app, 120, 4)); |
| 1385 | assert!(first.contains("deepseek-v4-pro"), "{first}"); |
| 1386 | assert!(first.contains("11.0k tokens"), "{first}"); |
| 1387 | |
| 1388 | // Replaying the same mailbox envelope must not inflate the receipt. |
| 1389 | crate::tui::subagent_routing::handle_subagent_mailbox( |
| 1390 | &mut app, |
| 1391 | 1, |
| 1392 | &usage("response-1", 10_000, 1_000), |
| 1393 | ); |
| 1394 | let replay = fleet_row(&render_rows(&mut app, 120, 4)); |
| 1395 | assert!(replay.contains("11.0k tokens"), "{replay}"); |
| 1396 | |
| 1397 | crate::tui::subagent_routing::handle_subagent_mailbox( |
| 1398 | &mut app, |
| 1399 | 2, |
| 1400 | &usage("response-2", 20_000, 2_000), |
| 1401 | ); |
| 1402 | let second = fleet_row(&render_rows(&mut app, 120, 4)); |
| 1403 | assert!(second.contains("deepseek-v4-pro"), "{second}"); |
| 1404 | assert!(second.contains("33.0k tokens"), "{second}"); |
| 1405 | } |
| 1406 | |
| 1407 | #[test] |
| 1408 | fn fleet_row_shows_remaining_todos_only_when_the_ledger_has_unsettled_work() { |
| 1409 | let mut app = fleet_app(Some(1_200)); |
| 1410 | app.agent_progress_meta |
| 1411 | .get_mut("agent_stream") |
| 1412 | .expect("meta") |
| 1413 | .todos_remaining = Some(3); |
| 1414 | |
| 1415 | let with_left = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1416 | assert!( |
| 1417 | with_left.contains("3 left"), |
| 1418 | "unsettled ledger must surface on the receipt: {with_left}" |
| 1419 | ); |
| 1420 | assert!( |
| 1421 | with_left.contains("↓") && with_left.contains("tokens"), |
| 1422 | "tokens stay alongside the remaining chip: {with_left}" |
| 1423 | ); |
| 1424 | |
| 1425 | // Fully settled list → quiet (no fabricated zero chip). |
| 1426 | app.agent_progress_meta |
| 1427 | .get_mut("agent_stream") |
| 1428 | .expect("meta") |
| 1429 | .todos_remaining = Some(0); |
| 1430 | let settled = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1431 | assert!( |
| 1432 | !settled.contains("left"), |
| 1433 | "zero remaining must not paint a chip: {settled}" |
| 1434 | ); |
| 1435 | |
| 1436 | // No ledger published → quiet. |
| 1437 | app.agent_progress_meta |
| 1438 | .get_mut("agent_stream") |
| 1439 | .expect("meta") |
| 1440 | .todos_remaining = None; |
| 1441 | let absent = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1442 | assert!( |
| 1443 | !absent.contains("left"), |
| 1444 | "missing ledger must not invent a chip: {absent}" |
| 1445 | ); |
| 1446 | } |
| 1447 | |
| 1448 | #[test] |
| 1449 | fn fleet_identity_prefers_the_nickname_and_falls_back_to_the_role() { |
| 1450 | // Nicknames are CodeWhale identity, so they lead. An agent that has |
| 1451 | // none falls back to its fleet role rather than showing a blank or a |
| 1452 | // fabricated name. |
| 1453 | let mut app = app(); |
| 1454 | app.current_session_id = Some(SESSION.to_string()); |
| 1455 | let mut named = fleet_worker( |
| 1456 | "agent_named", |
| 1457 | "general-purpose", |
| 1458 | "Streaming dead-code removal", |
| 1459 | 753_000, |
| 1460 | SubAgentStatus::Running, |
| 1461 | ); |
| 1462 | named.nickname = Some("Fluke".to_string()); |
| 1463 | app.subagent_cache.push(named); |
| 1464 | app.subagent_cache.push(fleet_worker( |
| 1465 | "agent_plain", |
| 1466 | "general-purpose", |
| 1467 | "Ambient visual calm-down", |
| 1468 | 741_000, |
| 1469 | SubAgentStatus::Running, |
| 1470 | )); |
| 1471 | |
| 1472 | let rows = super::model::project(&mut app); |
| 1473 | let row = |id: &str| { |
| 1474 | rows.iter() |
| 1475 | .find(|row| row.id.0 == format!("worker:{id}")) |
| 1476 | .unwrap_or_else(|| panic!("row for {id}")) |
| 1477 | }; |
| 1478 | assert_eq!(row("agent_named").label, "Fluke"); |
| 1479 | assert_eq!( |
| 1480 | row("agent_named").agent.as_ref().expect("facts").role_label, |
| 1481 | "general-purpose" |
| 1482 | ); |
| 1483 | // No nickname: the identity and its fallback are the same string. |
| 1484 | assert_eq!(row("agent_plain").label, "general-purpose"); |
| 1485 | |
| 1486 | // Both spellings share one column, so the objectives stay aligned. |
| 1487 | let painted = render_rows(&mut app, 100, 5); |
| 1488 | let named_line = painted |
| 1489 | .iter() |
| 1490 | .find(|line| line.contains("Fluke")) |
| 1491 | .expect("nicknamed row"); |
| 1492 | let plain_line = painted |
| 1493 | .iter() |
| 1494 | .find(|line| line.contains("general-purpose")) |
| 1495 | .expect("un-nicknamed row"); |
| 1496 | assert_eq!( |
| 1497 | named_line.find("Streaming"), |
| 1498 | plain_line.find("Ambient"), |
| 1499 | "objectives must share a column:\n{named_line}\n{plain_line}" |
| 1500 | ); |
| 1501 | } |
| 1502 | |
| 1503 | #[test] |
| 1504 | fn an_identity_too_wide_for_the_column_falls_back_without_widening_it() { |
| 1505 | // The identity column is shared, so one outlier must not starve every |
| 1506 | // other objective — and a name is shown whole or not at all. |
| 1507 | let mut app = app(); |
| 1508 | app.current_session_id = Some(SESSION.to_string()); |
| 1509 | let mut long = fleet_worker( |
| 1510 | "agent_long", |
| 1511 | "general-purpose", |
| 1512 | "Streaming dead-code removal", |
| 1513 | 753_000, |
| 1514 | SubAgentStatus::Running, |
| 1515 | ); |
| 1516 | long.nickname = Some("Bartholomew the Extremely Long-Winded Humpback".to_string()); |
| 1517 | app.subagent_cache.push(long); |
| 1518 | app.subagent_cache.push(fleet_worker( |
| 1519 | "agent_plain", |
| 1520 | "scout", |
| 1521 | "Ambient visual calm-down", |
| 1522 | 741_000, |
| 1523 | SubAgentStatus::Running, |
| 1524 | )); |
| 1525 | |
| 1526 | let painted = render_rows(&mut app, 100, 5); |
| 1527 | let joined = painted.join("\n"); |
| 1528 | // The oversized nickname never renders, whole or truncated. |
| 1529 | assert!(!joined.contains("Bartholomew"), "{joined}"); |
| 1530 | assert!(!joined.contains("Bartholom"), "{joined}"); |
| 1531 | // It falls back to its role, and the other row is untouched. |
| 1532 | assert!(joined.contains("general-purpose"), "{joined}"); |
| 1533 | assert!(joined.contains("scout"), "{joined}"); |
| 1534 | // Neither objective was starved by the outlier. |
| 1535 | assert!(joined.contains("Streaming dead-code removal"), "{joined}"); |
| 1536 | assert!(joined.contains("Ambient visual calm-down"), "{joined}"); |
| 1537 | } |
| 1538 | |
| 1539 | #[test] |
| 1540 | fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() { |
| 1541 | // Settled degradation order: tokens first, then elapsed, then the |
| 1542 | // type and status columns together. The objective is the last thing |
| 1543 | // to go and every column truncates rather than wrapping. The status |
| 1544 | // word outlives the whole receipt — a fleet row that cannot say its |
| 1545 | // state in words has lost the fact the strip exists to show. |
| 1546 | let mut app = fleet_app(Some(111_900)); |
| 1547 | let medium = fleet_row(&render_rows(&mut app, 72, 4)); |
| 1548 | assert!(medium.contains("12m 33s"), "{medium}"); |
| 1549 | assert!(!medium.contains("tokens"), "{medium}"); |
| 1550 | assert!(medium.contains("general-purpose"), "{medium}"); |
| 1551 | assert!(medium.contains("running"), "{medium}"); |
| 1552 | |
| 1553 | let narrow = fleet_row(&render_rows(&mut app, 56, 4)); |
| 1554 | assert!(!narrow.contains("tokens"), "{narrow}"); |
| 1555 | assert!(!narrow.contains("12m 33s"), "{narrow}"); |
| 1556 | assert!(narrow.contains("general-purpose"), "{narrow}"); |
| 1557 | assert!(narrow.contains("running"), "{narrow}"); |
| 1558 | |
| 1559 | let tight = fleet_row(&render_rows(&mut app, 28, 4)); |
| 1560 | assert!(!tight.contains("general-purpose"), "{tight}"); |
| 1561 | assert!(!tight.contains("running"), "{tight}"); |
| 1562 | assert!(tight.contains("Streaming"), "{tight}"); |
| 1563 | |
| 1564 | for line in [&medium, &narrow, &tight] { |
| 1565 | assert!(line.chars().all(|ch| ch != '\n'), "{line}"); |
| 1566 | } |
| 1567 | } |
| 1568 | |
| 1569 | #[test] |
| 1570 | fn fleet_row_elapsed_freezes_once_the_agent_is_finished() { |
| 1571 | // The manager recomputes `duration_ms` as `started_at.elapsed()` on |
| 1572 | // every snapshot, so a finished agent's raw duration keeps growing. |
| 1573 | // The row must latch the first terminal reading instead. |
| 1574 | let mut app = fleet_app(None); |
| 1575 | app.subagent_cache[0].status = SubAgentStatus::Completed; |
| 1576 | app.subagent_cache[0].duration_ms = 753_000; |
| 1577 | |
| 1578 | let first = super::model::project(&mut app); |
| 1579 | let finished = first |
| 1580 | .iter() |
| 1581 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1582 | .and_then(|row| row.agent.as_ref()) |
| 1583 | .expect("finished agent facts"); |
| 1584 | assert_eq!(finished.elapsed_secs, Some(753)); |
| 1585 | |
| 1586 | // A later snapshot reports a larger duration for the same dead agent. |
| 1587 | app.subagent_cache[0].duration_ms = 999_000; |
| 1588 | let second = super::model::project(&mut app); |
| 1589 | let still = second |
| 1590 | .iter() |
| 1591 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1592 | .and_then(|row| row.agent.as_ref()) |
| 1593 | .expect("finished agent facts"); |
| 1594 | assert_eq!( |
| 1595 | still.elapsed_secs, |
| 1596 | Some(753), |
| 1597 | "finished elapsed must freeze" |
| 1598 | ); |
| 1599 | } |
| 1600 | |
| 1601 | #[test] |
| 1602 | fn fleet_row_elapsed_still_advances_while_the_agent_runs() { |
| 1603 | let mut app = fleet_app(None); |
| 1604 | app.subagent_cache[0].duration_ms = 10_000; |
| 1605 | let early = super::model::project(&mut app); |
| 1606 | assert_eq!( |
| 1607 | early |
| 1608 | .iter() |
| 1609 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1610 | .and_then(|row| row.agent.as_ref()) |
| 1611 | .expect("running agent facts") |
| 1612 | .elapsed_secs, |
| 1613 | Some(10) |
| 1614 | ); |
| 1615 | |
| 1616 | app.subagent_cache[0].duration_ms = 40_000; |
| 1617 | let later = super::model::project(&mut app); |
| 1618 | assert_eq!( |
| 1619 | later |
| 1620 | .iter() |
| 1621 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1622 | .and_then(|row| row.agent.as_ref()) |
| 1623 | .expect("running agent facts") |
| 1624 | .elapsed_secs, |
| 1625 | Some(40) |
| 1626 | ); |
| 1627 | } |
| 1628 | |
| 1629 | #[test] |
| 1630 | fn fleet_row_with_no_reported_usage_shows_no_token_figure_at_all() { |
| 1631 | // An unknown number is rendered as nothing. Never `0`, which would |
| 1632 | // claim the agent spent nothing. |
| 1633 | let mut app = fleet_app(None); |
| 1634 | let row = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1635 | assert!(!row.contains("tokens"), "{row}"); |
| 1636 | assert!(!row.contains('↓'), "{row}"); |
| 1637 | assert!(row.contains("12m 33s"), "{row}"); |
| 1638 | |
| 1639 | let mut spent = fleet_app(Some(0)); |
| 1640 | let zero = fleet_row(&render_rows(&mut spent, 100, 4)); |
| 1641 | // A *reported* zero is a fact and does render. |
| 1642 | assert!(zero.contains("↓ 0 tokens"), "{zero}"); |
| 1643 | } |
| 1644 | |
| 1645 | #[test] |
| 1646 | fn fleet_row_child_badge_counts_children_that_are_on_the_surface() { |
| 1647 | let mut app = app(); |
| 1648 | app.current_session_id = Some(SESSION.to_string()); |
| 1649 | app.subagent_cache.push(cached_worker( |
| 1650 | "agent_lead", |
| 1651 | "general-purpose", |
| 1652 | None, |
| 1653 | None, |
| 1654 | SubAgentStatus::Running, |
| 1655 | )); |
| 1656 | for child in ["agent_c1", "agent_c2", "agent_c3"] { |
| 1657 | app.subagent_cache.push(cached_worker( |
| 1658 | child, |
| 1659 | "scout", |
| 1660 | None, |
| 1661 | Some("agent_lead"), |
| 1662 | SubAgentStatus::Running, |
| 1663 | )); |
| 1664 | } |
| 1665 | // A child whose parent is not on the surface must not be counted for |
| 1666 | // anyone, and must not inflate the lead's badge. |
| 1667 | app.subagent_cache.push(cached_worker( |
| 1668 | "agent_orphan", |
| 1669 | "scout", |
| 1670 | None, |
| 1671 | Some("agent_missing"), |
| 1672 | SubAgentStatus::Running, |
| 1673 | )); |
| 1674 | |
| 1675 | let rows = super::model::project(&mut app); |
| 1676 | let label = |id: &str| { |
| 1677 | rows.iter() |
| 1678 | .find(|row| row.id.0 == format!("worker:{id}")) |
| 1679 | .map(|row| row.label.clone()) |
| 1680 | .unwrap_or_else(|| panic!("row for {id}")) |
| 1681 | }; |
| 1682 | assert_eq!(label("agent_lead"), "general-purpose (+3)"); |
| 1683 | assert_eq!(label("agent_c1"), "↳ scout"); |
| 1684 | assert_eq!(label("agent_orphan"), "scout"); |
| 1685 | } |
| 1686 | |
| 1687 | #[test] |
| 1688 | fn a_capped_fleet_list_announces_how_many_rows_it_is_hiding() { |
| 1689 | let mut app = app(); |
| 1690 | app.current_session_id = Some(SESSION.to_string()); |
| 1691 | for index in 0..8 { |
| 1692 | app.subagent_cache.push(cached_worker( |
| 1693 | &format!("agent_{index}"), |
| 1694 | "general-purpose", |
| 1695 | None, |
| 1696 | None, |
| 1697 | SubAgentStatus::Running, |
| 1698 | )); |
| 1699 | } |
| 1700 | |
| 1701 | // Four content rows for nine projected rows (header + eight workers). |
| 1702 | let rows = render_rows(&mut app, 100, 5); |
| 1703 | let more = rows |
| 1704 | .iter() |
| 1705 | .find(|line| line.contains("more")) |
| 1706 | .unwrap_or_else(|| panic!("no overflow line in {rows:?}")); |
| 1707 | // Nine projected rows (header + eight workers); two fit, seven do not. |
| 1708 | assert!(more.contains("↓ 7 more"), "{more}"); |
| 1709 | // Right-aligned against the content column, not the left margin. |
| 1710 | assert!(more.starts_with(" "), "{more}"); |
| 1711 | } |
| 1712 | |
| 1713 | #[test] |
| 1714 | fn fleet_rows_render_in_top_left_and_right_placements() { |
| 1715 | for placement in [ |
| 1716 | super::WorkSurfacePlacement::Top, |
| 1717 | super::WorkSurfacePlacement::Left, |
| 1718 | super::WorkSurfacePlacement::Right, |
| 1719 | ] { |
| 1720 | let mut app = fleet_app(Some(111_900)); |
| 1721 | app.work_surface.placement = placement; |
| 1722 | app.work_surface.effective_placement = placement; |
| 1723 | let rows = render_rows(&mut app, 40, 8); |
| 1724 | let row = fleet_row(&rows); |
| 1725 | assert!( |
| 1726 | row.contains("Streaming"), |
| 1727 | "{placement:?} lost the objective: {rows:?}" |
| 1728 | ); |
| 1729 | } |
| 1730 | } |
| 1731 | |
| 1732 | #[test] |
| 1733 | fn progress_only_work_rows_use_typed_activity_not_display_substrings() { |
| 1734 | let mut app = app(); |
| 1735 | app.current_session_id = Some(SESSION.to_string()); |
| 1736 | app.agent_progress.insert( |
| 1737 | "agent_progress_only".to_string(), |
| 1738 | "queued waiting failed completed".to_string(), |
| 1739 | ); |
| 1740 | |
| 1741 | let rows = super::model::project(&mut app); |
| 1742 | let row = rows |
| 1743 | .iter() |
| 1744 | .find(|row| row.id.0 == "worker:agent_progress_only") |
| 1745 | .expect("progress-only work row"); |
| 1746 | assert_eq!(row.detail, "running"); |
| 1747 | |
| 1748 | app.agent_progress_meta.insert( |
| 1749 | "agent_progress_only".to_string(), |
| 1750 | crate::tui::app::AgentProgressMeta { |
| 1751 | current_activity: Some(AgentCurrentActivity::bounded( |
| 1752 | AgentCurrentActivityStatus::Waiting, |
| 1753 | Some("approval required".to_string()), |
| 1754 | None, |
| 1755 | Some(5), |
| 1756 | )), |
| 1757 | ..crate::tui::app::AgentProgressMeta::default() |
| 1758 | }, |
| 1759 | ); |
| 1760 | |
| 1761 | let rows = super::model::project(&mut app); |
| 1762 | let row = rows |
| 1763 | .iter() |
| 1764 | .find(|row| row.id.0 == "worker:agent_progress_only") |
| 1765 | .expect("typed progress-only work row"); |
| 1766 | assert!(row.detail.contains("waiting for input"), "{}", row.detail); |
| 1767 | assert!(row.detail.contains("approval required"), "{}", row.detail); |
| 1768 | assert!(row.detail.contains("step 5"), "{}", row.detail); |
| 1769 | } |
| 1770 | |
| 1771 | // === #5906: a parked husk is not an agent waiting for input ========== |
| 1772 | |
| 1773 | /// Build a child exactly the way the turn-end parking projection does: |
| 1774 | /// `Interrupted` + `WaitingForUser` + a `needs_input` note phrased as a |
| 1775 | /// question, distinguished from a real question only by the checkpoint's |
| 1776 | /// `parked_at_turn_end` flag. |
| 1777 | fn parked_worker(id: &str, objective: &str) -> SubAgentResult { |
| 1778 | let mut agent = fleet_worker( |
| 1779 | id, |
| 1780 | "general-purpose", |
| 1781 | objective, |
| 1782 | 753_000, |
| 1783 | SubAgentStatus::Interrupted( |
| 1784 | "Parent turn ended before this turn-owned child settled.".to_string(), |
| 1785 | ), |
| 1786 | ); |
| 1787 | agent.worker_status = Some(AgentWorkerStatus::WaitingForUser); |
| 1788 | agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput { |
| 1789 | question: format!( |
| 1790 | "Resume this parked child with agent(action=\"start\", resume_from=\"{id}\")." |
| 1791 | ), |
| 1792 | }); |
| 1793 | agent.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint { |
| 1794 | checkpoint_id: format!("{id}:step:2"), |
| 1795 | agent_id: id.to_string(), |
| 1796 | continuation_handle: format!("agent:{id}:checkpoint"), |
| 1797 | reason: "Parent turn ended before this turn-owned child settled.".to_string(), |
| 1798 | continuable: true, |
| 1799 | steps_taken: 2, |
| 1800 | message_count: 4, |
| 1801 | created_at_ms: 1_000, |
| 1802 | messages: Vec::new(), |
| 1803 | omitted_messages: 0, |
| 1804 | parked_at_turn_end: true, |
| 1805 | }); |
| 1806 | agent |
| 1807 | } |
| 1808 | |
| 1809 | fn asking_worker(id: &str, objective: &str) -> SubAgentResult { |
| 1810 | let mut agent = fleet_worker( |
| 1811 | id, |
| 1812 | "general-purpose", |
| 1813 | objective, |
| 1814 | 120_000, |
| 1815 | SubAgentStatus::Running, |
| 1816 | ); |
| 1817 | agent.worker_status = Some(AgentWorkerStatus::WaitingForUser); |
| 1818 | agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput { |
| 1819 | question: "Which path should I use?".to_string(), |
| 1820 | }); |
| 1821 | agent |
| 1822 | } |
| 1823 | |
| 1824 | fn parked_fixture() -> App { |
| 1825 | let mut app = app(); |
| 1826 | app.current_session_id = Some(SESSION.to_string()); |
| 1827 | app.subagent_cache |
| 1828 | .push(parked_worker("agent_parked", "Parked dead-code removal")); |
| 1829 | app.subagent_cache |
| 1830 | .push(asking_worker("agent_asking", "Asking about the path")); |
| 1831 | app.subagent_cache.push(fleet_worker( |
| 1832 | "agent_live", |
| 1833 | "general-purpose", |
| 1834 | "Streaming dead-code removal", |
| 1835 | 30_000, |
| 1836 | SubAgentStatus::Running, |
| 1837 | )); |
| 1838 | crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app); |
| 1839 | app |
| 1840 | } |
| 1841 | |
| 1842 | #[test] |
| 1843 | fn a_parked_work_row_says_parked_and_names_its_recovery() { |
| 1844 | let mut app = parked_fixture(); |
| 1845 | let rows = super::model::project(&mut app); |
| 1846 | |
| 1847 | let parked = rows |
| 1848 | .iter() |
| 1849 | .find(|row| row.id.0 == "worker:agent_parked") |
| 1850 | .expect("parked work row"); |
| 1851 | assert!(parked.detail.starts_with("parked"), "{}", parked.detail); |
| 1852 | assert!( |
| 1853 | !parked.detail.contains("waiting for input"), |
| 1854 | "a parked husk must not wear the answerable label: {}", |
| 1855 | parked.detail |
| 1856 | ); |
| 1857 | // The recovery names verbs the runtime actually exposes. |
| 1858 | assert!(parked.detail.contains("resume_from"), "{}", parked.detail); |
| 1859 | assert!(parked.detail.contains("cancel"), "{}", parked.detail); |
| 1860 | assert!( |
| 1861 | !parked.detail.contains("Resume this parked child"), |
| 1862 | "the parking note is not a question to replay at the operator: {}", |
| 1863 | parked.detail |
| 1864 | ); |
| 1865 | |
| 1866 | let asking = rows |
| 1867 | .iter() |
| 1868 | .find(|row| row.id.0 == "worker:agent_asking") |
| 1869 | .expect("asking work row"); |
| 1870 | assert!( |
| 1871 | asking.detail.contains("waiting for input"), |
| 1872 | "a child that really asked keeps the answerable label: {}", |
| 1873 | asking.detail |
| 1874 | ); |
| 1875 | assert!( |
| 1876 | asking.detail.contains("Which path should I use?"), |
| 1877 | "{}", |
| 1878 | asking.detail |
| 1879 | ); |
| 1880 | } |
| 1881 | |
| 1882 | #[test] |
| 1883 | fn parked_rows_sort_below_live_work_and_leave_the_needs_input_count_alone() { |
| 1884 | let mut app = parked_fixture(); |
| 1885 | let rows = super::model::project(&mut app); |
| 1886 | |
| 1887 | let heading = rows |
| 1888 | .iter() |
| 1889 | .find(|row| row.id.0 == "section:work") |
| 1890 | .expect("work heading"); |
| 1891 | // The attention chip counts children a person is actually blocking: |
| 1892 | // one, the child that asked. Two would mean the parked husk had been |
| 1893 | // counted as waiting for input all over again. |
| 1894 | assert!( |
| 1895 | heading.label.contains("1 blocked"), |
| 1896 | "only the child that actually asked is blocked on a person: {}", |
| 1897 | heading.label |
| 1898 | ); |
| 1899 | |
| 1900 | let position = |id: &str| { |
| 1901 | rows.iter() |
| 1902 | .position(|row| row.id.0 == id) |
| 1903 | .unwrap_or_else(|| panic!("{id} missing from {rows:?}")) |
| 1904 | }; |
| 1905 | assert!( |
| 1906 | position("worker:agent_parked") > position("worker:agent_live"), |
| 1907 | "a parked husk must not sort above live work" |
| 1908 | ); |
| 1909 | assert!( |
| 1910 | position("worker:agent_parked") > position("worker:agent_asking"), |
| 1911 | "a parked husk must not sort above a child a person can answer" |
| 1912 | ); |
| 1913 | } |
| 1914 | |
| 1915 | #[test] |
| 1916 | fn the_parked_status_word_survives_the_narrow_row_ladder() { |
| 1917 | // The status word outlives the whole receipt as the strip narrows |
| 1918 | // (see the degradation test above); `parked` is the fact the row |
| 1919 | // exists to carry, so it must survive the same ladder `running` does. |
| 1920 | let mut app = app(); |
| 1921 | app.current_session_id = Some(SESSION.to_string()); |
| 1922 | app.subagent_cache |
| 1923 | .push(parked_worker("agent_parked", "Parked dead-code removal")); |
| 1924 | crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app); |
| 1925 | |
| 1926 | for width in [96u16, 72, 56] { |
| 1927 | let rows = render_rows(&mut app, width, 6); |
| 1928 | let row = rows |
| 1929 | .iter() |
| 1930 | .find(|line| line.contains("Parked dead-code")) |
| 1931 | .unwrap_or_else(|| panic!("no parked row at width {width} in {rows:?}")); |
| 1932 | assert!(row.contains("parked"), "width {width}: {row}"); |
| 1933 | assert!(!row.contains("waiting for input"), "width {width}: {row}"); |
| 1934 | } |
| 1935 | } |
| 1936 | |
| 1937 | #[test] |
| 1938 | fn agent_transcript_keyboard_mouse_and_return_selection_converge() { |
| 1939 | fn add_worker(app: &mut App) { |
| 1940 | app.current_session_id = Some(SESSION.to_string()); |
| 1941 | app.subagent_cache.push(SubAgentResult { |
| 1942 | usage: None, |
| 1943 | name: "agent_converge".to_string(), |
| 1944 | agent_id: "agent_converge".to_string(), |
| 1945 | context_mode: "fresh".to_string(), |
| 1946 | fork_context: false, |
| 1947 | workspace: None, |
| 1948 | git_branch: Some("codex/details".to_string()), |
| 1949 | agent_type: FleetRole::Builder, |
| 1950 | assignment: SubAgentAssignment { |
| 1951 | objective: "Verify keyboard and mouse convergence".to_string(), |
| 1952 | role: Some("worker".to_string()), |
| 1953 | }, |
| 1954 | model: "test-model".to_string(), |
| 1955 | nickname: Some("Blue Whale".to_string()), |
| 1956 | status: SubAgentStatus::Running, |
| 1957 | worker_status: Some(AgentWorkerStatus::Running), |
| 1958 | runtime_permissions: None, |
| 1959 | parent_run_id: None, |
| 1960 | spawn_depth: 1, |
| 1961 | child_route: None, |
| 1962 | result: None, |
| 1963 | steps_taken: 1, |
| 1964 | checkpoint: None, |
| 1965 | needs_input: None, |
| 1966 | duration_ms: 100, |
| 1967 | started_at: None, |
| 1968 | from_prior_session: false, |
| 1969 | }); |
| 1970 | } |
| 1971 | |
| 1972 | let mut keyboard = app(); |
| 1973 | add_worker(&mut keyboard); |
| 1974 | let _ = render_text(&mut keyboard, 100, 6); |
| 1975 | let _ = super::handle_key( |
| 1976 | &mut keyboard, |
| 1977 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 1978 | ); |
| 1979 | let keyboard_action = super::handle_key( |
| 1980 | &mut keyboard, |
| 1981 | KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), |
| 1982 | ) |
| 1983 | .expect("Work key handled") |
| 1984 | .expect("agent transcript action"); |
| 1985 | let keyboard_selection = keyboard.work_surface.selected.clone(); |
| 1986 | |
| 1987 | let mut mouse = app(); |
| 1988 | add_worker(&mut mouse); |
| 1989 | let _ = render_text(&mut mouse, 100, 6); |
| 1990 | let row_y = mouse |
| 1991 | .work_surface |
| 1992 | .hitboxes |
| 1993 | .iter() |
| 1994 | .find(|hit| hit.id.0 == "worker:agent_converge") |
| 1995 | .expect("agent hitbox") |
| 1996 | .row_y; |
| 1997 | let mouse_action = super::handle_mouse( |
| 1998 | &mut mouse, |
| 1999 | MouseEvent { |
| 2000 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2001 | column: 2, |
| 2002 | row: row_y, |
| 2003 | modifiers: KeyModifiers::NONE, |
| 2004 | }, |
| 2005 | ) |
| 2006 | .action |
| 2007 | .expect("mouse agent transcript action"); |
| 2008 | assert_eq!(mouse_action, keyboard_action); |
| 2009 | assert_eq!(mouse.work_surface.selected, keyboard_selection); |
| 2010 | |
| 2011 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut mouse, mouse_action); |
| 2012 | // One agent, one destination: activation focuses the worker in place |
| 2013 | // (its full transcript owns the conversation area) instead of opening |
| 2014 | // a modal, and leaving focus keeps the rail selection where it was. |
| 2015 | assert!( |
| 2016 | mouse |
| 2017 | .agent_focus |
| 2018 | .as_ref() |
| 2019 | .is_some_and(|focus| focus.is("agent_converge")), |
| 2020 | "activation must focus the worker" |
| 2021 | ); |
| 2022 | let selected_before_close = mouse.work_surface.selected.clone(); |
| 2023 | assert!(crate::tui::agent_focus::exit_focus(&mut mouse)); |
| 2024 | assert_eq!(mouse.work_surface.selected, selected_before_close); |
| 2025 | assert!(mouse.work_surface.opened.is_none()); |
| 2026 | } |
| 2027 | |
| 2028 | #[test] |
| 2029 | fn active_session_without_work_keeps_surface_invisible() { |
| 2030 | let mut app = app(); |
| 2031 | app.current_session_id = Some(SESSION.to_string()); |
| 2032 | |
| 2033 | let rows = super::model::project(&mut app); |
| 2034 | |
| 2035 | assert!(rows.is_empty()); |
| 2036 | assert_eq!(super::height(&mut app, 120, 32, AMPLE_BUDGET), 0); |
| 2037 | } |
| 2038 | |
| 2039 | #[test] |
| 2040 | fn empty_work_stays_hidden_after_cached_session_state_is_cleared() { |
| 2041 | let mut app = app(); |
| 2042 | app.current_session_id = Some(SESSION.to_string()); |
| 2043 | app.work_surface.cached_graph = Some(operation_graph(NodeState::Active)); |
| 2044 | |
| 2045 | let rows = super::model::project(&mut app); |
| 2046 | |
| 2047 | assert!(rows.is_empty()); |
| 2048 | assert!(app.work_surface.cached_graph.is_none()); |
| 2049 | } |
| 2050 | |
| 2051 | #[test] |
| 2052 | fn empty_work_reserves_no_side_rail() { |
| 2053 | for placement in [ |
| 2054 | super::WorkSurfacePlacement::Left, |
| 2055 | super::WorkSurfacePlacement::Right, |
| 2056 | ] { |
| 2057 | let mut app = app(); |
| 2058 | app.current_session_id = Some(SESSION.to_string()); |
| 2059 | app.work_surface.placement = placement; |
| 2060 | let area = ratatui::layout::Rect::new(0, 0, 120, 32); |
| 2061 | |
| 2062 | assert_eq!( |
| 2063 | super::height(&mut app, area.width, area.height, AMPLE_BUDGET), |
| 2064 | 0 |
| 2065 | ); |
| 2066 | assert_eq!(super::split_chat(&mut app, area, 0), (area, None)); |
| 2067 | } |
| 2068 | } |
| 2069 | |
| 2070 | fn terminal_text(terminal: &Terminal<TestBackend>) -> String { |
| 2071 | let buf = terminal.backend().buffer(); |
| 2072 | (0..buf.area.height) |
| 2073 | .map(|y| { |
| 2074 | (0..buf.area.width) |
| 2075 | .map(|x| buf[(x, y)].symbol()) |
| 2076 | .collect::<String>() |
| 2077 | }) |
| 2078 | .collect::<Vec<_>>() |
| 2079 | .join("\n") |
| 2080 | } |
| 2081 | |
| 2082 | /// Render-level smoke coverage for the ported rail panels — reinstates |
| 2083 | /// the sidebar render smoke tests removed with the classic shell |
| 2084 | /// (739616787). Top never spends a row on panel chrome (content is |
| 2085 | /// self-evident). Side rails are named by their content's own heading |
| 2086 | /// row (`▾ Subagents N`, `Goal: …`); Context is the one line-list panel |
| 2087 | /// and keeps its muted panel title. |
| 2088 | #[test] |
| 2089 | fn rail_panels_render_in_all_placements() { |
| 2090 | for panel in [ |
| 2091 | super::RailPanel::Agents, |
| 2092 | super::RailPanel::Context, |
| 2093 | super::RailPanel::Tasks, |
| 2094 | ] { |
| 2095 | for placement in [ |
| 2096 | super::WorkSurfacePlacement::Bottom, |
| 2097 | super::WorkSurfacePlacement::Top, |
| 2098 | super::WorkSurfacePlacement::Left, |
| 2099 | super::WorkSurfacePlacement::Right, |
| 2100 | ] { |
| 2101 | let mut app = app(); |
| 2102 | app.work_surface.placement = placement; |
| 2103 | super::interaction::select_dock_panel(&mut app, panel); |
| 2104 | app.work_surface.focused = false; |
| 2105 | // Content so empty-collapse does not hide the panel. Agents |
| 2106 | // needs a cached worker; Tasks needs a goal; Context always |
| 2107 | // has a budget. |
| 2108 | app.goal.objective = Some("ship the release".to_string()); |
| 2109 | if panel == super::RailPanel::Agents { |
| 2110 | app.subagent_cache.push(cached_worker( |
| 2111 | "agent-a", |
| 2112 | "explore", |
| 2113 | Some("scout"), |
| 2114 | None, |
| 2115 | SubAgentStatus::Running, |
| 2116 | )); |
| 2117 | } |
| 2118 | let area = ratatui::layout::Rect::new(0, 0, 100, 24); |
| 2119 | |
| 2120 | // Render coverage, not yield coverage: a 24-row terminal with |
| 2121 | // work on screen has rows to spare, so the panel is expected |
| 2122 | // to draw. The idle-empty budget is exercised end-to-end in |
| 2123 | // `ui::tests::rail_strip_yields_the_ambient_floor_*`. |
| 2124 | let budget = working_budget(&app, area.height); |
| 2125 | let strip = super::height(&mut app, area.width, area.height, budget); |
| 2126 | let (_chat, rail) = super::split_chat(&mut app, area, 0); |
| 2127 | let backend = TestBackend::new(area.width, area.height); |
| 2128 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 2129 | terminal |
| 2130 | .draw(|frame| { |
| 2131 | if strip > 0 { |
| 2132 | super::render( |
| 2133 | frame, |
| 2134 | ratatui::layout::Rect::new(0, 0, area.width, strip), |
| 2135 | &mut app, |
| 2136 | ); |
| 2137 | } else if let Some(rail) = rail { |
| 2138 | super::render(frame, rail, &mut app); |
| 2139 | } |
| 2140 | }) |
| 2141 | .expect("draw"); |
| 2142 | let text = terminal_text(&terminal); |
| 2143 | match placement { |
| 2144 | super::WorkSurfacePlacement::Bottom => { |
| 2145 | assert!( |
| 2146 | strip > 0, |
| 2147 | "{panel:?} on Bottom should auto-fit a content strip; got height 0" |
| 2148 | ); |
| 2149 | } |
| 2150 | super::WorkSurfacePlacement::Top => { |
| 2151 | assert!( |
| 2152 | strip > 0, |
| 2153 | "{panel:?} on Top should auto-fit a content strip; got height 0" |
| 2154 | ); |
| 2155 | // Panel chrome ("Pinned"/"Agents") never on Top. |
| 2156 | // An active goal *is* a title — and this fixture sets one. |
| 2157 | // A chrome title would be a row saying only the |
| 2158 | // view's name; the tab row and `▾ Subagents N` both |
| 2159 | // legitimately contain the lowercase word. |
| 2160 | let strip_body = text.lines().skip(1).collect::<Vec<_>>().join("\n"); |
| 2161 | assert!( |
| 2162 | !strip_body.lines().any(|line| line.trim() == panel.title()), |
| 2163 | "{panel:?} on Top must not spend a row on panel chrome; got: {text}" |
| 2164 | ); |
| 2165 | // Goal title when a live goal is set. |
| 2166 | assert!( |
| 2167 | text.contains("Goal:") && text.contains("ship the release"), |
| 2168 | "Top with an active goal must title with Goal: …; got: {text}" |
| 2169 | ); |
| 2170 | } |
| 2171 | super::WorkSurfacePlacement::Left | super::WorkSurfacePlacement::Right => { |
| 2172 | assert!( |
| 2173 | rail.is_some() || strip > 0, |
| 2174 | "{panel:?} in {placement:?} should reserve a rail" |
| 2175 | ); |
| 2176 | // Work-row panels are named by their content heading; |
| 2177 | // only the Context fact list keeps a panel title. |
| 2178 | match panel { |
| 2179 | super::RailPanel::Agents => { |
| 2180 | assert!( |
| 2181 | text.contains("Subagents 1"), |
| 2182 | "{panel:?} in {placement:?} should render its \ |
| 2183 | Subagents heading; got: {text}" |
| 2184 | ); |
| 2185 | assert!( |
| 2186 | !app.work_surface.hitboxes.is_empty(), |
| 2187 | "{panel:?} in {placement:?} must record hitboxes — \ |
| 2188 | every work row is a door" |
| 2189 | ); |
| 2190 | } |
| 2191 | super::RailPanel::Tasks => { |
| 2192 | assert!( |
| 2193 | text.contains("Goal: ship the release"), |
| 2194 | "{panel:?} in {placement:?} should render the goal \ |
| 2195 | heading; got: {text}" |
| 2196 | ); |
| 2197 | } |
| 2198 | _ => { |
| 2199 | assert!( |
| 2200 | text.contains("compact now"), |
| 2201 | "{panel:?} in {placement:?} should render the budget \ |
| 2202 | rows; got: {text}" |
| 2203 | ); |
| 2204 | } |
| 2205 | } |
| 2206 | } |
| 2207 | super::WorkSurfacePlacement::Off => {} |
| 2208 | } |
| 2209 | } |
| 2210 | } |
| 2211 | } |
| 2212 | |
| 2213 | #[test] |
| 2214 | fn off_placement_reserves_no_rail_in_any_panel() { |
| 2215 | for panel in [ |
| 2216 | super::RailPanel::Tasks, |
| 2217 | super::RailPanel::Agents, |
| 2218 | super::RailPanel::Context, |
| 2219 | super::RailPanel::Tasks, |
| 2220 | ] { |
| 2221 | let mut app = app(); |
| 2222 | add_todos(&mut app, 2); |
| 2223 | app.work_surface.placement = super::WorkSurfacePlacement::Off; |
| 2224 | app.work_surface.panel = panel; |
| 2225 | let area = ratatui::layout::Rect::new(0, 0, 120, 32); |
| 2226 | |
| 2227 | assert_eq!( |
| 2228 | super::height(&mut app, area.width, area.height, AMPLE_BUDGET), |
| 2229 | 0 |
| 2230 | ); |
| 2231 | assert_eq!(super::split_chat(&mut app, area, 0), (area, None)); |
| 2232 | assert_eq!(app.work_surface.last_area, None); |
| 2233 | } |
| 2234 | } |
| 2235 | |
| 2236 | #[test] |
| 2237 | fn context_view_renders_the_budget_in_a_side_rail() { |
| 2238 | let mut app = app(); |
| 2239 | app.work_surface.placement = super::WorkSurfacePlacement::Right; |
| 2240 | super::interaction::select_dock_panel(&mut app, super::RailPanel::Context); |
| 2241 | let area = ratatui::layout::Rect::new(0, 0, 100, 24); |
| 2242 | |
| 2243 | let budget = working_budget(&app, area.height); |
| 2244 | let strip = super::height(&mut app, area.width, area.height, budget); |
| 2245 | assert_eq!(strip, 0, "side placements take no top strip"); |
| 2246 | let (_chat, rail) = super::split_chat(&mut app, area, 0); |
| 2247 | let rail = rail.expect("context panel reserves a side rail"); |
| 2248 | |
| 2249 | let backend = TestBackend::new(area.width, area.height); |
| 2250 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 2251 | terminal |
| 2252 | .draw(|frame| super::render(frame, rail, &mut app)) |
| 2253 | .expect("draw"); |
| 2254 | let text = terminal_text(&terminal); |
| 2255 | assert!(text.contains(" of "), "budget row; got: {text}"); |
| 2256 | assert!(text.contains("compact now"), "compact door; got: {text}"); |
| 2257 | assert!( |
| 2258 | app.work_surface |
| 2259 | .hitboxes |
| 2260 | .iter() |
| 2261 | .any(|hit| hit.id.0 == "context:compact"), |
| 2262 | "the compact row is a hit target" |
| 2263 | ); |
| 2264 | } |
| 2265 | |
| 2266 | #[test] |
| 2267 | fn missing_runtime_renders_disconnected_state() { |
| 2268 | let mut app = app(); |
| 2269 | app.current_session_id = Some(SESSION.to_string()); |
| 2270 | app.runtime_services.work = None; |
| 2271 | |
| 2272 | let rows = super::model::project(&mut app); |
| 2273 | |
| 2274 | assert_eq!(rows[0].label, "Work · disconnected"); |
| 2275 | } |
| 2276 | |
| 2277 | #[test] |
| 2278 | fn busy_graph_authority_renders_truthful_error_without_leaking_it_into_header() { |
| 2279 | let mut app = app(); |
| 2280 | app.current_session_id = Some(SESSION.to_string()); |
| 2281 | let todos = app.todos.clone(); |
| 2282 | let _guard = todos.try_lock().expect("hold To-do authority lock"); |
| 2283 | |
| 2284 | let rows = super::model::project(&mut app); |
| 2285 | |
| 2286 | assert_eq!(rows.len(), 1); |
| 2287 | assert_eq!(rows[0].label, "Work · error"); |
| 2288 | assert!(rows[0].detail.contains("To-do state is busy")); |
| 2289 | assert!(!rows[0].label.contains("busy")); |
| 2290 | } |
| 2291 | |
| 2292 | #[test] |
| 2293 | fn graph_error_without_an_active_session_stays_suppressed() { |
| 2294 | let mut app = app(); |
| 2295 | let todos = app.todos.clone(); |
| 2296 | let _guard = todos.try_lock().expect("hold To-do authority lock"); |
| 2297 | |
| 2298 | let rows = super::model::project(&mut app); |
| 2299 | |
| 2300 | assert!(rows.is_empty()); |
| 2301 | } |
| 2302 | |
| 2303 | #[test] |
| 2304 | fn waiting_operation_is_not_counted_as_running() { |
| 2305 | let mut app = app(); |
| 2306 | let graph = operation_graph(NodeState::Waiting); |
| 2307 | restore_graph(&mut app, &graph); |
| 2308 | app.runtime_services |
| 2309 | .work |
| 2310 | .as_ref() |
| 2311 | .expect("Work Graph runtime") |
| 2312 | .reconcile_operation( |
| 2313 | SESSION, |
| 2314 | OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Waiting, 1, 6), |
| 2315 | ) |
| 2316 | .expect("waiting shell owner"); |
| 2317 | |
| 2318 | let rows = super::model::project(&mut app); |
| 2319 | |
| 2320 | assert!( |
| 2321 | rows[0].label.starts_with("Work · Needs input:") |
| 2322 | || rows[0] |
| 2323 | .label |
| 2324 | .starts_with("Work · 0 active · 1 needs input · 0 ready · 0 recent"), |
| 2325 | "{}", |
| 2326 | rows[0].label |
| 2327 | ); |
| 2328 | assert!( |
| 2329 | rows[0].label.contains("blocked") || rows[0].label.contains("needs input"), |
| 2330 | "{}", |
| 2331 | rows[0].label |
| 2332 | ); |
| 2333 | } |
| 2334 | |
| 2335 | #[test] |
| 2336 | fn stale_operation_is_blocked_attention_with_bounded_output_section() { |
| 2337 | let mut app = app(); |
| 2338 | let graph = operation_graph(NodeState::Stale); |
| 2339 | restore_graph(&mut app, &graph); |
| 2340 | |
| 2341 | let rows = super::model::project(&mut app); |
| 2342 | assert!( |
| 2343 | rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"), |
| 2344 | "{}", |
| 2345 | rows[0].label |
| 2346 | ); |
| 2347 | let row = rows.iter().find(|row| row.selectable).expect("stale row"); |
| 2348 | assert_eq!(row.mark, "?"); |
| 2349 | assert!(row.detail.starts_with("stale · operation")); |
| 2350 | let Some(SidebarRowAction::InspectWork { |
| 2351 | body, stop_action, .. |
| 2352 | }) = row.primary_action.as_ref() |
| 2353 | else { |
| 2354 | panic!("stale row must open inspector"); |
| 2355 | }; |
| 2356 | assert!( |
| 2357 | stop_action.is_none(), |
| 2358 | "a stale owner cannot truthfully expose a stop action" |
| 2359 | ); |
| 2360 | assert!( |
| 2361 | body.contains("Last bounded output\nNo output receipt"), |
| 2362 | "{body}" |
| 2363 | ); |
| 2364 | assert!(body.contains("Owner cannot confirm liveness"), "{body}"); |
| 2365 | } |
| 2366 | |
| 2367 | /// A durable failed operation, as a fleet agent task from a crashed or |
| 2368 | /// sibling instance leaves behind in the persisted graph (#4416). |
| 2369 | fn durable_failed_operation_graph() -> crate::work_graph::WorkGraphSnapshot { |
| 2370 | let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Failed)); |
| 2371 | let operation = WorkNodeId::derive(SESSION, "operation"); |
| 2372 | graph |
| 2373 | .apply( |
| 2374 | WorkGraphChange::BindOperation { |
| 2375 | node: operation, |
| 2376 | binding: OperationBinding { |
| 2377 | external: "fleet:run_1/task_1".to_string(), |
| 2378 | durable: true, |
| 2379 | last_observation: None, |
| 2380 | }, |
| 2381 | }, |
| 2382 | ChangeCtx { |
| 2383 | session_id: SESSION.to_string(), |
| 2384 | now: 6, |
| 2385 | idempotency_key: None, |
| 2386 | }, |
| 2387 | ) |
| 2388 | .expect("durable binding"); |
| 2389 | graph.into_snapshot() |
| 2390 | } |
| 2391 | |
| 2392 | // Regression for #4416: a persisted failed-agent record stamped by |
| 2393 | // another session instance (boot id) must not appear in the default |
| 2394 | // work listing of a fresh session in the same workspace. |
| 2395 | #[test] |
| 2396 | fn prior_instance_failed_rows_stay_out_of_the_default_listing() { |
| 2397 | let dir = tempfile::tempdir().expect("tempdir"); |
| 2398 | let manager = |
| 2399 | crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager"); |
| 2400 | manager |
| 2401 | .record_session_boot_owner(SESSION, "boot_other_instance") |
| 2402 | .expect("stamp other instance"); |
| 2403 | |
| 2404 | let mut app = app(); |
| 2405 | app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf()); |
| 2406 | let graph = durable_failed_operation_graph(); |
| 2407 | restore_saved_graph(&mut app, &graph); |
| 2408 | |
| 2409 | let rows = super::model::project(&mut app); |
| 2410 | assert!( |
| 2411 | rows.iter() |
| 2412 | .all(|row| !row.label.contains("Verify installed build")), |
| 2413 | "prior-instance failed row leaked into the default listing: {rows:#?}" |
| 2414 | ); |
| 2415 | assert!( |
| 2416 | rows.iter() |
| 2417 | .all(|row| !row.label.contains("needs input") && !row.label.contains("1 active")), |
| 2418 | "prior-instance residue must not count as live work: {rows:#?}" |
| 2419 | ); |
| 2420 | // The record stays reachable through the explicit catalog, clearly |
| 2421 | // marked historical. |
| 2422 | let historical = app |
| 2423 | .work_surface |
| 2424 | .catalog_rows |
| 2425 | .iter() |
| 2426 | .find(|row| row.label.contains("Verify installed build")) |
| 2427 | .expect("historical row remains in the catalog"); |
| 2428 | assert!( |
| 2429 | historical.detail.starts_with("prior session · "), |
| 2430 | "historical row must be labeled: {}", |
| 2431 | historical.detail |
| 2432 | ); |
| 2433 | } |
| 2434 | |
| 2435 | // Ownership control for #4416: the same failed record owned by this |
| 2436 | // session instance still renders as actionable work. |
| 2437 | #[test] |
| 2438 | fn current_instance_failed_rows_still_render_in_the_default_listing() { |
| 2439 | let dir = tempfile::tempdir().expect("tempdir"); |
| 2440 | let manager = |
| 2441 | crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager"); |
| 2442 | manager |
| 2443 | .record_session_boot_owner(SESSION, crate::session_manager::current_session_boot_id()) |
| 2444 | .expect("stamp current instance"); |
| 2445 | |
| 2446 | let mut app = app(); |
| 2447 | app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf()); |
| 2448 | let graph = durable_failed_operation_graph(); |
| 2449 | restore_graph(&mut app, &graph); |
| 2450 | |
| 2451 | let rows = super::model::project(&mut app); |
| 2452 | assert!( |
| 2453 | rows.iter() |
| 2454 | .any(|row| row.label.contains("Verify installed build")), |
| 2455 | "this instance's own failed work must stay visible: {rows:#?}" |
| 2456 | ); |
| 2457 | } |
| 2458 | |
| 2459 | // Regression for review of #5063: if a prior session persisted no graph, |
| 2460 | // the first graph captured later belongs to this process and must not be |
| 2461 | // mistaken for restored residue. |
| 2462 | #[test] |
| 2463 | fn first_live_graph_after_empty_prior_restore_stays_visible() { |
| 2464 | let dir = tempfile::tempdir().expect("tempdir"); |
| 2465 | let manager = |
| 2466 | crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager"); |
| 2467 | manager |
| 2468 | .record_session_boot_owner(SESSION, "boot_other_instance") |
| 2469 | .expect("stamp other instance"); |
| 2470 | |
| 2471 | let mut app = app(); |
| 2472 | app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf()); |
| 2473 | app.current_session_id = Some(SESSION.to_string()); |
| 2474 | app.restore_work_state(SESSION, std::path::Path::new("."), None) |
| 2475 | .expect("restore empty prior session"); |
| 2476 | |
| 2477 | let graph = durable_failed_operation_graph(); |
| 2478 | restore_graph(&mut app, &graph); |
| 2479 | let rows = super::model::project(&mut app); |
| 2480 | assert!( |
| 2481 | rows.iter() |
| 2482 | .any(|row| row.label.contains("Verify installed build")), |
| 2483 | "this instance's first live graph must stay visible: {rows:#?}" |
| 2484 | ); |
| 2485 | } |
| 2486 | |
| 2487 | #[test] |
| 2488 | fn completed_operation_with_acceptance_is_not_rendered_done() { |
| 2489 | let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Ready)); |
| 2490 | let operation = WorkNodeId::derive(SESSION, "operation"); |
| 2491 | graph |
| 2492 | .apply( |
| 2493 | WorkGraphChange::UpdateNode { |
| 2494 | id: operation, |
| 2495 | patch: crate::work_graph::WorkNodePatch { |
| 2496 | state: Some(NodeState::Completed), |
| 2497 | acceptance: Some(vec![AcceptanceRequirement::EvidenceOfKind { |
| 2498 | kind: EvidenceKindTag::ToolRun, |
| 2499 | }]), |
| 2500 | ..crate::work_graph::WorkNodePatch::default() |
| 2501 | }, |
| 2502 | }, |
| 2503 | ChangeCtx { |
| 2504 | session_id: SESSION.to_string(), |
| 2505 | now: 6, |
| 2506 | idempotency_key: None, |
| 2507 | }, |
| 2508 | ) |
| 2509 | .expect("completed pending evidence"); |
| 2510 | let graph = graph.into_snapshot(); |
| 2511 | let mut app = app(); |
| 2512 | restore_graph(&mut app, &graph); |
| 2513 | |
| 2514 | let rows = super::model::project(&mut app); |
| 2515 | assert!( |
| 2516 | rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"), |
| 2517 | "{}", |
| 2518 | rows[0].label |
| 2519 | ); |
| 2520 | let row = rows |
| 2521 | .iter() |
| 2522 | .find(|row| row.selectable) |
| 2523 | .expect("operation row"); |
| 2524 | assert_eq!(row.mark, crate::tui::glyphs::ATTENTION); |
| 2525 | assert!(row.detail.contains("completed · evidence pending")); |
| 2526 | assert_ne!(row.mark, "✓"); |
| 2527 | let Some(SidebarRowAction::InspectWork { body, .. }) = row.primary_action.as_ref() else { |
| 2528 | panic!("completed operation must remain inspectable"); |
| 2529 | }; |
| 2530 | assert!(body.contains("evidence of kind tool run"), "{body}"); |
| 2531 | assert!( |
| 2532 | body.contains("acceptance evidence is still missing"), |
| 2533 | "{body}" |
| 2534 | ); |
| 2535 | } |
| 2536 | |
| 2537 | #[test] |
| 2538 | fn work_rows_open_graph_inspector_without_inline_controls() { |
| 2539 | let mut app = app(); |
| 2540 | app.work_surface.placement = WorkSurfacePlacement::Right; |
| 2541 | app.work_surface.effective_placement = WorkSurfacePlacement::Right; |
| 2542 | let graph = operation_graph(NodeState::Active); |
| 2543 | restore_graph(&mut app, &graph); |
| 2544 | app.runtime_services |
| 2545 | .work |
| 2546 | .as_ref() |
| 2547 | .expect("Work Graph runtime") |
| 2548 | .reconcile_operation( |
| 2549 | SESSION, |
| 2550 | OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Running, 1, 6), |
| 2551 | ) |
| 2552 | .expect("live shell owner"); |
| 2553 | |
| 2554 | let text = render_text(&mut app, 100, 6); |
| 2555 | assert!(!text.contains("[open]"), "{text}"); |
| 2556 | assert!(!text.contains("[stop]"), "{text}"); |
| 2557 | let row_y = app |
| 2558 | .work_surface |
| 2559 | .hitboxes |
| 2560 | .iter() |
| 2561 | .find(|hit| hit.id.0.starts_with("graph:")) |
| 2562 | .expect("graph hitbox") |
| 2563 | .row_y; |
| 2564 | let outcome = super::handle_mouse( |
| 2565 | &mut app, |
| 2566 | MouseEvent { |
| 2567 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2568 | column: 2, |
| 2569 | row: row_y, |
| 2570 | modifiers: KeyModifiers::NONE, |
| 2571 | }, |
| 2572 | ); |
| 2573 | let action = outcome.action.expect("inspector action"); |
| 2574 | let SidebarRowAction::InspectWork { |
| 2575 | body, stop_action, .. |
| 2576 | } = &action |
| 2577 | else { |
| 2578 | panic!("expected Work inspector"); |
| 2579 | }; |
| 2580 | for section in [ |
| 2581 | "Objective", |
| 2582 | "Prerequisites", |
| 2583 | "Downstream impact", |
| 2584 | "Binding + lifecycle owner", |
| 2585 | "Evidence vs acceptance", |
| 2586 | "Blockers / approvals", |
| 2587 | "Why next", |
| 2588 | "Provenance + last reconcile", |
| 2589 | ] { |
| 2590 | assert!(body.contains(section), "missing {section}: {body}"); |
| 2591 | } |
| 2592 | assert!(matches!( |
| 2593 | stop_action.as_deref(), |
| 2594 | Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_1234abcd" |
| 2595 | )); |
| 2596 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action); |
| 2597 | assert_eq!( |
| 2598 | app.view_stack.top_kind(), |
| 2599 | Some(crate::tui::views::ModalKind::Pager) |
| 2600 | ); |
| 2601 | } |
| 2602 | |
| 2603 | #[test] |
| 2604 | fn narrow_render_hover_keeps_full_untruncated_row() { |
| 2605 | let mut app = app(); |
| 2606 | app.todos.try_lock().expect("todos").add( |
| 2607 | "A deliberately long graph-owned work row".to_string(), |
| 2608 | TodoStatus::InProgress, |
| 2609 | ); |
| 2610 | |
| 2611 | let _ = render_text(&mut app, 24, 4); |
| 2612 | let hover = app |
| 2613 | .sidebar_hover |
| 2614 | .sections |
| 2615 | .last() |
| 2616 | .and_then(|section| section.rows.first()) |
| 2617 | .expect("hover row"); |
| 2618 | assert!(hover.is_truncated); |
| 2619 | assert!(hover.full_text.contains("deliberately long graph-owned")); |
| 2620 | assert!(hover.stop_action.is_none()); |
| 2621 | } |
| 2622 | |
| 2623 | #[test] |
| 2624 | fn narrow_file_activity_prioritizes_the_canonical_aggregate_label() { |
| 2625 | let mut app = app(); |
| 2626 | app.workspace = PathBuf::from("/workspace/project"); |
| 2627 | let result = crate::tools::spec::ToolResult::success("ok").with_metadata( |
| 2628 | serde_json::json!({ |
| 2629 | "mutation": { |
| 2630 | "diff": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/create.rs\n@@ -0,0 +1 @@\n+created\n--- a/delete.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-deleted\n", |
| 2631 | "files": [ |
| 2632 | { "path": "update.rs", "outcome": "updated" }, |
| 2633 | { "path": "create.rs", "outcome": "created" }, |
| 2634 | { "path": "delete.rs", "outcome": "deleted" } |
| 2635 | ], |
| 2636 | "renames": [{ "from": "old.rs", "to": "new.rs" }] |
| 2637 | } |
| 2638 | }), |
| 2639 | ); |
| 2640 | let receipt = FileMutationReceipt::from_success(&app.workspace, &result).expect("receipt"); |
| 2641 | app.add_message(HistoryCell::Tool(ToolCell::PatchSummary( |
| 2642 | PatchSummaryCell { |
| 2643 | path: "4 files".to_string(), |
| 2644 | summary: "ok".to_string(), |
| 2645 | status: ToolStatus::Success, |
| 2646 | error: None, |
| 2647 | receipt: Some(receipt), |
| 2648 | }, |
| 2649 | ))); |
| 2650 | app.tool_details_by_cell.insert( |
| 2651 | 0, |
| 2652 | ToolDetailRecord { |
| 2653 | tool_id: "file-multi".to_string(), |
| 2654 | tool_name: "File".to_string(), |
| 2655 | input: serde_json::json!({"action": "patch"}), |
| 2656 | output: Some("ok".to_string()), |
| 2657 | }, |
| 2658 | ); |
| 2659 | |
| 2660 | app.work_surface.placement = WorkSurfacePlacement::Right; |
| 2661 | app.work_surface.effective_placement = WorkSurfacePlacement::Right; |
| 2662 | let text = render_text(&mut app, 80, 6); |
| 2663 | assert!(text.contains("Wrote 4 files"), "{text}"); |
| 2664 | } |
| 2665 | |
| 2666 | #[test] |
| 2667 | fn overflow_scroll_and_selection_remain_panel_owned() { |
| 2668 | let mut app = app(); |
| 2669 | add_todos(&mut app, 8); |
| 2670 | let _ = render_text(&mut app, 80, 5); |
| 2671 | assert!(app.work_surface.total_rows > app.work_surface.visible_rows); |
| 2672 | |
| 2673 | let transcript_delta = app.viewport.pending_scroll_delta; |
| 2674 | let outcome = super::handle_mouse( |
| 2675 | &mut app, |
| 2676 | MouseEvent { |
| 2677 | kind: MouseEventKind::ScrollDown, |
| 2678 | column: 10, |
| 2679 | row: 2, |
| 2680 | modifiers: KeyModifiers::NONE, |
| 2681 | }, |
| 2682 | ); |
| 2683 | assert!(outcome.consumed); |
| 2684 | assert_eq!(app.viewport.pending_scroll_delta, transcript_delta); |
| 2685 | assert!(app.work_surface.scroll_offset > 0); |
| 2686 | } |
| 2687 | |
| 2688 | #[test] |
| 2689 | fn mouse_wheel_reaches_last_todo_across_top_surface_heights() { |
| 2690 | for height in [3, 5, 6, 8] { |
| 2691 | let mut app = app(); |
| 2692 | add_todos(&mut app, 10); |
| 2693 | let _ = render_text(&mut app, 80, height); |
| 2694 | assert!(app.work_surface.total_rows > app.work_surface.visible_rows); |
| 2695 | let transcript_delta = app.viewport.pending_scroll_delta; |
| 2696 | |
| 2697 | let mut text = String::new(); |
| 2698 | for _ in 0..16 { |
| 2699 | let outcome = super::handle_mouse( |
| 2700 | &mut app, |
| 2701 | MouseEvent { |
| 2702 | kind: MouseEventKind::ScrollDown, |
| 2703 | column: 10, |
| 2704 | row: 1, |
| 2705 | modifiers: KeyModifiers::NONE, |
| 2706 | }, |
| 2707 | ); |
| 2708 | assert!(outcome.consumed, "height {height}"); |
| 2709 | text = render_text(&mut app, 80, height); |
| 2710 | } |
| 2711 | |
| 2712 | assert!( |
| 2713 | text.contains("work item 9"), |
| 2714 | "last To-do was unreachable at surface height {height}: {text:?}" |
| 2715 | ); |
| 2716 | assert_eq!( |
| 2717 | app.work_surface.scroll_offset, |
| 2718 | app.work_surface |
| 2719 | .total_rows |
| 2720 | .saturating_sub(app.work_surface.visible_rows.max(1)), |
| 2721 | "wheel did not reach the legal tail at surface height {height}" |
| 2722 | ); |
| 2723 | assert_eq!(app.viewport.pending_scroll_delta, transcript_delta); |
| 2724 | } |
| 2725 | } |
| 2726 | |
| 2727 | #[test] |
| 2728 | fn mouse_wheel_reaches_last_todo_in_side_rail_placements() { |
| 2729 | for placement in [ |
| 2730 | super::WorkSurfacePlacement::Left, |
| 2731 | super::WorkSurfacePlacement::Right, |
| 2732 | ] { |
| 2733 | let mut app = app(); |
| 2734 | add_todos(&mut app, 10); |
| 2735 | app.work_surface.placement = placement; |
| 2736 | app.work_surface.effective_placement = placement; |
| 2737 | let _ = render_text(&mut app, 30, 6); |
| 2738 | |
| 2739 | let mut text = String::new(); |
| 2740 | for _ in 0..16 { |
| 2741 | let outcome = super::handle_mouse( |
| 2742 | &mut app, |
| 2743 | MouseEvent { |
| 2744 | kind: MouseEventKind::ScrollDown, |
| 2745 | column: 10, |
| 2746 | row: 1, |
| 2747 | modifiers: KeyModifiers::NONE, |
| 2748 | }, |
| 2749 | ); |
| 2750 | assert!(outcome.consumed, "placement {placement:?}"); |
| 2751 | text = render_text(&mut app, 30, 6); |
| 2752 | } |
| 2753 | |
| 2754 | assert!( |
| 2755 | text.contains("work item 9"), |
| 2756 | "last To-do was unreachable in {placement:?}: {text:?}" |
| 2757 | ); |
| 2758 | } |
| 2759 | } |
| 2760 | |
| 2761 | #[test] |
| 2762 | fn keyboard_end_reveals_last_todo_after_redraw() { |
| 2763 | let mut app = app(); |
| 2764 | add_todos(&mut app, 10); |
| 2765 | let _ = render_text(&mut app, 80, 5); |
| 2766 | let _ = super::handle_key( |
| 2767 | &mut app, |
| 2768 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 2769 | ); |
| 2770 | let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); |
| 2771 | |
| 2772 | let text = render_text(&mut app, 80, 5); |
| 2773 | |
| 2774 | assert!(text.contains("work item 9"), "{text:?}"); |
| 2775 | assert_eq!( |
| 2776 | app.work_surface.scroll_offset, |
| 2777 | app.work_surface |
| 2778 | .total_rows |
| 2779 | .saturating_sub(app.work_surface.visible_rows.max(1)) |
| 2780 | ); |
| 2781 | } |
| 2782 | |
| 2783 | #[test] |
| 2784 | fn keyboard_navigation_is_panel_local_when_focused() { |
| 2785 | let mut app = app(); |
| 2786 | add_todos(&mut app, 3); |
| 2787 | let _ = render_text(&mut app, 80, super::model::TOP_HEIGHT_MIN); |
| 2788 | assert!( |
| 2789 | super::handle_key( |
| 2790 | &mut app, |
| 2791 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT) |
| 2792 | ) |
| 2793 | .is_some() |
| 2794 | ); |
| 2795 | let first = app.work_surface.selected.clone(); |
| 2796 | let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); |
| 2797 | assert_ne!(app.work_surface.selected, first); |
| 2798 | assert!(app.work_surface.focused); |
| 2799 | } |
| 2800 | |
| 2801 | #[test] |
| 2802 | fn clicking_agents_tab_switches_active_panel() { |
| 2803 | let mut app = app(); |
| 2804 | add_todos(&mut app, 1); |
| 2805 | app.subagent_cache.push(cached_worker( |
| 2806 | "agent-tab", |
| 2807 | "explore", |
| 2808 | Some("scout"), |
| 2809 | None, |
| 2810 | SubAgentStatus::Running, |
| 2811 | )); |
| 2812 | let _ = render_text(&mut app, 80, 8); |
| 2813 | // The to-do list opened first; the running worker is one tab over. |
| 2814 | assert_eq!(app.work_surface.panel, super::RailPanel::Tasks); |
| 2815 | let tab_area = app |
| 2816 | .work_surface |
| 2817 | .dock_tabs |
| 2818 | .iter() |
| 2819 | .find(|hitbox| { |
| 2820 | hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Agents) |
| 2821 | }) |
| 2822 | .map(|hitbox| hitbox.area) |
| 2823 | .expect("Agents tab"); |
| 2824 | |
| 2825 | let down = super::handle_mouse( |
| 2826 | &mut app, |
| 2827 | MouseEvent { |
| 2828 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2829 | column: tab_area.x, |
| 2830 | row: tab_area.y, |
| 2831 | modifiers: KeyModifiers::NONE, |
| 2832 | }, |
| 2833 | ); |
| 2834 | assert!(down.consumed); |
| 2835 | let up = super::handle_mouse( |
| 2836 | &mut app, |
| 2837 | MouseEvent { |
| 2838 | kind: MouseEventKind::Up(MouseButton::Left), |
| 2839 | column: tab_area.x, |
| 2840 | row: tab_area.y, |
| 2841 | modifiers: KeyModifiers::NONE, |
| 2842 | }, |
| 2843 | ); |
| 2844 | assert!(up.consumed); |
| 2845 | assert_eq!(app.work_surface.panel, super::RailPanel::Agents); |
| 2846 | assert!(app.work_surface.explicit_view); |
| 2847 | assert!(!app.work_surface.dismissed); |
| 2848 | } |
| 2849 | |
| 2850 | #[test] |
| 2851 | fn clicking_active_tab_dismisses_the_dock_until_new_work_arrives() { |
| 2852 | let mut app = app(); |
| 2853 | app.work_surface.top_height = 8; |
| 2854 | add_todos(&mut app, 2); |
| 2855 | let _ = render_text(&mut app, 80, 8); |
| 2856 | let tab_area = app |
| 2857 | .work_surface |
| 2858 | .dock_tabs |
| 2859 | .iter() |
| 2860 | .find(|hitbox| { |
| 2861 | hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Tasks) |
| 2862 | }) |
| 2863 | .map(|hitbox| hitbox.area) |
| 2864 | .expect("Tasks tab"); |
| 2865 | |
| 2866 | super::handle_mouse( |
| 2867 | &mut app, |
| 2868 | MouseEvent { |
| 2869 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2870 | column: tab_area.x, |
| 2871 | row: tab_area.y, |
| 2872 | modifiers: KeyModifiers::NONE, |
| 2873 | }, |
| 2874 | ); |
| 2875 | super::handle_mouse( |
| 2876 | &mut app, |
| 2877 | MouseEvent { |
| 2878 | kind: MouseEventKind::Up(MouseButton::Left), |
| 2879 | column: tab_area.x, |
| 2880 | row: tab_area.y, |
| 2881 | modifiers: KeyModifiers::NONE, |
| 2882 | }, |
| 2883 | ); |
| 2884 | assert!(app.work_surface.dismissed); |
| 2885 | assert_eq!( |
| 2886 | super::height(&mut app, 80, 24, AMPLE_BUDGET), |
| 2887 | 0, |
| 2888 | "dismissed dock remains collapsed" |
| 2889 | ); |
| 2890 | |
| 2891 | app.subagent_cache.push(cached_worker( |
| 2892 | "new-work", |
| 2893 | "explore", |
| 2894 | Some("new work"), |
| 2895 | None, |
| 2896 | SubAgentStatus::Running, |
| 2897 | )); |
| 2898 | assert!( |
| 2899 | super::height(&mut app, 80, 24, AMPLE_BUDGET) > 0, |
| 2900 | "new work re-shows dismissed dock" |
| 2901 | ); |
| 2902 | assert!(!app.work_surface.dismissed); |
| 2903 | } |
| 2904 | |
| 2905 | #[test] |
| 2906 | fn dock_tabs_match_80x24_golden() { |
| 2907 | let mut app = app(); |
| 2908 | add_todos(&mut app, 3); |
| 2909 | app.subagent_cache.push(cached_worker( |
| 2910 | "dock-golden-agent", |
| 2911 | "explore", |
| 2912 | Some("scout"), |
| 2913 | None, |
| 2914 | SubAgentStatus::Running, |
| 2915 | )); |
| 2916 | |
| 2917 | assert_matches_golden("dock_80x24", &render_golden_text(&mut app, 80, 24)); |
| 2918 | } |
| 2919 | |
| 2920 | #[test] |
| 2921 | fn dock_selection_is_readable_and_close_target_stays_inside_small_hosts() { |
| 2922 | use super::model::DockTabTarget; |
| 2923 | use ratatui::style::Modifier; |
| 2924 | for theme_id in codewhale_palette::SELECTABLE_THEMES { |
| 2925 | let mut app = app(); |
| 2926 | app.ui_theme = theme_id.ui_theme(); |
| 2927 | app.work_surface.explicit_view = true; |
| 2928 | add_todos(&mut app, 1); |
| 2929 | let mut terminal = Terminal::new(TestBackend::new(80, 8)).unwrap(); |
| 2930 | terminal |
| 2931 | .draw(|frame| super::render(frame, frame.area(), &mut app)) |
| 2932 | .unwrap(); |
| 2933 | let tab = app |
| 2934 | .work_surface |
| 2935 | .dock_tabs |
| 2936 | .iter() |
| 2937 | .find(|tab| tab.target == DockTabTarget::Panel(super::RailPanel::Tasks)) |
| 2938 | .unwrap(); |
| 2939 | let cell = &terminal.backend().buffer()[(tab.area.x + 1, tab.area.y)]; |
| 2940 | assert_eq!(cell.bg, app.ui_theme.selection_bg, "{theme_id:?}"); |
| 2941 | assert!(!cell.modifier.contains(Modifier::REVERSED), "{theme_id:?}"); |
| 2942 | if let Some(ratio) = codewhale_palette::contrast_ratio(cell.fg, cell.bg) { |
| 2943 | assert!(ratio >= 4.5, "{theme_id:?}: {cell:?} ({ratio})"); |
| 2944 | } else { |
| 2945 | // Native terminal colors are user supplied and cannot be measured here. |
| 2946 | assert_eq!(*theme_id, codewhale_palette::ThemeId::Terminal); |
| 2947 | } |
| 2948 | } |
| 2949 | for width in [1, 2, 3, 8, 16, 40, 60, 80] { |
| 2950 | for placement in [WorkSurfacePlacement::Top, WorkSurfacePlacement::Bottom] { |
| 2951 | let mut app = app(); |
| 2952 | app.work_surface.explicit_view = true; |
| 2953 | app.work_surface.effective_placement = placement; |
| 2954 | let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap(); |
| 2955 | terminal |
| 2956 | .draw(|frame| super::render(frame, frame.area(), &mut app)) |
| 2957 | .unwrap(); |
| 2958 | let close = app |
| 2959 | .work_surface |
| 2960 | .dock_tabs |
| 2961 | .iter() |
| 2962 | .find(|tab| tab.target == DockTabTarget::Close) |
| 2963 | .unwrap(); |
| 2964 | assert!(close.area.right() <= width); |
| 2965 | assert!(close.area.width > 0); |
| 2966 | } |
| 2967 | } |
| 2968 | } |
| 2969 | |
| 2970 | #[test] |
| 2971 | fn narrow_dock_drops_counts_before_optional_tabs() { |
| 2972 | let mut app = app(); |
| 2973 | add_todos(&mut app, 3); |
| 2974 | app.subagent_cache.push(cached_worker( |
| 2975 | "agent-count", |
| 2976 | "explore", |
| 2977 | Some("scout"), |
| 2978 | None, |
| 2979 | SubAgentStatus::Running, |
| 2980 | )); |
| 2981 | let first_row = render_rows(&mut app, 40, 8) |
| 2982 | .into_iter() |
| 2983 | .next() |
| 2984 | .expect("dock tab row"); |
| 2985 | |
| 2986 | assert!(first_row.contains("Tasks"), "{first_row:?}"); |
| 2987 | assert!(first_row.contains("Fleet"), "{first_row:?}"); |
| 2988 | assert!(!first_row.contains("Tasks 3"), "{first_row:?}"); |
| 2989 | assert!(!first_row.contains("Fleet 1"), "{first_row:?}"); |
| 2990 | assert!(first_row.contains("Context"), "{first_row:?}"); |
| 2991 | // Shed from the right: price goes before any work view. |
| 2992 | assert!(!first_row.contains("Cost"), "{first_row:?}"); |
| 2993 | } |
| 2994 | |
| 2995 | #[test] |
| 2996 | fn empty_panel_releases_plain_y_before_composer_dispatch() { |
| 2997 | let mut app = app(); |
| 2998 | app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 80, 8)); |
| 2999 | app.work_surface.focused = true; |
| 3000 | app.work_surface.explicit_view = false; |
| 3001 | let outcome = super::handle_key( |
| 3002 | &mut app, |
| 3003 | KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), |
| 3004 | ); |
| 3005 | assert!(outcome.is_none()); |
| 3006 | assert!(!app.work_surface.focused); |
| 3007 | } |
| 3008 | |
| 3009 | #[test] |
| 3010 | fn printable_keys_release_panel_focus_for_composer() { |
| 3011 | let mut app = app(); |
| 3012 | add_todos(&mut app, 1); |
| 3013 | let _ = render_text(&mut app, 80, super::model::TOP_HEIGHT_MIN); |
| 3014 | assert!( |
| 3015 | super::handle_key( |
| 3016 | &mut app, |
| 3017 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 3018 | ) |
| 3019 | .is_some() |
| 3020 | ); |
| 3021 | |
| 3022 | let outcome = super::handle_key( |
| 3023 | &mut app, |
| 3024 | KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), |
| 3025 | ); |
| 3026 | |
| 3027 | assert!(outcome.is_none()); |
| 3028 | assert!(!app.work_surface.focused); |
| 3029 | } |
| 3030 | |
| 3031 | #[test] |
| 3032 | fn side_placements_reuse_the_same_graph_rows() { |
| 3033 | for (placement, expected_chat_x, expected_rail_x) in [ |
| 3034 | (super::WorkSurfacePlacement::Left, 30, 0), |
| 3035 | (super::WorkSurfacePlacement::Right, 0, 70), |
| 3036 | ] { |
| 3037 | let mut app = app(); |
| 3038 | add_todos(&mut app, 2); |
| 3039 | app.work_surface.placement = placement; |
| 3040 | assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0); |
| 3041 | let area = ratatui::layout::Rect::new(0, 0, 100, 12); |
| 3042 | let (chat, rail) = super::split_chat(&mut app, area, 0); |
| 3043 | let rail = rail.expect("side rail"); |
| 3044 | assert_eq!(chat.x, expected_chat_x); |
| 3045 | assert_eq!(rail.x, expected_rail_x); |
| 3046 | assert_eq!(rail.width, 30); |
| 3047 | assert!( |
| 3048 | app.work_surface |
| 3049 | .latest_rows |
| 3050 | .iter() |
| 3051 | .any(|row| row.label == "work item 1") |
| 3052 | ); |
| 3053 | } |
| 3054 | } |
| 3055 | |
| 3056 | #[test] |
| 3057 | fn divider_drag_resizes_top_left_and_right_surfaces() { |
| 3058 | let mut top = app(); |
| 3059 | add_todos(&mut top, 3); |
| 3060 | let _ = render_text(&mut top, 80, 3); |
| 3061 | let down = super::handle_mouse( |
| 3062 | &mut top, |
| 3063 | MouseEvent { |
| 3064 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3065 | column: 20, |
| 3066 | row: 2, |
| 3067 | modifiers: KeyModifiers::NONE, |
| 3068 | }, |
| 3069 | ); |
| 3070 | assert!(down.consumed); |
| 3071 | let _ = super::handle_mouse( |
| 3072 | &mut top, |
| 3073 | MouseEvent { |
| 3074 | kind: MouseEventKind::Drag(MouseButton::Left), |
| 3075 | column: 20, |
| 3076 | row: 7, |
| 3077 | modifiers: KeyModifiers::NONE, |
| 3078 | }, |
| 3079 | ); |
| 3080 | assert_eq!(top.work_surface.top_height, 8); |
| 3081 | |
| 3082 | for (placement, drag_column, expected_width) in [ |
| 3083 | (WorkSurfacePlacement::Left, 39, 40), |
| 3084 | (WorkSurfacePlacement::Right, 10, 26), |
| 3085 | ] { |
| 3086 | let mut side = app(); |
| 3087 | add_todos(&mut side, 2); |
| 3088 | side.work_surface.placement = placement; |
| 3089 | side.work_surface.effective_placement = placement; |
| 3090 | let _ = render_text(&mut side, 30, 8); |
| 3091 | let divider_column = if placement == WorkSurfacePlacement::Left { |
| 3092 | 29 |
| 3093 | } else { |
| 3094 | 0 |
| 3095 | }; |
| 3096 | let _ = super::handle_mouse( |
| 3097 | &mut side, |
| 3098 | MouseEvent { |
| 3099 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3100 | column: divider_column, |
| 3101 | row: 2, |
| 3102 | modifiers: KeyModifiers::NONE, |
| 3103 | }, |
| 3104 | ); |
| 3105 | let _ = super::handle_mouse( |
| 3106 | &mut side, |
| 3107 | MouseEvent { |
| 3108 | kind: MouseEventKind::Drag(MouseButton::Left), |
| 3109 | column: drag_column, |
| 3110 | row: 2, |
| 3111 | modifiers: KeyModifiers::NONE, |
| 3112 | }, |
| 3113 | ); |
| 3114 | assert_eq!( |
| 3115 | side.work_surface.side_width, expected_width, |
| 3116 | "{placement:?}" |
| 3117 | ); |
| 3118 | } |
| 3119 | } |
| 3120 | |
| 3121 | #[test] |
| 3122 | fn divider_hover_and_drag_render_a_discoverable_handle() { |
| 3123 | let mut app = app(); |
| 3124 | add_todos(&mut app, 3); |
| 3125 | let resting = render_text(&mut app, 80, 3); |
| 3126 | assert!(resting.contains('─'), "{resting}"); |
| 3127 | |
| 3128 | let hover = super::handle_mouse( |
| 3129 | &mut app, |
| 3130 | MouseEvent { |
| 3131 | kind: MouseEventKind::Moved, |
| 3132 | column: 20, |
| 3133 | row: 2, |
| 3134 | modifiers: KeyModifiers::NONE, |
| 3135 | }, |
| 3136 | ); |
| 3137 | assert!(hover.consumed); |
| 3138 | assert!(app.work_surface.divider_hovered); |
| 3139 | let hovered = render_text(&mut app, 80, 3); |
| 3140 | assert!(hovered.contains('━'), "{hovered}"); |
| 3141 | |
| 3142 | let _ = super::handle_mouse( |
| 3143 | &mut app, |
| 3144 | MouseEvent { |
| 3145 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3146 | column: 20, |
| 3147 | row: 2, |
| 3148 | modifiers: KeyModifiers::NONE, |
| 3149 | }, |
| 3150 | ); |
| 3151 | let dragging = render_text(&mut app, 80, 3); |
| 3152 | assert!(dragging.contains('━'), "{dragging}"); |
| 3153 | } |
| 3154 | |
| 3155 | #[test] |
| 3156 | fn top_bar_excludes_generic_operations() { |
| 3157 | let mut operation_app = app(); |
| 3158 | let graph = operation_graph(NodeState::Failed); |
| 3159 | restore_graph(&mut operation_app, &graph); |
| 3160 | |
| 3161 | assert_eq!(super::height(&mut operation_app, 100, 24, AMPLE_BUDGET), 0); |
| 3162 | assert!(operation_app.work_surface.latest_rows.is_empty()); |
| 3163 | |
| 3164 | let mut todo_app = app(); |
| 3165 | add_todos(&mut todo_app, 2); |
| 3166 | assert!(super::height(&mut todo_app, 100, 24, AMPLE_BUDGET) > 0); |
| 3167 | assert!( |
| 3168 | todo_app |
| 3169 | .work_surface |
| 3170 | .latest_rows |
| 3171 | .iter() |
| 3172 | .all(|row| row.id.0.starts_with("graph:") || row.id.0.starts_with("worker:")) |
| 3173 | ); |
| 3174 | assert!( |
| 3175 | todo_app |
| 3176 | .work_surface |
| 3177 | .latest_rows |
| 3178 | .iter() |
| 3179 | .all(|row| !row.label.starts_with("Work ·")) |
| 3180 | ); |
| 3181 | } |
| 3182 | |
| 3183 | #[test] |
| 3184 | fn opened_row_toggles_closed_without_losing_selection() { |
| 3185 | let mut app = app(); |
| 3186 | add_todos(&mut app, 1); |
| 3187 | let row = super::model::project(&mut app) |
| 3188 | .into_iter() |
| 3189 | .find(|row| row.selectable) |
| 3190 | .expect("work row"); |
| 3191 | let open = row.primary_action.clone(); |
| 3192 | |
| 3193 | assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some()); |
| 3194 | // The action's pager is on screen, so the second activation is a |
| 3195 | // toggle-close. |
| 3196 | app.view_stack.push(crate::tui::pager::PagerView::from_text( |
| 3197 | "Work · test".to_string(), |
| 3198 | "body", |
| 3199 | 40, |
| 3200 | )); |
| 3201 | assert!(super::interaction::activate_primary(&mut app, &row.id, open).is_none()); |
| 3202 | assert!(app.work_surface.opened.is_none()); |
| 3203 | assert_eq!(app.work_surface.selected.as_ref(), Some(&row.id)); |
| 3204 | } |
| 3205 | |
| 3206 | #[test] |
| 3207 | fn a_click_after_the_pager_closed_itself_reopens_instead_of_going_dead() { |
| 3208 | // q/Esc inside the pager pops it without clearing `opened`. The next |
| 3209 | // click on that row must reopen its world, not be swallowed by a |
| 3210 | // stale toggle (owner regression report, 2026-08-04). |
| 3211 | let mut app = app(); |
| 3212 | add_todos(&mut app, 1); |
| 3213 | let row = super::model::project(&mut app) |
| 3214 | .into_iter() |
| 3215 | .find(|row| row.selectable) |
| 3216 | .expect("work row"); |
| 3217 | let open = row.primary_action.clone(); |
| 3218 | |
| 3219 | assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some()); |
| 3220 | // The pager was closed from inside itself; `opened` is now stale. |
| 3221 | assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id)); |
| 3222 | assert!(app.view_stack.is_empty()); |
| 3223 | |
| 3224 | let reopened = super::interaction::activate_primary(&mut app, &row.id, open); |
| 3225 | assert!( |
| 3226 | reopened.is_some(), |
| 3227 | "a stale opened owner must not swallow the next activation" |
| 3228 | ); |
| 3229 | assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id)); |
| 3230 | } |
| 3231 | |
| 3232 | /// Settled to-dos keep their rows across the recent-only TTL and new user |
| 3233 | /// turns. Finished sub-agents collapse into the Subagents Archived count |
| 3234 | /// (still reachable via the Agents panel) so fan-outs do not permanently |
| 3235 | /// eat the transcript. |
| 3236 | #[test] |
| 3237 | fn settled_todos_stay_and_finished_workers_collapse_after_ttl() { |
| 3238 | let mut app = app(); |
| 3239 | app.current_session_id = Some(SESSION.to_string()); |
| 3240 | { |
| 3241 | let mut todos = app.todos.try_lock().expect("todos"); |
| 3242 | todos.add("ship the fix".to_string(), TodoStatus::Completed); |
| 3243 | todos.add("verify the fix".to_string(), TodoStatus::Completed); |
| 3244 | } |
| 3245 | app.subagent_cache.push(cached_worker( |
| 3246 | "agent-settled", |
| 3247 | "builder", |
| 3248 | None, |
| 3249 | None, |
| 3250 | SubAgentStatus::Completed, |
| 3251 | )); |
| 3252 | |
| 3253 | app.work_surface.set_presentation_now_ms(0); |
| 3254 | let first = super::model::project_visible(&mut app); |
| 3255 | assert!( |
| 3256 | first.iter().any(|row| row.id.0.starts_with("graph:")), |
| 3257 | "settled to-dos must be listed: {first:?}" |
| 3258 | ); |
| 3259 | assert!( |
| 3260 | !first.iter().any(|row| row.id.0.starts_with("worker:")), |
| 3261 | "workers are the agents view's rows, never the tasks view's: {first:?}" |
| 3262 | ); |
| 3263 | let roster = super::model::visible_rows_for(&mut app, super::RailPanel::Agents); |
| 3264 | assert!( |
| 3265 | roster.iter().any(|row| row.id.0 == "worker:agent-settled"), |
| 3266 | "the roster retains a finished worker: {roster:?}" |
| 3267 | ); |
| 3268 | |
| 3269 | app.work_surface |
| 3270 | .set_presentation_now_ms(super::model::RECENT_ONLY_TTL_MS + 1); |
| 3271 | app.work_surface.note_user_turn_or_new_operation(); |
| 3272 | let later = super::model::project_visible(&mut app); |
| 3273 | assert!( |
| 3274 | later.iter().any(|row| row.id.0.starts_with("graph:")), |
| 3275 | "a settled to-do must survive the TTL and the next user turn: {later:?}" |
| 3276 | ); |
| 3277 | assert!( |
| 3278 | super::height(&mut app, 100, 40, AMPLE_BUDGET) > 0, |
| 3279 | "the strip must keep its height while it holds settled work" |
| 3280 | ); |
| 3281 | } |
| 3282 | |
| 3283 | /// A to-do row says its state in words, in the `/task digest` vocabulary. |
| 3284 | /// Dropping the words (2011b9b11 conflated them with the redundant kind |
| 3285 | /// label) was half of owner regression A1. |
| 3286 | #[test] |
| 3287 | fn todo_rows_carry_their_status_words() { |
| 3288 | let mut app = app(); |
| 3289 | add_todos(&mut app, 3); |
| 3290 | let rows = super::model::project(&mut app); |
| 3291 | let todo_details: Vec<&str> = rows |
| 3292 | .iter() |
| 3293 | .filter(|row| row.id.0.starts_with("graph:")) |
| 3294 | .map(|row| row.detail.as_str()) |
| 3295 | .collect(); |
| 3296 | assert!( |
| 3297 | todo_details.contains(&"in progress"), |
| 3298 | "the active step says so in words: {todo_details:?}" |
| 3299 | ); |
| 3300 | assert!( |
| 3301 | todo_details.contains(&"pending"), |
| 3302 | "a pending step is labeled, not blank: {todo_details:?}" |
| 3303 | ); |
| 3304 | |
| 3305 | // And the words are painted, not just projected. |
| 3306 | let text = render_text(&mut app, 100, 6); |
| 3307 | assert!(text.contains("in progress"), "{text}"); |
| 3308 | assert!(text.contains("pending"), "{text}"); |
| 3309 | } |
| 3310 | |
| 3311 | /// Top strip collapses completed/cancelled workers into an Archived count |
| 3312 | /// while keeping live (and failed) workers as rows. Agents panel still |
| 3313 | /// lists every worker — see the click test below. |
| 3314 | #[test] |
| 3315 | fn the_roster_keeps_live_failed_and_finished_workers() { |
| 3316 | let mut app = app(); |
| 3317 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3318 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 3319 | app.current_session_id = Some(SESSION.to_string()); |
| 3320 | app.subagent_cache.push(cached_worker( |
| 3321 | "agent-live", |
| 3322 | "scout", |
| 3323 | None, |
| 3324 | None, |
| 3325 | SubAgentStatus::Running, |
| 3326 | )); |
| 3327 | app.subagent_cache.push(cached_worker( |
| 3328 | "agent-done", |
| 3329 | "builder", |
| 3330 | None, |
| 3331 | None, |
| 3332 | SubAgentStatus::Completed, |
| 3333 | )); |
| 3334 | app.subagent_cache.push(cached_worker( |
| 3335 | "agent-failed", |
| 3336 | "verifier", |
| 3337 | None, |
| 3338 | None, |
| 3339 | SubAgentStatus::Failed("boom".to_string()), |
| 3340 | )); |
| 3341 | |
| 3342 | // The roster is a history: every worker keeps its row, in every |
| 3343 | // state, and the tasks view never lists one. |
| 3344 | let rows = super::model::visible_rows_for(&mut app, super::RailPanel::Agents); |
| 3345 | let ids: Vec<&str> = rows.iter().map(|row| row.id.0.as_str()).collect(); |
| 3346 | assert!(ids.contains(&"section:agents"), "{ids:?}"); |
| 3347 | for id in [ |
| 3348 | "worker:agent-live", |
| 3349 | "worker:agent-failed", |
| 3350 | "worker:agent-done", |
| 3351 | ] { |
| 3352 | assert!(ids.contains(&id), "{id} in the roster: {ids:?}"); |
| 3353 | } |
| 3354 | let tasks = super::model::project_visible(&mut app); |
| 3355 | assert!( |
| 3356 | !tasks.iter().any(|row| row.id.0.starts_with("worker:")), |
| 3357 | "{tasks:?}" |
| 3358 | ); |
| 3359 | assert_eq!(super::model::live_agent_row_count(&mut app), 2); |
| 3360 | } |
| 3361 | |
| 3362 | #[test] |
| 3363 | fn subagent_header_opens_the_full_agents_register() { |
| 3364 | let mut app = app(); |
| 3365 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3366 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 3367 | app.current_session_id = Some(SESSION.to_string()); |
| 3368 | app.subagent_cache.push(cached_worker( |
| 3369 | "agent-archived", |
| 3370 | "builder", |
| 3371 | None, |
| 3372 | None, |
| 3373 | SubAgentStatus::Completed, |
| 3374 | )); |
| 3375 | |
| 3376 | // Nothing live: the dock stays down until the user opens agents. |
| 3377 | assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0); |
| 3378 | super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents); |
| 3379 | let top = render_text(&mut app, 100, 4); |
| 3380 | assert!(top.contains("Subagents 1"), "{top}"); |
| 3381 | let header_y = app |
| 3382 | .work_surface |
| 3383 | .hitboxes |
| 3384 | .iter() |
| 3385 | .find(|hit| hit.id.0 == "section:agents") |
| 3386 | .expect("subagent header must be a real hit target") |
| 3387 | .row_y; |
| 3388 | let action = super::handle_mouse( |
| 3389 | &mut app, |
| 3390 | MouseEvent { |
| 3391 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3392 | column: 2, |
| 3393 | row: header_y, |
| 3394 | modifiers: KeyModifiers::NONE, |
| 3395 | }, |
| 3396 | ) |
| 3397 | .action |
| 3398 | .expect("subagent header must dispatch its primary action"); |
| 3399 | assert_eq!(action, SidebarRowAction::ShowSubagentsPanel); |
| 3400 | super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents); |
| 3401 | |
| 3402 | let agents = render_text(&mut app, 100, 6); |
| 3403 | assert!( |
| 3404 | agents.contains("agent-archived") || agents.contains("builder"), |
| 3405 | "the full Agents register keeps the archived worker reachable: {agents}" |
| 3406 | ); |
| 3407 | } |
| 3408 | |
| 3409 | #[test] |
| 3410 | fn agent_entry_focuses_a_visible_row_and_esc_returns_to_composer() { |
| 3411 | let mut app = app(); |
| 3412 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3413 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 3414 | app.current_session_id = Some(SESSION.to_string()); |
| 3415 | app.subagent_cache.push(cached_worker( |
| 3416 | "agent-live", |
| 3417 | "builder", |
| 3418 | None, |
| 3419 | None, |
| 3420 | SubAgentStatus::Running, |
| 3421 | )); |
| 3422 | |
| 3423 | let rendered = render_text(&mut app, 100, 8); |
| 3424 | assert!(rendered.contains("builder"), "{rendered}"); |
| 3425 | |
| 3426 | assert!(super::enter_agents(&mut app)); |
| 3427 | assert_eq!(app.work_surface.panel, super::RailPanel::Agents); |
| 3428 | assert!(app.work_surface.focused); |
| 3429 | assert_eq!( |
| 3430 | app.work_surface.selected.as_ref().map(|row| row.0.as_str()), |
| 3431 | Some("worker:agent-live") |
| 3432 | ); |
| 3433 | |
| 3434 | let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 3435 | assert!(handled.is_some()); |
| 3436 | assert!( |
| 3437 | !app.work_surface.focused, |
| 3438 | "Esc returns ownership to composer" |
| 3439 | ); |
| 3440 | } |
| 3441 | |
| 3442 | #[test] |
| 3443 | fn agent_entry_rejects_a_surface_that_is_not_rendered() { |
| 3444 | let mut app = app(); |
| 3445 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3446 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 3447 | app.current_session_id = Some(SESSION.to_string()); |
| 3448 | app.subagent_cache.push(cached_worker( |
| 3449 | "agent-live", |
| 3450 | "builder", |
| 3451 | None, |
| 3452 | None, |
| 3453 | SubAgentStatus::Running, |
| 3454 | )); |
| 3455 | // A previous frame's rectangle is not evidence that this Agent row |
| 3456 | // was painted. Only the renderer's current hitboxes may transfer |
| 3457 | // keyboard ownership away from the composer. |
| 3458 | app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 5)); |
| 3459 | assert!(app.work_surface.hitboxes.is_empty()); |
| 3460 | |
| 3461 | assert!(!super::enter_agents(&mut app)); |
| 3462 | assert!( |
| 3463 | !app.work_surface.focused, |
| 3464 | "hidden surface cannot own arrows" |
| 3465 | ); |
| 3466 | } |
| 3467 | |
| 3468 | #[test] |
| 3469 | fn compact_top_surface_keeps_goal_todos_and_named_agent_visible() { |
| 3470 | for (width, terminal_height) in [(160, 48), (120, 32), (80, 24)] { |
| 3471 | let mut app = app(); |
| 3472 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3473 | app.work_surface.panel = super::RailPanel::Tasks; |
| 3474 | app.work_surface.top_height = super::model::TOP_HEIGHT_MIN; |
| 3475 | app.goal.objective = Some("ship the release".to_string()); |
| 3476 | add_todos(&mut app, 3); |
| 3477 | app.current_session_id = Some(SESSION.to_string()); |
| 3478 | app.subagent_cache.push(cached_worker( |
| 3479 | "agent-harbor", |
| 3480 | "builder", |
| 3481 | Some("Harbor"), |
| 3482 | None, |
| 3483 | SubAgentStatus::Running, |
| 3484 | )); |
| 3485 | |
| 3486 | let budget = crate::tui::ui::rail_row_budget(&app, width, terminal_height, false); |
| 3487 | let height = super::height(&mut app, width, terminal_height, budget); |
| 3488 | assert_eq!( |
| 3489 | height, |
| 3490 | super::model::TOP_HEIGHT_MIN, |
| 3491 | "{width}x{terminal_height} must seat the readable compact surface" |
| 3492 | ); |
| 3493 | // The to-do list opens first: goal title + the progress |
| 3494 | // receipt. The named agent lives one view over. |
| 3495 | let rendered = render_text(&mut app, width, height); |
| 3496 | assert!( |
| 3497 | rendered.contains("ship the release"), |
| 3498 | "{width}x{terminal_height}: {rendered}" |
| 3499 | ); |
| 3500 | assert!( |
| 3501 | rendered.contains("3 left"), |
| 3502 | "{width}x{terminal_height}: {rendered}" |
| 3503 | ); |
| 3504 | super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents); |
| 3505 | let height = super::height(&mut app, width, terminal_height, budget); |
| 3506 | let agents = render_text(&mut app, width, height); |
| 3507 | assert!( |
| 3508 | agents.contains("Harbor"), |
| 3509 | "{width}x{terminal_height}: {agents}" |
| 3510 | ); |
| 3511 | app.work_surface.explicit_view = false; |
| 3512 | let height = super::height(&mut app, width, terminal_height, budget); |
| 3513 | let _ = render_text(&mut app, width, height); |
| 3514 | |
| 3515 | assert!(super::enter_agents(&mut app)); |
| 3516 | assert_eq!( |
| 3517 | app.work_surface.selected.as_ref().map(|row| row.0.as_str()), |
| 3518 | Some("worker:agent-harbor"), |
| 3519 | "the advertised Left control must focus the named visible Agent" |
| 3520 | ); |
| 3521 | } |
| 3522 | } |
| 3523 | |
| 3524 | #[test] |
| 3525 | fn starved_surface_cannot_take_keyboard_focus() { |
| 3526 | let mut app = app(); |
| 3527 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3528 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 3529 | app.current_session_id = Some(SESSION.to_string()); |
| 3530 | app.subagent_cache.push(cached_worker( |
| 3531 | "agent-hidden", |
| 3532 | "builder", |
| 3533 | Some("Harbor"), |
| 3534 | None, |
| 3535 | SubAgentStatus::Running, |
| 3536 | )); |
| 3537 | |
| 3538 | assert_eq!(super::height(&mut app, 80, 12, 0), 0); |
| 3539 | assert!(app.work_surface.last_area.is_none()); |
| 3540 | assert!(!super::enter_agents(&mut app)); |
| 3541 | assert!(!app.work_surface.focused); |
| 3542 | assert!( |
| 3543 | super::handle_key( |
| 3544 | &mut app, |
| 3545 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 3546 | ) |
| 3547 | .is_none() |
| 3548 | ); |
| 3549 | assert!(!app.work_surface.focused); |
| 3550 | } |
| 3551 | |
| 3552 | /// Acceptance for owner regression A2: an agent row is a door in the |
| 3553 | /// Agents panel too, and a FINISHED agent's world still opens — the |
| 3554 | /// panel is a standing register, not a live-only view. Since v0.9.7 the |
| 3555 | /// door leads to the agent's transcript (which explains itself when no |
| 3556 | /// capture exists yet), not to the details projection. |
| 3557 | #[test] |
| 3558 | fn agents_panel_click_opens_the_transcript_even_for_finished_agents() { |
| 3559 | let mut app = app(); |
| 3560 | app.work_surface.panel = super::RailPanel::Agents; |
| 3561 | app.current_session_id = Some(SESSION.to_string()); |
| 3562 | app.subagent_cache.push(cached_worker( |
| 3563 | "agent-finished", |
| 3564 | "builder", |
| 3565 | None, |
| 3566 | None, |
| 3567 | SubAgentStatus::Completed, |
| 3568 | )); |
| 3569 | |
| 3570 | let _ = render_text(&mut app, 100, 6); |
| 3571 | let row_y = app |
| 3572 | .work_surface |
| 3573 | .hitboxes |
| 3574 | .iter() |
| 3575 | .find(|hit| hit.id.0 == "worker:agent-finished") |
| 3576 | .expect("finished agent row must keep a hitbox in the Agents panel") |
| 3577 | .row_y; |
| 3578 | let action = super::handle_mouse( |
| 3579 | &mut app, |
| 3580 | MouseEvent { |
| 3581 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3582 | column: 2, |
| 3583 | row: row_y, |
| 3584 | modifiers: KeyModifiers::NONE, |
| 3585 | }, |
| 3586 | ) |
| 3587 | .action |
| 3588 | .expect("click on a finished agent row must dispatch its primary action"); |
| 3589 | assert_eq!( |
| 3590 | action, |
| 3591 | SidebarRowAction::OpenAgentTranscript { |
| 3592 | agent_id: "agent-finished".to_string() |
| 3593 | } |
| 3594 | ); |
| 3595 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action); |
| 3596 | assert!( |
| 3597 | app.agent_focus |
| 3598 | .as_ref() |
| 3599 | .is_some_and(|focus| focus.is("agent-finished")), |
| 3600 | "the finished agent's transcript must actually take focus" |
| 3601 | ); |
| 3602 | } |
| 3603 | |
| 3604 | /// Acceptance for owner regression A1: to-do rows are doors in the |
| 3605 | /// Pinned panel too — clicking one opens the work inspector. |
| 3606 | #[test] |
| 3607 | fn tasks_view_todo_rows_stay_clickable() { |
| 3608 | let mut app = app(); |
| 3609 | app.work_surface.panel = super::RailPanel::Tasks; |
| 3610 | add_todos(&mut app, 2); |
| 3611 | |
| 3612 | let _ = render_text(&mut app, 100, 6); |
| 3613 | let hit = app |
| 3614 | .work_surface |
| 3615 | .hitboxes |
| 3616 | .iter() |
| 3617 | .find(|hit| hit.id.0.starts_with("graph:")) |
| 3618 | .expect("tasks view to-do rows must keep hitboxes") |
| 3619 | .clone(); |
| 3620 | let action = super::handle_mouse( |
| 3621 | &mut app, |
| 3622 | MouseEvent { |
| 3623 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3624 | column: 2, |
| 3625 | row: hit.row_y, |
| 3626 | modifiers: KeyModifiers::NONE, |
| 3627 | }, |
| 3628 | ) |
| 3629 | .action |
| 3630 | .expect("click on a to-do row must dispatch its primary action"); |
| 3631 | assert!( |
| 3632 | matches!(action, SidebarRowAction::InspectWork { .. }), |
| 3633 | "a to-do row opens the work inspector: {action:?}" |
| 3634 | ); |
| 3635 | } |
| 3636 | |
| 3637 | /// Opening the sub-agent register must not hide the to-do list — both |
| 3638 | /// durable surfaces stay visible together (owner report, 0.9.6). The |
| 3639 | /// dock opens on TODO; AGENTS is the next tab (founder, 2026-09-03). |
| 3640 | #[test] |
| 3641 | fn agents_and_tasks_are_separate_views_and_the_dock_opens_on_todo() { |
| 3642 | let mut app = app(); |
| 3643 | app.current_session_id = Some(SESSION.to_string()); |
| 3644 | app.subagent_cache.push(cached_worker( |
| 3645 | "agent-live", |
| 3646 | "scout", |
| 3647 | None, |
| 3648 | None, |
| 3649 | SubAgentStatus::Running, |
| 3650 | )); |
| 3651 | add_todos(&mut app, 2); |
| 3652 | |
| 3653 | // The auto rule: the to-do list first, and only to-dos in it. |
| 3654 | super::model::resolve_view(&mut app); |
| 3655 | assert_eq!(app.work_surface.panel, super::RailPanel::Tasks); |
| 3656 | let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app) |
| 3657 | .iter() |
| 3658 | .map(|row| row.id.0.clone()) |
| 3659 | .collect(); |
| 3660 | assert!(ids.iter().any(|id| id.starts_with("graph:")), "{ids:?}"); |
| 3661 | assert!(!ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}"); |
| 3662 | |
| 3663 | // One key forward: the agents view, the roster only. |
| 3664 | super::cycle_view(&mut app, true); |
| 3665 | assert_eq!(app.work_surface.panel, super::RailPanel::Agents); |
| 3666 | assert!(app.work_surface.explicit_view); |
| 3667 | let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app) |
| 3668 | .iter() |
| 3669 | .map(|row| row.id.0.clone()) |
| 3670 | .collect(); |
| 3671 | assert!(ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}"); |
| 3672 | assert!( |
| 3673 | !ids.iter().any(|id| id.starts_with("graph:")), |
| 3674 | "the agents view is the roster, not the to-do list: {ids:?}" |
| 3675 | ); |
| 3676 | |
| 3677 | // Back, and Esc hands the choice back to the auto rule. |
| 3678 | super::cycle_view(&mut app, false); |
| 3679 | assert_eq!(app.work_surface.panel, super::RailPanel::Tasks); |
| 3680 | let _ = render_text(&mut app, 80, 8); |
| 3681 | assert!(app.work_surface.focused); |
| 3682 | let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); |
| 3683 | assert!(handled.is_some()); |
| 3684 | assert!(!app.work_surface.explicit_view); |
| 3685 | assert!(app.work_surface.dismissed); |
| 3686 | } |
| 3687 | |
| 3688 | #[test] |
| 3689 | fn cycling_visits_every_view_in_order_and_an_empty_view_still_paints() { |
| 3690 | let mut app = app(); |
| 3691 | add_todos(&mut app, 1); |
| 3692 | let mut seen = vec![]; |
| 3693 | for _ in 0..super::RailPanel::ORDER.len() { |
| 3694 | super::cycle_view(&mut app, true); |
| 3695 | seen.push(app.work_surface.panel); |
| 3696 | let height = super::height(&mut app, 80, 24, AMPLE_BUDGET); |
| 3697 | assert!( |
| 3698 | height > 0, |
| 3699 | "{:?} must keep a strip while explicitly open", |
| 3700 | app.work_surface.panel |
| 3701 | ); |
| 3702 | } |
| 3703 | let mut expected = super::RailPanel::ORDER.to_vec(); |
| 3704 | expected.rotate_left(1); // the fixture starts on tasks, the first tab |
| 3705 | assert_eq!(seen, expected); |
| 3706 | // An empty explicit view names itself instead of going blank. |
| 3707 | super::interaction::select_dock_panel(&mut app, super::RailPanel::Files); |
| 3708 | let text = render_text(&mut app, 80, 5); |
| 3709 | assert!(text.contains("no files touched this session"), "{text}"); |
| 3710 | } |
| 3711 | |
| 3712 | /// The register header is a two-way door: open the Agents panel, then the |
| 3713 | /// same click returns to Tasks, so the to-do list is never stranded. |
| 3714 | #[test] |
| 3715 | fn subagent_header_returns_to_tasks_from_the_agents_view() { |
| 3716 | let mut app = app(); |
| 3717 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 3718 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 3719 | app.current_session_id = Some(SESSION.to_string()); |
| 3720 | app.subagent_cache.push(cached_worker( |
| 3721 | "agent-archived", |
| 3722 | "builder", |
| 3723 | None, |
| 3724 | None, |
| 3725 | SubAgentStatus::Completed, |
| 3726 | )); |
| 3727 | // A finished worker alone opens nothing; the user cycles to agents. |
| 3728 | assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0); |
| 3729 | super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents); |
| 3730 | |
| 3731 | let click_header = |app: &mut App| -> SidebarRowAction { |
| 3732 | let header_y = app |
| 3733 | .work_surface |
| 3734 | .hitboxes |
| 3735 | .iter() |
| 3736 | .find(|hit| hit.id.0 == "section:agents") |
| 3737 | .expect("subagent header is a real hit target") |
| 3738 | .row_y; |
| 3739 | super::handle_mouse( |
| 3740 | app, |
| 3741 | MouseEvent { |
| 3742 | kind: MouseEventKind::Down(MouseButton::Left), |
| 3743 | column: 2, |
| 3744 | row: header_y, |
| 3745 | modifiers: KeyModifiers::NONE, |
| 3746 | }, |
| 3747 | ) |
| 3748 | .action |
| 3749 | .expect("subagent header dispatches its primary action") |
| 3750 | }; |
| 3751 | |
| 3752 | let _ = render_text(&mut app, 100, 6); |
| 3753 | let action = click_header(&mut app); |
| 3754 | assert_eq!(action, SidebarRowAction::ShowSubagentsPanel); |
| 3755 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action); |
| 3756 | assert_eq!( |
| 3757 | app.work_surface.panel, |
| 3758 | super::RailPanel::Tasks, |
| 3759 | "clicking the header inside the register returns to Tasks" |
| 3760 | ); |
| 3761 | } |
| 3762 | |
| 3763 | /// ⌥V opens the selected work row's own details; the transcript pager is |
| 3764 | /// only the fallback when no row is selected (owner report, 0.9.6). |
| 3765 | #[test] |
| 3766 | fn details_chord_opens_the_selected_work_row() { |
| 3767 | let mut app = app(); |
| 3768 | app.current_session_id = Some(SESSION.to_string()); |
| 3769 | app.work_surface.panel = super::RailPanel::Agents; |
| 3770 | add_todos(&mut app, 2); |
| 3771 | let _ = render_text(&mut app, 100, 6); |
| 3772 | |
| 3773 | let rows = super::model::visible_rows_for_panel(&mut app); |
| 3774 | let todo_row = rows |
| 3775 | .iter() |
| 3776 | .find(|row| row.id.0.starts_with("graph:")) |
| 3777 | .expect("a to-do row projects") |
| 3778 | .clone(); |
| 3779 | app.work_surface.focused = true; |
| 3780 | app.work_surface.selected = Some(todo_row.id.clone()); |
| 3781 | |
| 3782 | let handled = super::handle_key( |
| 3783 | &mut app, |
| 3784 | KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), |
| 3785 | ); |
| 3786 | assert!( |
| 3787 | matches!(handled, Some(Some(SidebarRowAction::InspectWork { .. }))), |
| 3788 | "⌥V opens the selected row's own details: {handled:?}" |
| 3789 | ); |
| 3790 | } |
| 3791 | } |
| 3792 |