| 1 | //! Ocean Work Graph surface ownership — **this module is the top bar.** |
| 2 | //! |
| 3 | //! Naming warning, because it has confused readers repeatedly: this is called |
| 4 | //! the "rail" or the "work surface", but [`WorkSurfacePlacement`] defaults to |
| 5 | //! `Top`, so by default it renders as a horizontal strip under the header and |
| 6 | //! above the transcript. "The rail", "the work surface" and "the top bar" all |
| 7 | //! name this module. It is not the header ([`crate::tui::underwater`]) and not |
| 8 | //! the footer. |
| 9 | //! |
| 10 | //! Two settings are orthogonal and are routinely mixed up: |
| 11 | //! |
| 12 | //! - **placement** — where it renders. `Top` (default) | `Left` | `Right` | |
| 13 | //! `Off`. Drag-resizing the divider persists `work_surface_top_height` |
| 14 | //! (2..=16) or `work_surface_side_width` (26..=80) to `settings.toml`. |
| 15 | //! - **panel** — what it shows. [`RailPanel`]: `Tasks` (default) | `Agents` | |
| 16 | //! `Context` | `Pinned`, from the `rail_panel` setting. The legacy |
| 17 | //! `sidebar_focus` key migrates into 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 details / 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 panels; |
| 68 | mod render; |
| 69 | |
| 70 | pub use input::{handle_key, handle_mouse}; |
| 71 | pub(crate) use interaction::agent_details_closed; |
| 72 | pub use model::{RailPanel, WorkSurfacePlacement, WorkSurfaceState}; |
| 73 | pub use render::{height, render, split_chat}; |
| 74 | |
| 75 | #[cfg(test)] |
| 76 | mod tests { |
| 77 | use super::WorkSurfacePlacement; |
| 78 | use std::path::PathBuf; |
| 79 | |
| 80 | use crossterm::event::{ |
| 81 | KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, |
| 82 | }; |
| 83 | use ratatui::{Terminal, backend::TestBackend}; |
| 84 | |
| 85 | use crate::config::Config; |
| 86 | use crate::tools::subagent::{ |
| 87 | AgentWorkerStatus, FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus, |
| 88 | }; |
| 89 | use crate::tools::todo::TodoStatus; |
| 90 | use crate::tui::app::{ |
| 91 | AgentCurrentActivity, AgentCurrentActivityStatus, App, SidebarRowAction, ToolDetailRecord, |
| 92 | TuiOptions, |
| 93 | }; |
| 94 | use crate::tui::history::{ |
| 95 | FileMutationReceipt, GenericToolCell, HistoryCell, PatchSummaryCell, ToolCell, ToolStatus, |
| 96 | }; |
| 97 | use crate::work_graph::{ |
| 98 | AcceptanceRequirement, ChangeCtx, EdgeKind, EvidenceKindTag, NodeKind, NodeState, |
| 99 | OperationBinding, OperationOwnerSnapshot, OwnerState, Provenance, WorkEdge, WorkEdgeId, |
| 100 | WorkGraph, WorkGraphChange, WorkNode, WorkNodeId, |
| 101 | }; |
| 102 | |
| 103 | const SESSION: &str = "work-surface-test"; |
| 104 | |
| 105 | fn app() -> App { |
| 106 | let options = TuiOptions { |
| 107 | use_mouse_capture: true, |
| 108 | max_subagents: 4, |
| 109 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 110 | }; |
| 111 | let mut app = App::new(options, &Config::default()); |
| 112 | app.ui_locale = crate::localization::Locale::En; |
| 113 | // Dogfood guard: App::new reads the developer's real settings.toml, |
| 114 | // and the 0.9.4 migration maps a legacy sidebar_focus onto the rail |
| 115 | // panel. These tests exercise the Tasks panel's row machinery, so |
| 116 | // pin it rather than depend on the host file. |
| 117 | app.work_surface.panel = super::RailPanel::Tasks; |
| 118 | app |
| 119 | } |
| 120 | |
| 121 | /// The row budget `ui::render` would hand the rail on a terminal of this |
| 122 | /// height with real work on screen. Calls the production formula rather |
| 123 | /// than restating it, so a change to the chrome accounting shows up here |
| 124 | /// instead of silently diverging. The idle-empty budget (where the |
| 125 | /// ambient floor bites) is covered end-to-end in `ui::tests`. |
| 126 | fn working_budget(app: &App, terminal_height: u16) -> u16 { |
| 127 | crate::tui::ui::rail_row_budget(app, 80, terminal_height, false) |
| 128 | } |
| 129 | |
| 130 | /// A budget wide enough never to bind, for tests about something else. |
| 131 | const AMPLE_BUDGET: u16 = u16::MAX; |
| 132 | |
| 133 | fn add_todos(app: &mut App, count: usize) { |
| 134 | let mut todos = app.todos.try_lock().expect("todos"); |
| 135 | for index in 0..count { |
| 136 | todos.add( |
| 137 | format!("work item {index}"), |
| 138 | if index == 0 { |
| 139 | TodoStatus::InProgress |
| 140 | } else { |
| 141 | TodoStatus::Pending |
| 142 | }, |
| 143 | ); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | fn operation_graph(state: NodeState) -> crate::work_graph::WorkGraphSnapshot { |
| 148 | let objective = WorkNodeId::derive(SESSION, "objective"); |
| 149 | let operation = WorkNodeId::derive(SESSION, "operation"); |
| 150 | let ctx = |now| ChangeCtx { |
| 151 | session_id: SESSION.to_string(), |
| 152 | now, |
| 153 | idempotency_key: None, |
| 154 | }; |
| 155 | let node = |id: WorkNodeId, kind, title: &str, now| WorkNode { |
| 156 | id, |
| 157 | kind, |
| 158 | title: title.to_string(), |
| 159 | state: NodeState::Ready, |
| 160 | acceptance: Vec::new(), |
| 161 | binding: None, |
| 162 | evidence: None, |
| 163 | provenance: Provenance::RuntimeReconcile { |
| 164 | source: "test-owner".to_string(), |
| 165 | observed_at: now, |
| 166 | }, |
| 167 | created_at: now, |
| 168 | updated_at: now, |
| 169 | }; |
| 170 | let mut graph = WorkGraph::new(); |
| 171 | graph |
| 172 | .apply( |
| 173 | WorkGraphChange::AddNode { |
| 174 | node: node(objective.clone(), NodeKind::Objective, "Ship v0.9.1", 1), |
| 175 | }, |
| 176 | ctx(1), |
| 177 | ) |
| 178 | .expect("objective"); |
| 179 | graph |
| 180 | .apply( |
| 181 | WorkGraphChange::AddNode { |
| 182 | node: node( |
| 183 | operation.clone(), |
| 184 | NodeKind::Operation, |
| 185 | "Verify installed build", |
| 186 | 2, |
| 187 | ), |
| 188 | }, |
| 189 | ctx(2), |
| 190 | ) |
| 191 | .expect("operation"); |
| 192 | graph |
| 193 | .apply( |
| 194 | WorkGraphChange::AddEdge { |
| 195 | edge: WorkEdge { |
| 196 | id: WorkEdgeId::derive(SESSION, "contains"), |
| 197 | kind: EdgeKind::Contains, |
| 198 | from: objective, |
| 199 | to: operation.clone(), |
| 200 | }, |
| 201 | }, |
| 202 | ctx(3), |
| 203 | ) |
| 204 | .expect("contains"); |
| 205 | graph |
| 206 | .apply( |
| 207 | WorkGraphChange::BindOperation { |
| 208 | node: operation.clone(), |
| 209 | binding: OperationBinding { |
| 210 | external: "shell:shell_1234abcd".to_string(), |
| 211 | durable: false, |
| 212 | last_observation: None, |
| 213 | }, |
| 214 | }, |
| 215 | ctx(4), |
| 216 | ) |
| 217 | .expect("binding"); |
| 218 | if state != NodeState::Ready { |
| 219 | graph |
| 220 | .apply( |
| 221 | WorkGraphChange::UpdateNode { |
| 222 | id: operation, |
| 223 | patch: crate::work_graph::WorkNodePatch { |
| 224 | state: Some(state), |
| 225 | ..crate::work_graph::WorkNodePatch::default() |
| 226 | }, |
| 227 | }, |
| 228 | ctx(5), |
| 229 | ) |
| 230 | .expect("state"); |
| 231 | } |
| 232 | graph.into_snapshot() |
| 233 | } |
| 234 | |
| 235 | fn restore_graph(app: &mut App, graph: &crate::work_graph::WorkGraphSnapshot) { |
| 236 | app.current_session_id = Some(SESSION.to_string()); |
| 237 | app.runtime_services |
| 238 | .work |
| 239 | .as_ref() |
| 240 | .expect("Work Graph runtime") |
| 241 | .restore( |
| 242 | SESSION, |
| 243 | Some(graph), |
| 244 | &crate::work_graph::project_todos(graph), |
| 245 | &crate::work_graph::project_plan(graph), |
| 246 | ) |
| 247 | .expect("restore graph"); |
| 248 | } |
| 249 | |
| 250 | fn restore_saved_graph(app: &mut App, graph: &crate::work_graph::WorkGraphSnapshot) { |
| 251 | app.current_session_id = Some(SESSION.to_string()); |
| 252 | let state = crate::session_manager::SessionWorkState { |
| 253 | graph: Some(graph.clone()), |
| 254 | todos: crate::work_graph::project_todos(graph), |
| 255 | plan: crate::work_graph::project_plan(graph), |
| 256 | }; |
| 257 | app.restore_work_state(SESSION, std::path::Path::new("."), Some(&state)) |
| 258 | .expect("restore saved graph"); |
| 259 | } |
| 260 | |
| 261 | fn render_text(app: &mut App, width: u16, height: u16) -> String { |
| 262 | let backend = TestBackend::new(width, height); |
| 263 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 264 | terminal |
| 265 | .draw(|frame| super::render(frame, frame.area(), app)) |
| 266 | .expect("draw"); |
| 267 | terminal |
| 268 | .backend() |
| 269 | .buffer() |
| 270 | .content() |
| 271 | .iter() |
| 272 | .map(|cell| cell.symbol()) |
| 273 | .collect() |
| 274 | } |
| 275 | |
| 276 | #[test] |
| 277 | fn projection_keeps_every_legacy_todo_as_a_graph_row() { |
| 278 | let mut app = app(); |
| 279 | add_todos(&mut app, 4); |
| 280 | |
| 281 | let rows = super::model::project(&mut app); |
| 282 | |
| 283 | assert!( |
| 284 | rows[0].label.starts_with("Work · Running:") |
| 285 | || rows[0] |
| 286 | .label |
| 287 | .starts_with("Work · 1 active · 0 needs input · 3 ready"), |
| 288 | "unexpected heading {}", |
| 289 | rows[0].label |
| 290 | ); |
| 291 | for index in 0..4 { |
| 292 | assert!( |
| 293 | rows.iter() |
| 294 | .any(|row| row.label == format!("work item {index}")) |
| 295 | ); |
| 296 | } |
| 297 | assert!(rows.iter().all(|row| !row.id.0.starts_with("todo:"))); |
| 298 | } |
| 299 | |
| 300 | #[test] |
| 301 | fn coordination_projection_is_one_selectable_work_row_with_shared_details() { |
| 302 | use crate::tools::subagent::CoordinationDetailProjection; |
| 303 | use crate::tools::subagent::coord::{ |
| 304 | CoordinationDetailMetrics, DecisionRecord, DecisionStatus, |
| 305 | }; |
| 306 | |
| 307 | let mut app = app(); |
| 308 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 309 | schema_version: 1, |
| 310 | sequence: 7, |
| 311 | decisions: vec![DecisionRecord { |
| 312 | decision_id: "decision-work".to_string(), |
| 313 | subject: "coordination row".to_string(), |
| 314 | status: DecisionStatus::Accepted, |
| 315 | owner: "release-owner".to_string(), |
| 316 | scope: Vec::new(), |
| 317 | constraints: vec!["PRIVATE-TRANSCRIPT-MARKER".to_string()], |
| 318 | evidence_handles: Vec::new(), |
| 319 | version: 2, |
| 320 | sequence: 7, |
| 321 | }], |
| 322 | write_claims: Vec::new(), |
| 323 | reconciliations: Vec::new(), |
| 324 | context_projections: Vec::new(), |
| 325 | contentions: Vec::new(), |
| 326 | metrics: CoordinationDetailMetrics { |
| 327 | hottest_paths: Vec::new(), |
| 328 | package_or_module_growth: None, |
| 329 | route_or_cost: None, |
| 330 | note: "No active claims".to_string(), |
| 331 | }, |
| 332 | bounded: true, |
| 333 | limit: 24, |
| 334 | process_lock_held: true, |
| 335 | process_lock_note: None, |
| 336 | }); |
| 337 | |
| 338 | let rows = super::model::project(&mut app); |
| 339 | assert_eq!( |
| 340 | rows[0].label, |
| 341 | "Work · 0 active · 0 needs input · 0 ready · 1 recent" |
| 342 | ); |
| 343 | let row = rows |
| 344 | .iter() |
| 345 | .find(|row| row.id.0 == "coordination") |
| 346 | .expect("coordination Work row"); |
| 347 | assert_eq!(row.label, "Coordination Work"); |
| 348 | assert_eq!(row.detail, "1 decisions · 0 contentions · 0 reconciled"); |
| 349 | let Some(SidebarRowAction::InspectWork { title, body, .. }) = row.primary_action.as_ref() |
| 350 | else { |
| 351 | panic!("coordination row must open the shared Work inspector"); |
| 352 | }; |
| 353 | assert_eq!(title, "Coordination Work"); |
| 354 | assert!(body.contains("decision-work · coordination row"), "{body}"); |
| 355 | assert!( |
| 356 | body.contains("status accepted · owner release-owner · version 2"), |
| 357 | "{body}" |
| 358 | ); |
| 359 | assert!(!body.contains("PRIVATE-TRANSCRIPT-MARKER"), "{body}"); |
| 360 | |
| 361 | app.work_surface.placement = WorkSurfacePlacement::Right; |
| 362 | app.work_surface.effective_placement = WorkSurfacePlacement::Right; |
| 363 | let narrow = render_text(&mut app, 32, 4); |
| 364 | assert!(narrow.contains("Coordination Work"), "{narrow}"); |
| 365 | let _ = super::handle_key( |
| 366 | &mut app, |
| 367 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 368 | ); |
| 369 | let action = super::handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) |
| 370 | .expect("Work surface handled Enter") |
| 371 | .expect("coordination inspector action"); |
| 372 | assert!(matches!(action, SidebarRowAction::InspectWork { .. })); |
| 373 | } |
| 374 | |
| 375 | #[test] |
| 376 | fn empty_coordination_projection_does_not_create_work_chrome() { |
| 377 | use crate::tools::subagent::CoordinationDetailProjection; |
| 378 | use crate::tools::subagent::coord::{ContextProjectionReceipt, CoordinationDetailMetrics}; |
| 379 | |
| 380 | let mut app = app(); |
| 381 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 382 | schema_version: 1, |
| 383 | sequence: 3, |
| 384 | decisions: Vec::new(), |
| 385 | write_claims: Vec::new(), |
| 386 | reconciliations: Vec::new(), |
| 387 | context_projections: ["agent-a", "agent-b", "agent-c"] |
| 388 | .into_iter() |
| 389 | .enumerate() |
| 390 | .map(|(index, child_id)| ContextProjectionReceipt { |
| 391 | child_id: child_id.to_string(), |
| 392 | decision_ids: Vec::new(), |
| 393 | projected_bytes: 0, |
| 394 | deduplicated: 0, |
| 395 | omitted: 0, |
| 396 | sequence: u64::try_from(index + 1).expect("small fixture sequence"), |
| 397 | }) |
| 398 | .collect(), |
| 399 | contentions: Vec::new(), |
| 400 | metrics: CoordinationDetailMetrics { |
| 401 | hottest_paths: Vec::new(), |
| 402 | package_or_module_growth: None, |
| 403 | route_or_cost: None, |
| 404 | note: "growth and route/cost stay null when the coordination ledger has no authoritative source".to_string(), |
| 405 | }, |
| 406 | bounded: true, |
| 407 | limit: 24, |
| 408 | process_lock_held: true, |
| 409 | process_lock_note: None, |
| 410 | }); |
| 411 | |
| 412 | let rows = super::model::project(&mut app); |
| 413 | assert!( |
| 414 | rows.is_empty(), |
| 415 | "zero-byte, no-decision coordination receipts must not create Work chrome: {rows:?}" |
| 416 | ); |
| 417 | } |
| 418 | |
| 419 | #[test] |
| 420 | fn nonempty_context_projection_remains_inspectable_work() { |
| 421 | use crate::tools::subagent::CoordinationDetailProjection; |
| 422 | use crate::tools::subagent::coord::{ContextProjectionReceipt, CoordinationDetailMetrics}; |
| 423 | |
| 424 | let mut app = app(); |
| 425 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 426 | schema_version: 1, |
| 427 | sequence: 1, |
| 428 | decisions: Vec::new(), |
| 429 | write_claims: Vec::new(), |
| 430 | reconciliations: Vec::new(), |
| 431 | context_projections: vec![ContextProjectionReceipt { |
| 432 | child_id: "agent-a".to_string(), |
| 433 | decision_ids: vec!["decision-a".to_string()], |
| 434 | projected_bytes: 32, |
| 435 | deduplicated: 0, |
| 436 | omitted: 0, |
| 437 | sequence: 1, |
| 438 | }], |
| 439 | contentions: Vec::new(), |
| 440 | metrics: CoordinationDetailMetrics { |
| 441 | hottest_paths: Vec::new(), |
| 442 | package_or_module_growth: None, |
| 443 | route_or_cost: None, |
| 444 | note: String::new(), |
| 445 | }, |
| 446 | bounded: true, |
| 447 | limit: 24, |
| 448 | process_lock_held: true, |
| 449 | process_lock_note: None, |
| 450 | }); |
| 451 | |
| 452 | let rows = super::model::project(&mut app); |
| 453 | assert!( |
| 454 | rows.iter().any(|row| row.id.0 == "coordination"), |
| 455 | "non-empty context projection must remain inspectable: {rows:?}" |
| 456 | ); |
| 457 | } |
| 458 | |
| 459 | #[test] |
| 460 | fn current_blocked_contention_uses_attention_bucket_mark_and_tone() { |
| 461 | use crate::tools::subagent::CoordinationDetailProjection; |
| 462 | use crate::tools::subagent::coord::{ |
| 463 | CoordinationDetailMetrics, PersistedWriteClaim, WriteContentionDisposition, |
| 464 | WriteContentionReceipt, WriteScopeClaim, |
| 465 | }; |
| 466 | |
| 467 | let mut app = app(); |
| 468 | app.coordination_detail = Some(CoordinationDetailProjection { |
| 469 | schema_version: 1, |
| 470 | sequence: 2, |
| 471 | decisions: Vec::new(), |
| 472 | write_claims: vec![PersistedWriteClaim { |
| 473 | claim: WriteScopeClaim { |
| 474 | owner: "worker-a".to_string(), |
| 475 | roots: vec!["crates/tui".to_string()], |
| 476 | exact_files: Vec::new(), |
| 477 | contracts: vec!["ui-contract".to_string()], |
| 478 | }, |
| 479 | sequence: 1, |
| 480 | isolated_worktree: false, |
| 481 | }], |
| 482 | reconciliations: Vec::new(), |
| 483 | context_projections: Vec::new(), |
| 484 | contentions: vec![WriteContentionReceipt { |
| 485 | claimant: "worker-b".to_string(), |
| 486 | conflicting_owner: "worker-a".to_string(), |
| 487 | roots: vec!["crates/tui".to_string()], |
| 488 | exact_files: Vec::new(), |
| 489 | contracts: vec!["ui-contract".to_string()], |
| 490 | disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization, |
| 491 | resolution_sequence: None, |
| 492 | sequence: 2, |
| 493 | }], |
| 494 | metrics: CoordinationDetailMetrics { |
| 495 | hottest_paths: Vec::new(), |
| 496 | package_or_module_growth: None, |
| 497 | route_or_cost: None, |
| 498 | note: "No authoritative metric source".to_string(), |
| 499 | }, |
| 500 | bounded: true, |
| 501 | limit: 24, |
| 502 | process_lock_held: true, |
| 503 | process_lock_note: None, |
| 504 | }); |
| 505 | |
| 506 | let rows = super::model::project(&mut app); |
| 507 | assert_eq!( |
| 508 | rows[0].label, |
| 509 | "Work · Needs input: Coordination Work · 1 blocked" |
| 510 | ); |
| 511 | let row = rows |
| 512 | .iter() |
| 513 | .find(|row| row.id.0 == "coordination") |
| 514 | .expect("blocked coordination Work row"); |
| 515 | assert_eq!(row.mark, crate::tui::glyphs::ATTENTION); |
| 516 | assert_eq!(row.tone, super::model::WorkTone::Attention); |
| 517 | assert_eq!(row.detail, "0 decisions · 1 contentions · 0 reconciled"); |
| 518 | } |
| 519 | |
| 520 | #[test] |
| 521 | fn todos_share_one_canonical_work_projection_without_a_second_heading() { |
| 522 | let mut app = app(); |
| 523 | { |
| 524 | let mut todos = app.todos.try_lock().expect("todos"); |
| 525 | todos.add("finished".to_string(), TodoStatus::Completed); |
| 526 | todos.add("current".to_string(), TodoStatus::InProgress); |
| 527 | todos.add("next".to_string(), TodoStatus::Pending); |
| 528 | } |
| 529 | |
| 530 | let rows = super::model::project(&mut app); |
| 531 | |
| 532 | assert!( |
| 533 | rows[0].label.starts_with("Work · Running:") |
| 534 | || rows[0].label.starts_with("Work · Ready:"), |
| 535 | "expected actionable title heading, got {}", |
| 536 | rows[0].label |
| 537 | ); |
| 538 | assert_eq!( |
| 539 | rows.iter() |
| 540 | .skip(1) |
| 541 | .map(|row| row.label.as_str()) |
| 542 | .collect::<Vec<_>>(), |
| 543 | ["finished", "current", "next"] |
| 544 | ); |
| 545 | } |
| 546 | |
| 547 | #[test] |
| 548 | fn top_surface_pins_one_progress_receipt_and_numbers_canonical_rows() { |
| 549 | let mut app = app(); |
| 550 | { |
| 551 | let mut todos = app.todos.try_lock().expect("todos"); |
| 552 | todos.add("finished".to_string(), TodoStatus::Completed); |
| 553 | todos.add("current".to_string(), TodoStatus::InProgress); |
| 554 | todos.add("next".to_string(), TodoStatus::Pending); |
| 555 | } |
| 556 | |
| 557 | let text = render_text(&mut app, 80, 6); |
| 558 | let done = format!("1 · {} finished", crate::tui::glyphs::DONE); |
| 559 | let current = format!("2 · {} current", crate::tui::glyphs::SELECTION); |
| 560 | let next = format!("3 · {} next", crate::tui::glyphs::READY); |
| 561 | |
| 562 | assert!(text.contains("To-do · 1/3 · 2 left"), "{text:?}"); |
| 563 | assert_eq!(text.matches("To-do ·").count(), 1, "{text:?}"); |
| 564 | assert!(text.contains(&done), "{text:?}"); |
| 565 | assert!(text.contains(¤t), "{text:?}"); |
| 566 | assert!(text.contains(&next), "{text:?}"); |
| 567 | assert!( |
| 568 | text.find(&done) < text.find(¤t) && text.find(¤t) < text.find(&next), |
| 569 | "canonical order drifted: {text:?}" |
| 570 | ); |
| 571 | assert_eq!(app.work_surface.hitboxes.len(), 3); |
| 572 | assert_eq!(app.work_surface.hitboxes[0].row_y, 1); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn top_strip_auto_fits_step_count_up_to_caps() { |
| 577 | // Two steps: divider + progress receipt + 2 rows = 4 lines, not a |
| 578 | // fixed-height band of blank water. |
| 579 | let mut two_steps = app(); |
| 580 | two_steps.work_surface.top_height = 8; |
| 581 | add_todos(&mut two_steps, 2); |
| 582 | let budget = working_budget(&two_steps, 40); |
| 583 | assert_eq!(super::height(&mut two_steps, 100, 40, budget), 4); |
| 584 | |
| 585 | // Ten steps: content wants 12 lines, the default 8-line cap wins. |
| 586 | let mut ten_steps = app(); |
| 587 | ten_steps.work_surface.top_height = 8; |
| 588 | add_todos(&mut ten_steps, 10); |
| 589 | let budget = working_budget(&ten_steps, 40); |
| 590 | assert_eq!(super::height(&mut ten_steps, 100, 40, budget), 8); |
| 591 | |
| 592 | // Short terminal: the transcript's spare rows beat both content and |
| 593 | // the configured cap. A 12-row terminal spends 1 on the header, 1 on |
| 594 | // the phase strip and 3 on the bordered composer, and owes the |
| 595 | // transcript its 3-row floor — so 4 rows are actually spare. (This |
| 596 | // used to be 6, half the terminal, which left the transcript 2 rows.) |
| 597 | let mut short_terminal = app(); |
| 598 | short_terminal.work_surface.top_height = 8; |
| 599 | add_todos(&mut short_terminal, 10); |
| 600 | let budget = working_budget(&short_terminal, 12); |
| 601 | assert_eq!(super::height(&mut short_terminal, 100, 12, budget), 4); |
| 602 | |
| 603 | // Nothing to show: no strip at all. |
| 604 | let mut empty = app(); |
| 605 | empty.work_surface.top_height = 8; |
| 606 | assert_eq!(super::height(&mut empty, 100, 40, AMPLE_BUDGET), 0); |
| 607 | } |
| 608 | |
| 609 | /// A strip that reports zero rows is not on screen, so the interaction |
| 610 | /// state describing it must go with it. Stale hitboxes outlive the rows |
| 611 | /// they described: the transcript rows that replaced the strip would keep |
| 612 | /// routing clicks into a panel that is not there. |
| 613 | #[test] |
| 614 | fn a_yielded_strip_drops_its_interaction_state() { |
| 615 | // Each case is a distinct zero-return inside `height`, and every one |
| 616 | // of them has to tear down. `starve` turns a rendered strip into a |
| 617 | // yielded one; the assertions are identical either way. The first two |
| 618 | // are the returns this yield rule introduced — the ones that had no |
| 619 | // teardown at all. |
| 620 | type Starve = fn(&mut App) -> (u16, u16, u16); |
| 621 | let cases: [(&str, Starve); 3] = [ |
| 622 | ("budget starves the Tasks strip", |_app| (100, 40, 0)), |
| 623 | ("budget starves a switched-to panel", |app| { |
| 624 | app.work_surface.panel = super::RailPanel::Pinned; |
| 625 | (100, 40, 0) |
| 626 | }), |
| 627 | ("placement off", |app| { |
| 628 | app.work_surface.placement = WorkSurfacePlacement::Off; |
| 629 | (100, 40, AMPLE_BUDGET) |
| 630 | }), |
| 631 | ]; |
| 632 | |
| 633 | for (label, starve) in cases { |
| 634 | let mut app = app(); |
| 635 | app.work_surface.placement = WorkSurfacePlacement::Top; |
| 636 | // `app()` reads the developer's real settings.toml. Pin the height |
| 637 | // too, or the strip this test renders to earn its hitboxes depends |
| 638 | // on whoever runs the suite. |
| 639 | app.work_surface.top_height = 8; |
| 640 | add_todos(&mut app, 4); |
| 641 | |
| 642 | // Earn a real strip, so the hitboxes under test are the ones the |
| 643 | // renderer actually produces rather than a fixture's guess. |
| 644 | render_text(&mut app, 100, 12); |
| 645 | assert!( |
| 646 | !app.work_surface.hitboxes.is_empty(), |
| 647 | "{label}: setup never rendered a strip to tear down" |
| 648 | ); |
| 649 | app.work_surface.focused = true; |
| 650 | app.work_surface.resizing = true; |
| 651 | app.work_surface.divider_hovered = true; |
| 652 | |
| 653 | let (width, height, budget) = starve(&mut app); |
| 654 | assert_eq!( |
| 655 | super::height(&mut app, width, height, budget), |
| 656 | 0, |
| 657 | "{label}: expected the strip to yield" |
| 658 | ); |
| 659 | assert!( |
| 660 | app.work_surface.hitboxes.is_empty(), |
| 661 | "{label}: left {} stale hitboxes behind", |
| 662 | app.work_surface.hitboxes.len() |
| 663 | ); |
| 664 | assert!( |
| 665 | app.work_surface.last_area.is_none(), |
| 666 | "{label}: stale last_area" |
| 667 | ); |
| 668 | assert!(!app.work_surface.focused, "{label}: focus survived"); |
| 669 | assert!(!app.work_surface.resizing, "{label}: resize drag survived"); |
| 670 | assert!( |
| 671 | !app.work_surface.divider_hovered, |
| 672 | "{label}: divider hover survived" |
| 673 | ); |
| 674 | } |
| 675 | } |
| 676 | |
| 677 | /// `top_height` is a ceiling, not a fixed size. A short ceiling must still |
| 678 | /// render (not collapse), and content longer than the ceiling is clamped |
| 679 | /// to it rather than padded with blank water. |
| 680 | #[test] |
| 681 | fn a_short_top_height_caps_content_rather_than_collapsing() { |
| 682 | let mut capped = app(); |
| 683 | capped.work_surface.placement = WorkSurfacePlacement::Top; |
| 684 | capped.work_surface.panel = super::RailPanel::Pinned; |
| 685 | capped.work_surface.top_height = 2; |
| 686 | capped.composer_border = true; |
| 687 | // Goal + several checklist rows: content wants more than 2, the cap wins. |
| 688 | capped.hunt.quarry = Some("ship the release".to_string()); |
| 689 | add_todos(&mut capped, 6); |
| 690 | let budget = working_budget(&capped, 40); |
| 691 | assert_eq!( |
| 692 | super::height(&mut capped, 100, 40, budget), |
| 693 | 2, |
| 694 | "short top_height is a cap the strip must fit under, not a cliff" |
| 695 | ); |
| 696 | |
| 697 | // Content shorter than the cap shrinks: a single goal line + divider |
| 698 | // is 2 rows, not a padded 8-row band. |
| 699 | let mut short = app(); |
| 700 | short.work_surface.placement = WorkSurfacePlacement::Top; |
| 701 | short.work_surface.panel = super::RailPanel::Pinned; |
| 702 | short.work_surface.top_height = 8; |
| 703 | short.hunt.quarry = Some("one goal only".to_string()); |
| 704 | let budget = working_budget(&short, 40); |
| 705 | let h = super::height(&mut short, 100, 40, budget); |
| 706 | assert!( |
| 707 | (2..=4).contains(&h), |
| 708 | "short content auto-fits under the cap, got {h}" |
| 709 | ); |
| 710 | } |
| 711 | |
| 712 | /// Non-Tasks Top panels auto-fit the same way Tasks always did: content |
| 713 | /// rows + divider, never a fixed four-row chrome band. An active goal |
| 714 | /// adds exactly one title row (not a panel name). |
| 715 | #[test] |
| 716 | fn top_panel_auto_fits_content_like_tasks() { |
| 717 | let mut pinned = app(); |
| 718 | pinned.work_surface.placement = WorkSurfacePlacement::Top; |
| 719 | pinned.work_surface.panel = super::RailPanel::Pinned; |
| 720 | pinned.work_surface.top_height = 12; |
| 721 | pinned.hunt.quarry = Some("goal".to_string()); |
| 722 | add_todos(&mut pinned, 3); |
| 723 | let budget = working_budget(&pinned, 40); |
| 724 | let h = super::height(&mut pinned, 100, 40, budget); |
| 725 | // goal title + 3 checklist + divider ≈ 5; must not be the old fixed 4, |
| 726 | // and must not pad out to the 12-row cap. |
| 727 | assert!( |
| 728 | (4..=8).contains(&h), |
| 729 | "Pinned should auto-fit checklist content, got {h}" |
| 730 | ); |
| 731 | |
| 732 | // Empty Pinned collapses entirely. |
| 733 | let mut empty = app(); |
| 734 | empty.work_surface.placement = WorkSurfacePlacement::Top; |
| 735 | empty.work_surface.panel = super::RailPanel::Pinned; |
| 736 | empty.work_surface.top_height = 12; |
| 737 | assert_eq!( |
| 738 | super::height(&mut empty, 100, 40, AMPLE_BUDGET), |
| 739 | 0, |
| 740 | "empty Pinned is not a panel" |
| 741 | ); |
| 742 | |
| 743 | // Empty Agents collapses too (no "No agents" chrome strip). |
| 744 | let mut agents = app(); |
| 745 | agents.work_surface.placement = WorkSurfacePlacement::Top; |
| 746 | agents.work_surface.panel = super::RailPanel::Agents; |
| 747 | agents.work_surface.top_height = 12; |
| 748 | assert_eq!( |
| 749 | super::height(&mut agents, 100, 40, AMPLE_BUDGET), |
| 750 | 0, |
| 751 | "empty Agents is not a panel" |
| 752 | ); |
| 753 | } |
| 754 | |
| 755 | /// Top titles only when a live goal is set — never the panel name. |
| 756 | #[test] |
| 757 | fn top_title_is_goal_only_never_panel_chrome() { |
| 758 | // With a goal: title is "Goal: …". |
| 759 | let mut with_goal = app(); |
| 760 | with_goal.work_surface.placement = WorkSurfacePlacement::Top; |
| 761 | with_goal.work_surface.panel = super::RailPanel::Pinned; |
| 762 | with_goal.work_surface.top_height = 8; |
| 763 | with_goal.hunt.quarry = Some("ship 0.9.4".to_string()); |
| 764 | let text = render_text(&mut with_goal, 80, 8); |
| 765 | assert!( |
| 766 | text.contains("Goal: ship 0.9.4"), |
| 767 | "active goal must be the Top title: {text:?}" |
| 768 | ); |
| 769 | assert!( |
| 770 | !text.contains("Pinned"), |
| 771 | "panel name is not a Top title: {text:?}" |
| 772 | ); |
| 773 | |
| 774 | // Without a goal, only checklist: no Goal title, no Pinned chrome. |
| 775 | let mut no_goal = app(); |
| 776 | no_goal.work_surface.placement = WorkSurfacePlacement::Top; |
| 777 | no_goal.work_surface.panel = super::RailPanel::Pinned; |
| 778 | no_goal.work_surface.top_height = 8; |
| 779 | add_todos(&mut no_goal, 2); |
| 780 | let text = render_text(&mut no_goal, 80, 6); |
| 781 | assert!( |
| 782 | !text.contains("Goal:"), |
| 783 | "no live goal → no Goal title: {text:?}" |
| 784 | ); |
| 785 | assert!( |
| 786 | !text.contains("Pinned"), |
| 787 | "panel name is never a Top title: {text:?}" |
| 788 | ); |
| 789 | } |
| 790 | |
| 791 | /// Tasks with only a goal (no todos/agents) still shows a strip. |
| 792 | #[test] |
| 793 | fn top_tasks_goal_alone_still_renders_a_strip() { |
| 794 | let mut app = app(); |
| 795 | app.work_surface.placement = WorkSurfacePlacement::Top; |
| 796 | app.work_surface.panel = super::RailPanel::Tasks; |
| 797 | app.work_surface.top_height = 8; |
| 798 | app.hunt.quarry = Some("only a goal".to_string()); |
| 799 | let budget = working_budget(&app, 40); |
| 800 | let h = super::height(&mut app, 100, 40, budget); |
| 801 | assert!(h >= 2, "goal alone must reserve title + divider, got {h}"); |
| 802 | let text = render_text(&mut app, 80, h); |
| 803 | assert!( |
| 804 | text.contains("Goal: only a goal"), |
| 805 | "goal-alone strip must paint the title: {text:?}" |
| 806 | ); |
| 807 | } |
| 808 | |
| 809 | /// Side rails share the empty-collapse rule: no content → no column. |
| 810 | /// Width stays the configured ceiling when content exists. |
| 811 | #[test] |
| 812 | fn side_rail_collapses_when_empty_and_reserves_when_contentful() { |
| 813 | let area = ratatui::layout::Rect::new(0, 0, 120, 32); |
| 814 | |
| 815 | // Empty Pinned: no side column. |
| 816 | let mut empty = app(); |
| 817 | empty.work_surface.placement = WorkSurfacePlacement::Right; |
| 818 | empty.work_surface.panel = super::RailPanel::Pinned; |
| 819 | empty.work_surface.side_width = 30; |
| 820 | assert_eq!( |
| 821 | super::split_chat(&mut empty, area, 0), |
| 822 | (area, None), |
| 823 | "empty Pinned must not reserve a side column" |
| 824 | ); |
| 825 | |
| 826 | // Contentful Pinned: full-height column at configured width. |
| 827 | let mut full = app(); |
| 828 | full.work_surface.placement = WorkSurfacePlacement::Right; |
| 829 | full.work_surface.panel = super::RailPanel::Pinned; |
| 830 | full.work_surface.side_width = 30; |
| 831 | full.hunt.quarry = Some("ship it".to_string()); |
| 832 | let (chat, rail) = super::split_chat(&mut full, area, 0); |
| 833 | let rail = rail.expect("contentful Pinned reserves a side rail"); |
| 834 | assert_eq!(rail.width, 30); |
| 835 | assert_eq!(chat.width, area.width - 30); |
| 836 | assert_eq!(rail.height, area.height); |
| 837 | } |
| 838 | |
| 839 | #[test] |
| 840 | fn minimum_top_surface_keeps_a_numbered_todo_selectable() { |
| 841 | let mut app = app(); |
| 842 | add_todos(&mut app, 2); |
| 843 | |
| 844 | let text = render_text(&mut app, 40, 2); |
| 845 | |
| 846 | assert!(text.contains("1 ·"), "{text:?}"); |
| 847 | assert!(!text.contains("To-do · 0/"), "{text:?}"); |
| 848 | assert_eq!(app.work_surface.hitboxes.len(), 1); |
| 849 | assert_eq!(app.work_surface.hitboxes[0].row_y, 0); |
| 850 | } |
| 851 | |
| 852 | #[test] |
| 853 | fn compact_progress_window_reveals_current_without_reordering() { |
| 854 | let mut app = app(); |
| 855 | { |
| 856 | let mut todos = app.todos.try_lock().expect("todos"); |
| 857 | todos.add("finished".to_string(), TodoStatus::Completed); |
| 858 | todos.add("current".to_string(), TodoStatus::InProgress); |
| 859 | todos.add("next".to_string(), TodoStatus::Pending); |
| 860 | } |
| 861 | |
| 862 | // Three rows means one pinned progress receipt, one selectable row, |
| 863 | // and the divider. The current item must win that compact window while |
| 864 | // retaining its canonical ordinal. |
| 865 | let text = render_text(&mut app, 80, 3); |
| 866 | |
| 867 | assert!(text.contains("To-do · 1/3 · 2 left"), "{text:?}"); |
| 868 | assert!( |
| 869 | text.contains(&format!("2 · {} current", crate::tui::glyphs::SELECTION)), |
| 870 | "{text:?}" |
| 871 | ); |
| 872 | assert_eq!(app.work_surface.scroll_offset, 1); |
| 873 | assert_eq!(app.work_surface.hitboxes[0].row_y, 1); |
| 874 | } |
| 875 | |
| 876 | #[test] |
| 877 | fn settled_file_tools_aggregate_once_and_keep_only_safe_targets() { |
| 878 | let mut app = app(); |
| 879 | app.current_session_id = Some(SESSION.to_string()); |
| 880 | app.workspace = PathBuf::from("/workspace/project"); |
| 881 | for (id, name, input, status) in [ |
| 882 | ( |
| 883 | "read-1", |
| 884 | "read_file", |
| 885 | serde_json::json!({"path": "/workspace/project/src/lib.rs"}), |
| 886 | ToolStatus::Success, |
| 887 | ), |
| 888 | ( |
| 889 | "search-1", |
| 890 | "grep_files", |
| 891 | serde_json::json!({"pattern": "WorkSurfaceState"}), |
| 892 | ToolStatus::Success, |
| 893 | ), |
| 894 | ( |
| 895 | "write-1", |
| 896 | "edit_file", |
| 897 | serde_json::json!({"path": "src/lib.rs"}), |
| 898 | ToolStatus::Success, |
| 899 | ), |
| 900 | ( |
| 901 | "read-external", |
| 902 | "read_file", |
| 903 | serde_json::json!({"path": "/Users/alice/private.txt"}), |
| 904 | ToolStatus::Failed, |
| 905 | ), |
| 906 | ] { |
| 907 | app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 908 | name: name.to_string(), |
| 909 | status, |
| 910 | input_summary: None, |
| 911 | output: Some("done".to_string()), |
| 912 | prompts: None, |
| 913 | spillover_path: None, |
| 914 | output_summary: None, |
| 915 | is_diff: false, |
| 916 | }))); |
| 917 | let index = app.history.len() - 1; |
| 918 | app.tool_details_by_cell.insert( |
| 919 | index, |
| 920 | ToolDetailRecord { |
| 921 | tool_id: id.to_string(), |
| 922 | tool_name: name.to_string(), |
| 923 | input, |
| 924 | output: Some("done".to_string()), |
| 925 | }, |
| 926 | ); |
| 927 | } |
| 928 | |
| 929 | let rows = super::model::project(&mut app); |
| 930 | let activity = rows |
| 931 | .iter() |
| 932 | .find(|row| row.id.0 == "activity:aggregate") |
| 933 | .expect("aggregated activity row"); |
| 934 | assert!( |
| 935 | activity.label.contains("Read 1 files") |
| 936 | && activity.label.contains("Searched 1 patterns") |
| 937 | && activity.label.contains("Wrote 1 files"), |
| 938 | "aggregated label: {}", |
| 939 | activity.label |
| 940 | ); |
| 941 | assert!(!activity.detail.contains("/Users/alice")); |
| 942 | assert!(!activity.label.contains("WorkSurfaceState")); |
| 943 | } |
| 944 | |
| 945 | #[test] |
| 946 | fn agent_rows_show_role_assignment_and_open_real_agent_details() { |
| 947 | let mut app = app(); |
| 948 | app.current_session_id = Some(SESSION.to_string()); |
| 949 | app.subagent_cache.push(SubAgentResult { |
| 950 | name: "agent_worker".to_string(), |
| 951 | agent_id: "agent_worker".to_string(), |
| 952 | context_mode: "fresh".to_string(), |
| 953 | fork_context: false, |
| 954 | workspace: None, |
| 955 | git_branch: None, |
| 956 | agent_type: FleetRole::Builder, |
| 957 | assignment: SubAgentAssignment { |
| 958 | objective: "Wire settled file activity".to_string(), |
| 959 | role: Some("worker".to_string()), |
| 960 | }, |
| 961 | model: "test-model".to_string(), |
| 962 | nickname: Some("Blue Whale".to_string()), |
| 963 | status: SubAgentStatus::Running, |
| 964 | worker_status: Some(AgentWorkerStatus::RunningTool), |
| 965 | runtime_permissions: None, |
| 966 | parent_run_id: None, |
| 967 | spawn_depth: 1, |
| 968 | result: None, |
| 969 | steps_taken: 2, |
| 970 | checkpoint: None, |
| 971 | needs_input: None, |
| 972 | duration_ms: 50, |
| 973 | from_prior_session: false, |
| 974 | }); |
| 975 | app.agent_progress_meta.insert( |
| 976 | "agent_worker".to_string(), |
| 977 | crate::tui::app::AgentProgressMeta { |
| 978 | current_activity: Some(AgentCurrentActivity::bounded( |
| 979 | AgentCurrentActivityStatus::RunningTool, |
| 980 | None, |
| 981 | Some("File.apply_patch".to_string()), |
| 982 | Some(2), |
| 983 | )), |
| 984 | current_tool: Some("apply_patch".to_string()), |
| 985 | files_touched: 2, |
| 986 | ..crate::tui::app::AgentProgressMeta::default() |
| 987 | }, |
| 988 | ); |
| 989 | |
| 990 | let rows = super::model::project(&mut app); |
| 991 | let row = rows |
| 992 | .iter() |
| 993 | .find(|row| row.id.0 == "worker:agent_worker") |
| 994 | .expect("agent work row"); |
| 995 | // The identity column leads with the agent's nickname and keeps the |
| 996 | // fleet role as the fallback spelling. It is never the raw agent id |
| 997 | // (#36), and carries no `(+N)` while the agent is childless. |
| 998 | assert_eq!(row.label, "Blue Whale"); |
| 999 | let facts = row.agent.as_ref().expect("agent row facts"); |
| 1000 | assert_eq!(facts.role_label, "worker"); |
| 1001 | assert_eq!(facts.objective, "Wire settled file activity"); |
| 1002 | assert_eq!(facts.elapsed_secs, Some(0)); |
| 1003 | // No usage envelope has been seen, so there is no token figure at all. |
| 1004 | assert_eq!(facts.tokens, None); |
| 1005 | assert!(row.detail.contains("Wire settled file activity")); |
| 1006 | assert!(row.detail.contains("using File.apply_patch")); |
| 1007 | assert!(row.detail.contains("step 2")); |
| 1008 | assert!(row.detail.contains("2 files changed")); |
| 1009 | assert_eq!( |
| 1010 | row.primary_action, |
| 1011 | Some(SidebarRowAction::OpenAgentDetail { |
| 1012 | agent_id: "agent_worker".to_string(), |
| 1013 | }) |
| 1014 | ); |
| 1015 | } |
| 1016 | |
| 1017 | fn cached_worker( |
| 1018 | id: &str, |
| 1019 | role: &str, |
| 1020 | nickname: Option<&str>, |
| 1021 | parent_run_id: Option<&str>, |
| 1022 | status: SubAgentStatus, |
| 1023 | ) -> SubAgentResult { |
| 1024 | SubAgentResult { |
| 1025 | // `name` is the raw session id in production snapshots — the |
| 1026 | // strip must never render it (#36). |
| 1027 | name: id.to_string(), |
| 1028 | agent_id: id.to_string(), |
| 1029 | context_mode: "fresh".to_string(), |
| 1030 | fork_context: false, |
| 1031 | workspace: None, |
| 1032 | git_branch: None, |
| 1033 | agent_type: FleetRole::Builder, |
| 1034 | assignment: SubAgentAssignment { |
| 1035 | objective: format!("objective for {id}"), |
| 1036 | role: Some(role.to_string()), |
| 1037 | }, |
| 1038 | model: "test-model".to_string(), |
| 1039 | nickname: nickname.map(str::to_string), |
| 1040 | status, |
| 1041 | worker_status: None, |
| 1042 | runtime_permissions: None, |
| 1043 | parent_run_id: parent_run_id.map(str::to_string), |
| 1044 | spawn_depth: u32::from(parent_run_id.is_some()) + 1, |
| 1045 | result: None, |
| 1046 | steps_taken: 1, |
| 1047 | checkpoint: None, |
| 1048 | needs_input: None, |
| 1049 | duration_ms: 50, |
| 1050 | from_prior_session: false, |
| 1051 | } |
| 1052 | } |
| 1053 | |
| 1054 | #[test] |
| 1055 | fn agent_rows_identify_by_fleet_role_and_never_leak_raw_ids() { |
| 1056 | // #36: the strip identifies an agent by its fleet role; the raw agent |
| 1057 | // id hash is noise and must never render as the "name". Flat fan-outs |
| 1058 | // carry no nesting chrome. |
| 1059 | let mut app = app(); |
| 1060 | app.current_session_id = Some(SESSION.to_string()); |
| 1061 | app.subagent_cache.push(cached_worker( |
| 1062 | "agent_e0b2dcf1", |
| 1063 | "builder", |
| 1064 | None, |
| 1065 | None, |
| 1066 | SubAgentStatus::Running, |
| 1067 | )); |
| 1068 | app.subagent_cache.push(cached_worker( |
| 1069 | "agent_99aa77bb", |
| 1070 | "scout", |
| 1071 | None, |
| 1072 | None, |
| 1073 | SubAgentStatus::Running, |
| 1074 | )); |
| 1075 | |
| 1076 | let rows = super::model::project(&mut app); |
| 1077 | let first = rows |
| 1078 | .iter() |
| 1079 | .find(|row| row.id.0 == "worker:agent_e0b2dcf1") |
| 1080 | .expect("first agent row"); |
| 1081 | let second = rows |
| 1082 | .iter() |
| 1083 | .find(|row| row.id.0 == "worker:agent_99aa77bb") |
| 1084 | .expect("second agent row"); |
| 1085 | assert_eq!(first.label, "builder"); |
| 1086 | assert_eq!(second.label, "scout"); |
| 1087 | assert!(first.detail.starts_with("running"), "{}", first.detail); |
| 1088 | for row in rows.iter().filter(|row| row.id.0.starts_with("worker:")) { |
| 1089 | assert!(!row.label.contains("agent_e0b2dcf1"), "{}", row.label); |
| 1090 | assert!(!row.label.contains("agent_99aa77bb"), "{}", row.label); |
| 1091 | assert!( |
| 1092 | !row.label.contains('↳'), |
| 1093 | "flat fan-out must not show nesting chrome: {}", |
| 1094 | row.label |
| 1095 | ); |
| 1096 | } |
| 1097 | } |
| 1098 | |
| 1099 | #[test] |
| 1100 | fn agent_rows_order_and_indent_nested_spawns_under_their_parent() { |
| 1101 | // #36: nesting is visible only when actually present — the child |
| 1102 | // renders directly under its parent with a `↳` indent, and the parent |
| 1103 | // advertises the child it spawned as `(+1)`. |
| 1104 | let mut app = app(); |
| 1105 | app.current_session_id = Some(SESSION.to_string()); |
| 1106 | app.subagent_cache.push(cached_worker( |
| 1107 | "agent_child", |
| 1108 | "scout", |
| 1109 | None, |
| 1110 | Some("agent_parent"), |
| 1111 | SubAgentStatus::Running, |
| 1112 | )); |
| 1113 | app.subagent_cache.push(cached_worker( |
| 1114 | "agent_parent", |
| 1115 | "builder", |
| 1116 | None, |
| 1117 | None, |
| 1118 | SubAgentStatus::Running, |
| 1119 | )); |
| 1120 | |
| 1121 | let rows = super::model::project(&mut app); |
| 1122 | let worker_labels = rows |
| 1123 | .iter() |
| 1124 | .filter(|row| row.id.0.starts_with("worker:")) |
| 1125 | .map(|row| row.label.as_str()) |
| 1126 | .collect::<Vec<_>>(); |
| 1127 | let parent_pos = worker_labels |
| 1128 | .iter() |
| 1129 | .position(|label| *label == "builder (+1)") |
| 1130 | .expect("parent row label with child count"); |
| 1131 | let child_pos = worker_labels |
| 1132 | .iter() |
| 1133 | .position(|label| *label == "↳ scout") |
| 1134 | .expect("indented child row label"); |
| 1135 | assert!( |
| 1136 | child_pos == parent_pos + 1, |
| 1137 | "child must render directly under its parent: {worker_labels:?}" |
| 1138 | ); |
| 1139 | } |
| 1140 | |
| 1141 | #[test] |
| 1142 | fn agent_rows_completed_agents_render_quietly_without_spawn_metadata() { |
| 1143 | // #36: quiet completion — a finished agent keeps status + objective; |
| 1144 | // in-flight metadata (tool, step counters, file tallies) must not |
| 1145 | // linger as a receipt dump. |
| 1146 | let mut app = app(); |
| 1147 | app.current_session_id = Some(SESSION.to_string()); |
| 1148 | app.subagent_cache.push(cached_worker( |
| 1149 | "agent_done", |
| 1150 | "builder", |
| 1151 | None, |
| 1152 | None, |
| 1153 | SubAgentStatus::Completed, |
| 1154 | )); |
| 1155 | app.agent_progress_meta.insert( |
| 1156 | "agent_done".to_string(), |
| 1157 | crate::tui::app::AgentProgressMeta { |
| 1158 | current_activity: Some(AgentCurrentActivity::bounded( |
| 1159 | AgentCurrentActivityStatus::Done, |
| 1160 | Some("apply_patch finished".to_string()), |
| 1161 | Some("File.apply_patch".to_string()), |
| 1162 | Some(7), |
| 1163 | )), |
| 1164 | current_tool: Some("apply_patch".to_string()), |
| 1165 | files_touched: 4, |
| 1166 | ..crate::tui::app::AgentProgressMeta::default() |
| 1167 | }, |
| 1168 | ); |
| 1169 | |
| 1170 | let rows = super::model::project(&mut app); |
| 1171 | let row = rows |
| 1172 | .iter() |
| 1173 | .find(|row| row.id.0 == "worker:agent_done") |
| 1174 | .expect("completed agent row"); |
| 1175 | assert!(row.detail.contains("completed"), "{}", row.detail); |
| 1176 | assert!( |
| 1177 | row.detail.contains("objective for agent_done"), |
| 1178 | "{}", |
| 1179 | row.detail |
| 1180 | ); |
| 1181 | assert!(!row.detail.contains("using "), "{}", row.detail); |
| 1182 | assert!(!row.detail.contains("step 7"), "{}", row.detail); |
| 1183 | assert!(!row.detail.contains("files changed"), "{}", row.detail); |
| 1184 | } |
| 1185 | |
| 1186 | // ---- Fleet row layout ------------------------------------------------- |
| 1187 | |
| 1188 | /// Painted lines, one per terminal row, trailing padding removed. |
| 1189 | fn render_rows(app: &mut App, width: u16, height: u16) -> Vec<String> { |
| 1190 | let backend = TestBackend::new(width, height); |
| 1191 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 1192 | terminal |
| 1193 | .draw(|frame| super::render(frame, frame.area(), app)) |
| 1194 | .expect("draw"); |
| 1195 | let buffer = terminal.backend().buffer().clone(); |
| 1196 | (0..height) |
| 1197 | .map(|y| { |
| 1198 | (0..width) |
| 1199 | .map(|x| buffer[(x, y)].symbol()) |
| 1200 | .collect::<String>() |
| 1201 | .trim_end() |
| 1202 | .to_string() |
| 1203 | }) |
| 1204 | .collect() |
| 1205 | } |
| 1206 | |
| 1207 | fn fleet_row(rows: &[String]) -> String { |
| 1208 | rows.iter() |
| 1209 | .find(|line| line.contains("Streaming")) |
| 1210 | .cloned() |
| 1211 | .unwrap_or_else(|| panic!("no fleet row in {rows:?}")) |
| 1212 | } |
| 1213 | |
| 1214 | fn fleet_worker( |
| 1215 | id: &str, |
| 1216 | role: &str, |
| 1217 | objective: &str, |
| 1218 | duration_ms: u64, |
| 1219 | status: SubAgentStatus, |
| 1220 | ) -> SubAgentResult { |
| 1221 | let mut agent = cached_worker(id, role, None, None, status); |
| 1222 | agent.assignment.objective = objective.to_string(); |
| 1223 | agent.duration_ms = duration_ms; |
| 1224 | agent |
| 1225 | } |
| 1226 | |
| 1227 | /// Seed a live fleet of one, with a reported token spend. |
| 1228 | fn fleet_app(tokens: Option<u64>) -> App { |
| 1229 | let mut app = app(); |
| 1230 | app.current_session_id = Some(SESSION.to_string()); |
| 1231 | app.subagent_cache.push(fleet_worker( |
| 1232 | "agent_stream", |
| 1233 | "general-purpose", |
| 1234 | "Streaming dead-code removal", |
| 1235 | 753_000, |
| 1236 | SubAgentStatus::Running, |
| 1237 | )); |
| 1238 | app.agent_progress_meta.insert( |
| 1239 | "agent_stream".to_string(), |
| 1240 | crate::tui::app::AgentProgressMeta { |
| 1241 | received_tokens: tokens, |
| 1242 | ..crate::tui::app::AgentProgressMeta::default() |
| 1243 | }, |
| 1244 | ); |
| 1245 | app |
| 1246 | } |
| 1247 | |
| 1248 | #[test] |
| 1249 | fn fleet_row_lays_out_type_objective_and_a_right_aligned_receipt() { |
| 1250 | let mut app = fleet_app(Some(111_900)); |
| 1251 | let rows = render_rows(&mut app, 100, 4); |
| 1252 | |
| 1253 | assert_eq!( |
| 1254 | fleet_row(&rows), |
| 1255 | " ▸ general-purpose running Streaming dead-code removal \ |
| 1256 | 12m 33s · ↓ 111.9k tokens" |
| 1257 | ); |
| 1258 | // The group header the strip already had stays put. |
| 1259 | assert!( |
| 1260 | rows.iter().any(|line| line.contains("Subagents 1")), |
| 1261 | "{rows:?}" |
| 1262 | ); |
| 1263 | } |
| 1264 | |
| 1265 | #[test] |
| 1266 | fn fleet_row_shows_remaining_todos_only_when_the_ledger_has_unsettled_work() { |
| 1267 | let mut app = fleet_app(Some(1_200)); |
| 1268 | app.agent_progress_meta |
| 1269 | .get_mut("agent_stream") |
| 1270 | .expect("meta") |
| 1271 | .todos_remaining = Some(3); |
| 1272 | |
| 1273 | let with_left = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1274 | assert!( |
| 1275 | with_left.contains("3 left"), |
| 1276 | "unsettled ledger must surface on the receipt: {with_left}" |
| 1277 | ); |
| 1278 | assert!( |
| 1279 | with_left.contains("↓") && with_left.contains("tokens"), |
| 1280 | "tokens stay alongside the remaining chip: {with_left}" |
| 1281 | ); |
| 1282 | |
| 1283 | // Fully settled list → quiet (no fabricated zero chip). |
| 1284 | app.agent_progress_meta |
| 1285 | .get_mut("agent_stream") |
| 1286 | .expect("meta") |
| 1287 | .todos_remaining = Some(0); |
| 1288 | let settled = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1289 | assert!( |
| 1290 | !settled.contains("left"), |
| 1291 | "zero remaining must not paint a chip: {settled}" |
| 1292 | ); |
| 1293 | |
| 1294 | // No ledger published → quiet. |
| 1295 | app.agent_progress_meta |
| 1296 | .get_mut("agent_stream") |
| 1297 | .expect("meta") |
| 1298 | .todos_remaining = None; |
| 1299 | let absent = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1300 | assert!( |
| 1301 | !absent.contains("left"), |
| 1302 | "missing ledger must not invent a chip: {absent}" |
| 1303 | ); |
| 1304 | } |
| 1305 | |
| 1306 | #[test] |
| 1307 | fn fleet_identity_prefers_the_nickname_and_falls_back_to_the_role() { |
| 1308 | // Nicknames are CodeWhale identity, so they lead. An agent that has |
| 1309 | // none falls back to its fleet role rather than showing a blank or a |
| 1310 | // fabricated name. |
| 1311 | let mut app = app(); |
| 1312 | app.current_session_id = Some(SESSION.to_string()); |
| 1313 | let mut named = fleet_worker( |
| 1314 | "agent_named", |
| 1315 | "general-purpose", |
| 1316 | "Streaming dead-code removal", |
| 1317 | 753_000, |
| 1318 | SubAgentStatus::Running, |
| 1319 | ); |
| 1320 | named.nickname = Some("Fluke".to_string()); |
| 1321 | app.subagent_cache.push(named); |
| 1322 | app.subagent_cache.push(fleet_worker( |
| 1323 | "agent_plain", |
| 1324 | "general-purpose", |
| 1325 | "Ambient visual calm-down", |
| 1326 | 741_000, |
| 1327 | SubAgentStatus::Running, |
| 1328 | )); |
| 1329 | |
| 1330 | let rows = super::model::project(&mut app); |
| 1331 | let row = |id: &str| { |
| 1332 | rows.iter() |
| 1333 | .find(|row| row.id.0 == format!("worker:{id}")) |
| 1334 | .unwrap_or_else(|| panic!("row for {id}")) |
| 1335 | }; |
| 1336 | assert_eq!(row("agent_named").label, "Fluke"); |
| 1337 | assert_eq!( |
| 1338 | row("agent_named").agent.as_ref().expect("facts").role_label, |
| 1339 | "general-purpose" |
| 1340 | ); |
| 1341 | // No nickname: the identity and its fallback are the same string. |
| 1342 | assert_eq!(row("agent_plain").label, "general-purpose"); |
| 1343 | |
| 1344 | // Both spellings share one column, so the objectives stay aligned. |
| 1345 | let painted = render_rows(&mut app, 100, 5); |
| 1346 | let named_line = painted |
| 1347 | .iter() |
| 1348 | .find(|line| line.contains("Fluke")) |
| 1349 | .expect("nicknamed row"); |
| 1350 | let plain_line = painted |
| 1351 | .iter() |
| 1352 | .find(|line| line.contains("general-purpose")) |
| 1353 | .expect("un-nicknamed row"); |
| 1354 | assert_eq!( |
| 1355 | named_line.find("Streaming"), |
| 1356 | plain_line.find("Ambient"), |
| 1357 | "objectives must share a column:\n{named_line}\n{plain_line}" |
| 1358 | ); |
| 1359 | } |
| 1360 | |
| 1361 | #[test] |
| 1362 | fn an_identity_too_wide_for_the_column_falls_back_without_widening_it() { |
| 1363 | // The identity column is shared, so one outlier must not starve every |
| 1364 | // other objective — and a name is shown whole or not at all. |
| 1365 | let mut app = app(); |
| 1366 | app.current_session_id = Some(SESSION.to_string()); |
| 1367 | let mut long = fleet_worker( |
| 1368 | "agent_long", |
| 1369 | "general-purpose", |
| 1370 | "Streaming dead-code removal", |
| 1371 | 753_000, |
| 1372 | SubAgentStatus::Running, |
| 1373 | ); |
| 1374 | long.nickname = Some("Bartholomew the Extremely Long-Winded Humpback".to_string()); |
| 1375 | app.subagent_cache.push(long); |
| 1376 | app.subagent_cache.push(fleet_worker( |
| 1377 | "agent_plain", |
| 1378 | "scout", |
| 1379 | "Ambient visual calm-down", |
| 1380 | 741_000, |
| 1381 | SubAgentStatus::Running, |
| 1382 | )); |
| 1383 | |
| 1384 | let painted = render_rows(&mut app, 100, 5); |
| 1385 | let joined = painted.join("\n"); |
| 1386 | // The oversized nickname never renders, whole or truncated. |
| 1387 | assert!(!joined.contains("Bartholomew"), "{joined}"); |
| 1388 | assert!(!joined.contains("Bartholom"), "{joined}"); |
| 1389 | // It falls back to its role, and the other row is untouched. |
| 1390 | assert!(joined.contains("general-purpose"), "{joined}"); |
| 1391 | assert!(joined.contains("scout"), "{joined}"); |
| 1392 | // Neither objective was starved by the outlier. |
| 1393 | assert!(joined.contains("Streaming dead-code removal"), "{joined}"); |
| 1394 | assert!(joined.contains("Ambient visual calm-down"), "{joined}"); |
| 1395 | } |
| 1396 | |
| 1397 | #[test] |
| 1398 | fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() { |
| 1399 | // Settled degradation order: tokens first, then elapsed, then the |
| 1400 | // type and status columns together. The objective is the last thing |
| 1401 | // to go and every column truncates rather than wrapping. The status |
| 1402 | // word outlives the whole receipt — a fleet row that cannot say its |
| 1403 | // state in words has lost the fact the strip exists to show. |
| 1404 | let mut app = fleet_app(Some(111_900)); |
| 1405 | let medium = fleet_row(&render_rows(&mut app, 72, 4)); |
| 1406 | assert!(medium.contains("12m 33s"), "{medium}"); |
| 1407 | assert!(!medium.contains("tokens"), "{medium}"); |
| 1408 | assert!(medium.contains("general-purpose"), "{medium}"); |
| 1409 | assert!(medium.contains("running"), "{medium}"); |
| 1410 | |
| 1411 | let narrow = fleet_row(&render_rows(&mut app, 56, 4)); |
| 1412 | assert!(!narrow.contains("tokens"), "{narrow}"); |
| 1413 | assert!(!narrow.contains("12m 33s"), "{narrow}"); |
| 1414 | assert!(narrow.contains("general-purpose"), "{narrow}"); |
| 1415 | assert!(narrow.contains("running"), "{narrow}"); |
| 1416 | |
| 1417 | let tight = fleet_row(&render_rows(&mut app, 28, 4)); |
| 1418 | assert!(!tight.contains("general-purpose"), "{tight}"); |
| 1419 | assert!(!tight.contains("running"), "{tight}"); |
| 1420 | assert!(tight.contains("Streaming"), "{tight}"); |
| 1421 | |
| 1422 | for line in [&medium, &narrow, &tight] { |
| 1423 | assert!(line.chars().all(|ch| ch != '\n'), "{line}"); |
| 1424 | } |
| 1425 | } |
| 1426 | |
| 1427 | #[test] |
| 1428 | fn fleet_row_elapsed_freezes_once_the_agent_is_finished() { |
| 1429 | // The manager recomputes `duration_ms` as `started_at.elapsed()` on |
| 1430 | // every snapshot, so a finished agent's raw duration keeps growing. |
| 1431 | // The row must latch the first terminal reading instead. |
| 1432 | let mut app = fleet_app(None); |
| 1433 | app.subagent_cache[0].status = SubAgentStatus::Completed; |
| 1434 | app.subagent_cache[0].duration_ms = 753_000; |
| 1435 | |
| 1436 | let first = super::model::project(&mut app); |
| 1437 | let finished = first |
| 1438 | .iter() |
| 1439 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1440 | .and_then(|row| row.agent.as_ref()) |
| 1441 | .expect("finished agent facts"); |
| 1442 | assert_eq!(finished.elapsed_secs, Some(753)); |
| 1443 | |
| 1444 | // A later snapshot reports a larger duration for the same dead agent. |
| 1445 | app.subagent_cache[0].duration_ms = 999_000; |
| 1446 | let second = super::model::project(&mut app); |
| 1447 | let still = second |
| 1448 | .iter() |
| 1449 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1450 | .and_then(|row| row.agent.as_ref()) |
| 1451 | .expect("finished agent facts"); |
| 1452 | assert_eq!( |
| 1453 | still.elapsed_secs, |
| 1454 | Some(753), |
| 1455 | "finished elapsed must freeze" |
| 1456 | ); |
| 1457 | } |
| 1458 | |
| 1459 | #[test] |
| 1460 | fn fleet_row_elapsed_still_advances_while_the_agent_runs() { |
| 1461 | let mut app = fleet_app(None); |
| 1462 | app.subagent_cache[0].duration_ms = 10_000; |
| 1463 | let early = super::model::project(&mut app); |
| 1464 | assert_eq!( |
| 1465 | early |
| 1466 | .iter() |
| 1467 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1468 | .and_then(|row| row.agent.as_ref()) |
| 1469 | .expect("running agent facts") |
| 1470 | .elapsed_secs, |
| 1471 | Some(10) |
| 1472 | ); |
| 1473 | |
| 1474 | app.subagent_cache[0].duration_ms = 40_000; |
| 1475 | let later = super::model::project(&mut app); |
| 1476 | assert_eq!( |
| 1477 | later |
| 1478 | .iter() |
| 1479 | .find(|row| row.id.0 == "worker:agent_stream") |
| 1480 | .and_then(|row| row.agent.as_ref()) |
| 1481 | .expect("running agent facts") |
| 1482 | .elapsed_secs, |
| 1483 | Some(40) |
| 1484 | ); |
| 1485 | } |
| 1486 | |
| 1487 | #[test] |
| 1488 | fn fleet_row_with_no_reported_usage_shows_no_token_figure_at_all() { |
| 1489 | // An unknown number is rendered as nothing. Never `0`, which would |
| 1490 | // claim the agent spent nothing. |
| 1491 | let mut app = fleet_app(None); |
| 1492 | let row = fleet_row(&render_rows(&mut app, 100, 4)); |
| 1493 | assert!(!row.contains("tokens"), "{row}"); |
| 1494 | assert!(!row.contains('↓'), "{row}"); |
| 1495 | assert!(row.contains("12m 33s"), "{row}"); |
| 1496 | |
| 1497 | let mut spent = fleet_app(Some(0)); |
| 1498 | let zero = fleet_row(&render_rows(&mut spent, 100, 4)); |
| 1499 | // A *reported* zero is a fact and does render. |
| 1500 | assert!(zero.contains("↓ 0 tokens"), "{zero}"); |
| 1501 | } |
| 1502 | |
| 1503 | #[test] |
| 1504 | fn fleet_row_child_badge_counts_children_that_are_on_the_surface() { |
| 1505 | let mut app = app(); |
| 1506 | app.current_session_id = Some(SESSION.to_string()); |
| 1507 | app.subagent_cache.push(cached_worker( |
| 1508 | "agent_lead", |
| 1509 | "general-purpose", |
| 1510 | None, |
| 1511 | None, |
| 1512 | SubAgentStatus::Running, |
| 1513 | )); |
| 1514 | for child in ["agent_c1", "agent_c2", "agent_c3"] { |
| 1515 | app.subagent_cache.push(cached_worker( |
| 1516 | child, |
| 1517 | "scout", |
| 1518 | None, |
| 1519 | Some("agent_lead"), |
| 1520 | SubAgentStatus::Running, |
| 1521 | )); |
| 1522 | } |
| 1523 | // A child whose parent is not on the surface must not be counted for |
| 1524 | // anyone, and must not inflate the lead's badge. |
| 1525 | app.subagent_cache.push(cached_worker( |
| 1526 | "agent_orphan", |
| 1527 | "scout", |
| 1528 | None, |
| 1529 | Some("agent_missing"), |
| 1530 | SubAgentStatus::Running, |
| 1531 | )); |
| 1532 | |
| 1533 | let rows = super::model::project(&mut app); |
| 1534 | let label = |id: &str| { |
| 1535 | rows.iter() |
| 1536 | .find(|row| row.id.0 == format!("worker:{id}")) |
| 1537 | .map(|row| row.label.clone()) |
| 1538 | .unwrap_or_else(|| panic!("row for {id}")) |
| 1539 | }; |
| 1540 | assert_eq!(label("agent_lead"), "general-purpose (+3)"); |
| 1541 | assert_eq!(label("agent_c1"), "↳ scout"); |
| 1542 | assert_eq!(label("agent_orphan"), "scout"); |
| 1543 | } |
| 1544 | |
| 1545 | #[test] |
| 1546 | fn a_capped_fleet_list_announces_how_many_rows_it_is_hiding() { |
| 1547 | let mut app = app(); |
| 1548 | app.current_session_id = Some(SESSION.to_string()); |
| 1549 | for index in 0..8 { |
| 1550 | app.subagent_cache.push(cached_worker( |
| 1551 | &format!("agent_{index}"), |
| 1552 | "general-purpose", |
| 1553 | None, |
| 1554 | None, |
| 1555 | SubAgentStatus::Running, |
| 1556 | )); |
| 1557 | } |
| 1558 | |
| 1559 | // Four content rows for nine projected rows (header + eight workers). |
| 1560 | let rows = render_rows(&mut app, 100, 5); |
| 1561 | let more = rows |
| 1562 | .iter() |
| 1563 | .find(|line| line.contains("more")) |
| 1564 | .unwrap_or_else(|| panic!("no overflow line in {rows:?}")); |
| 1565 | // Nine projected rows (header + eight workers); three fit, six do not. |
| 1566 | assert!(more.contains("↓ 6 more"), "{more}"); |
| 1567 | // Right-aligned against the content column, not the left margin. |
| 1568 | assert!(more.starts_with(" "), "{more}"); |
| 1569 | } |
| 1570 | |
| 1571 | #[test] |
| 1572 | fn fleet_rows_render_in_top_left_and_right_placements() { |
| 1573 | for placement in [ |
| 1574 | super::WorkSurfacePlacement::Top, |
| 1575 | super::WorkSurfacePlacement::Left, |
| 1576 | super::WorkSurfacePlacement::Right, |
| 1577 | ] { |
| 1578 | let mut app = fleet_app(Some(111_900)); |
| 1579 | app.work_surface.placement = placement; |
| 1580 | app.work_surface.effective_placement = placement; |
| 1581 | let rows = render_rows(&mut app, 40, 8); |
| 1582 | let row = fleet_row(&rows); |
| 1583 | assert!( |
| 1584 | row.contains("Streaming"), |
| 1585 | "{placement:?} lost the objective: {rows:?}" |
| 1586 | ); |
| 1587 | } |
| 1588 | } |
| 1589 | |
| 1590 | #[test] |
| 1591 | fn progress_only_work_rows_use_typed_activity_not_display_substrings() { |
| 1592 | let mut app = app(); |
| 1593 | app.current_session_id = Some(SESSION.to_string()); |
| 1594 | app.agent_progress.insert( |
| 1595 | "agent_progress_only".to_string(), |
| 1596 | "queued waiting failed completed".to_string(), |
| 1597 | ); |
| 1598 | |
| 1599 | let rows = super::model::project(&mut app); |
| 1600 | let row = rows |
| 1601 | .iter() |
| 1602 | .find(|row| row.id.0 == "worker:agent_progress_only") |
| 1603 | .expect("progress-only work row"); |
| 1604 | assert_eq!(row.detail, "running"); |
| 1605 | |
| 1606 | app.agent_progress_meta.insert( |
| 1607 | "agent_progress_only".to_string(), |
| 1608 | crate::tui::app::AgentProgressMeta { |
| 1609 | current_activity: Some(AgentCurrentActivity::bounded( |
| 1610 | AgentCurrentActivityStatus::Waiting, |
| 1611 | Some("approval required".to_string()), |
| 1612 | None, |
| 1613 | Some(5), |
| 1614 | )), |
| 1615 | ..crate::tui::app::AgentProgressMeta::default() |
| 1616 | }, |
| 1617 | ); |
| 1618 | |
| 1619 | let rows = super::model::project(&mut app); |
| 1620 | let row = rows |
| 1621 | .iter() |
| 1622 | .find(|row| row.id.0 == "worker:agent_progress_only") |
| 1623 | .expect("typed progress-only work row"); |
| 1624 | assert!(row.detail.contains("waiting for input"), "{}", row.detail); |
| 1625 | assert!(row.detail.contains("approval required"), "{}", row.detail); |
| 1626 | assert!(row.detail.contains("step 5"), "{}", row.detail); |
| 1627 | } |
| 1628 | |
| 1629 | #[test] |
| 1630 | fn agent_details_keyboard_mouse_and_return_selection_converge() { |
| 1631 | fn add_worker(app: &mut App) { |
| 1632 | app.current_session_id = Some(SESSION.to_string()); |
| 1633 | app.subagent_cache.push(SubAgentResult { |
| 1634 | name: "agent_converge".to_string(), |
| 1635 | agent_id: "agent_converge".to_string(), |
| 1636 | context_mode: "fresh".to_string(), |
| 1637 | fork_context: false, |
| 1638 | workspace: None, |
| 1639 | git_branch: Some("codex/details".to_string()), |
| 1640 | agent_type: FleetRole::Builder, |
| 1641 | assignment: SubAgentAssignment { |
| 1642 | objective: "Verify keyboard and mouse convergence".to_string(), |
| 1643 | role: Some("worker".to_string()), |
| 1644 | }, |
| 1645 | model: "test-model".to_string(), |
| 1646 | nickname: Some("Blue Whale".to_string()), |
| 1647 | status: SubAgentStatus::Running, |
| 1648 | worker_status: Some(AgentWorkerStatus::Running), |
| 1649 | runtime_permissions: None, |
| 1650 | parent_run_id: None, |
| 1651 | spawn_depth: 1, |
| 1652 | result: None, |
| 1653 | steps_taken: 1, |
| 1654 | checkpoint: None, |
| 1655 | needs_input: None, |
| 1656 | duration_ms: 100, |
| 1657 | from_prior_session: false, |
| 1658 | }); |
| 1659 | } |
| 1660 | |
| 1661 | let mut keyboard = app(); |
| 1662 | add_worker(&mut keyboard); |
| 1663 | let _ = render_text(&mut keyboard, 100, 6); |
| 1664 | let _ = super::handle_key( |
| 1665 | &mut keyboard, |
| 1666 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 1667 | ); |
| 1668 | let keyboard_action = super::handle_key( |
| 1669 | &mut keyboard, |
| 1670 | KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), |
| 1671 | ) |
| 1672 | .expect("Work key handled") |
| 1673 | .expect("agent details action"); |
| 1674 | let keyboard_selection = keyboard.work_surface.selected.clone(); |
| 1675 | |
| 1676 | let mut mouse = app(); |
| 1677 | add_worker(&mut mouse); |
| 1678 | let _ = render_text(&mut mouse, 100, 6); |
| 1679 | let row_y = mouse |
| 1680 | .work_surface |
| 1681 | .hitboxes |
| 1682 | .iter() |
| 1683 | .find(|hit| hit.id.0 == "worker:agent_converge") |
| 1684 | .expect("agent hitbox") |
| 1685 | .row_y; |
| 1686 | let mouse_action = super::handle_mouse( |
| 1687 | &mut mouse, |
| 1688 | MouseEvent { |
| 1689 | kind: MouseEventKind::Down(MouseButton::Left), |
| 1690 | column: 2, |
| 1691 | row: row_y, |
| 1692 | modifiers: KeyModifiers::NONE, |
| 1693 | }, |
| 1694 | ) |
| 1695 | .action |
| 1696 | .expect("mouse agent details action"); |
| 1697 | assert_eq!(mouse_action, keyboard_action); |
| 1698 | assert_eq!(mouse.work_surface.selected, keyboard_selection); |
| 1699 | |
| 1700 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut mouse, mouse_action); |
| 1701 | let selected_before_close = mouse.work_surface.selected.clone(); |
| 1702 | let events = mouse |
| 1703 | .view_stack |
| 1704 | .handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); |
| 1705 | let [crate::tui::views::ViewEvent::AgentDetailsClosed { agent_id }] = events.as_slice() |
| 1706 | else { |
| 1707 | panic!("Left should close Agent Details with a receipt: {events:?}"); |
| 1708 | }; |
| 1709 | super::interaction::agent_details_closed(&mut mouse, agent_id); |
| 1710 | assert_eq!(mouse.work_surface.selected, selected_before_close); |
| 1711 | assert!(mouse.work_surface.opened.is_none()); |
| 1712 | } |
| 1713 | |
| 1714 | #[test] |
| 1715 | fn active_session_without_work_keeps_surface_invisible() { |
| 1716 | let mut app = app(); |
| 1717 | app.current_session_id = Some(SESSION.to_string()); |
| 1718 | |
| 1719 | let rows = super::model::project(&mut app); |
| 1720 | |
| 1721 | assert!(rows.is_empty()); |
| 1722 | assert_eq!(super::height(&mut app, 120, 32, AMPLE_BUDGET), 0); |
| 1723 | } |
| 1724 | |
| 1725 | #[test] |
| 1726 | fn empty_work_stays_hidden_after_cached_session_state_is_cleared() { |
| 1727 | let mut app = app(); |
| 1728 | app.current_session_id = Some(SESSION.to_string()); |
| 1729 | app.work_surface.cached_graph = Some(operation_graph(NodeState::Active)); |
| 1730 | |
| 1731 | let rows = super::model::project(&mut app); |
| 1732 | |
| 1733 | assert!(rows.is_empty()); |
| 1734 | assert!(app.work_surface.cached_graph.is_none()); |
| 1735 | } |
| 1736 | |
| 1737 | #[test] |
| 1738 | fn empty_work_reserves_no_side_rail() { |
| 1739 | for placement in [ |
| 1740 | super::WorkSurfacePlacement::Left, |
| 1741 | super::WorkSurfacePlacement::Right, |
| 1742 | ] { |
| 1743 | let mut app = app(); |
| 1744 | app.current_session_id = Some(SESSION.to_string()); |
| 1745 | app.work_surface.placement = placement; |
| 1746 | let area = ratatui::layout::Rect::new(0, 0, 120, 32); |
| 1747 | |
| 1748 | assert_eq!( |
| 1749 | super::height(&mut app, area.width, area.height, AMPLE_BUDGET), |
| 1750 | 0 |
| 1751 | ); |
| 1752 | assert_eq!(super::split_chat(&mut app, area, 0), (area, None)); |
| 1753 | } |
| 1754 | } |
| 1755 | |
| 1756 | fn terminal_text(terminal: &Terminal<TestBackend>) -> String { |
| 1757 | let buf = terminal.backend().buffer(); |
| 1758 | let mut text = String::new(); |
| 1759 | for y in 0..buf.area.height { |
| 1760 | for x in 0..buf.area.width { |
| 1761 | text.push_str(buf[(x, y)].symbol()); |
| 1762 | } |
| 1763 | } |
| 1764 | text |
| 1765 | } |
| 1766 | |
| 1767 | /// Render-level smoke coverage for the ported rail panels — reinstates |
| 1768 | /// the sidebar render smoke tests removed with the classic shell |
| 1769 | /// (739616787). Top never spends a row on panel chrome (content is |
| 1770 | /// self-evident). Side rails are named by their content's own heading |
| 1771 | /// row (`▾ Subagents N`, `Goal: …`); Context is the one line-list panel |
| 1772 | /// and keeps its muted panel title. |
| 1773 | #[test] |
| 1774 | fn rail_panels_render_in_all_placements() { |
| 1775 | for panel in [ |
| 1776 | super::RailPanel::Agents, |
| 1777 | super::RailPanel::Context, |
| 1778 | super::RailPanel::Pinned, |
| 1779 | ] { |
| 1780 | for placement in [ |
| 1781 | super::WorkSurfacePlacement::Top, |
| 1782 | super::WorkSurfacePlacement::Left, |
| 1783 | super::WorkSurfacePlacement::Right, |
| 1784 | ] { |
| 1785 | let mut app = app(); |
| 1786 | app.work_surface.placement = placement; |
| 1787 | app.work_surface.panel = panel; |
| 1788 | // Content so empty-collapse does not hide the panel. Agents |
| 1789 | // needs a cached worker; Pinned needs a goal; Context always |
| 1790 | // has session facts. |
| 1791 | app.hunt.quarry = Some("ship the release".to_string()); |
| 1792 | if panel == super::RailPanel::Agents { |
| 1793 | app.subagent_cache.push(cached_worker( |
| 1794 | "agent-a", |
| 1795 | "explore", |
| 1796 | Some("scout"), |
| 1797 | None, |
| 1798 | SubAgentStatus::Running, |
| 1799 | )); |
| 1800 | } |
| 1801 | let area = ratatui::layout::Rect::new(0, 0, 100, 24); |
| 1802 | |
| 1803 | // Render coverage, not yield coverage: a 24-row terminal with |
| 1804 | // work on screen has rows to spare, so the panel is expected |
| 1805 | // to draw. The idle-empty budget is exercised end-to-end in |
| 1806 | // `ui::tests::rail_strip_yields_the_ambient_floor_*`. |
| 1807 | let budget = working_budget(&app, area.height); |
| 1808 | let strip = super::height(&mut app, area.width, area.height, budget); |
| 1809 | let (_chat, rail) = super::split_chat(&mut app, area, 0); |
| 1810 | let backend = TestBackend::new(area.width, area.height); |
| 1811 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 1812 | terminal |
| 1813 | .draw(|frame| { |
| 1814 | if strip > 0 { |
| 1815 | super::render( |
| 1816 | frame, |
| 1817 | ratatui::layout::Rect::new(0, 0, area.width, strip), |
| 1818 | &mut app, |
| 1819 | ); |
| 1820 | } else if let Some(rail) = rail { |
| 1821 | super::render(frame, rail, &mut app); |
| 1822 | } |
| 1823 | }) |
| 1824 | .expect("draw"); |
| 1825 | let text = terminal_text(&terminal); |
| 1826 | match placement { |
| 1827 | super::WorkSurfacePlacement::Top => { |
| 1828 | assert!( |
| 1829 | strip > 0, |
| 1830 | "{panel:?} on Top should auto-fit a content strip; got height 0" |
| 1831 | ); |
| 1832 | // Panel chrome ("Pinned"/"Agents") never on Top. |
| 1833 | // An active goal *is* a title — and this fixture sets one. |
| 1834 | assert!( |
| 1835 | !text.contains(panel.title()) |
| 1836 | || panel.title() == "Context" && text.contains("Context"), |
| 1837 | "{panel:?} on Top must not spend a row on panel chrome; got: {text}" |
| 1838 | ); |
| 1839 | if panel != super::RailPanel::Context { |
| 1840 | assert!( |
| 1841 | !text.split_whitespace().any(|tok| tok == panel.title()), |
| 1842 | "{panel:?} on Top must not print the panel name as chrome; got: {text}" |
| 1843 | ); |
| 1844 | } |
| 1845 | // Goal title when a live goal is set. |
| 1846 | assert!( |
| 1847 | text.contains("Goal:") && text.contains("ship the release"), |
| 1848 | "Top with an active goal must title with Goal: …; got: {text}" |
| 1849 | ); |
| 1850 | } |
| 1851 | super::WorkSurfacePlacement::Left | super::WorkSurfacePlacement::Right => { |
| 1852 | assert!( |
| 1853 | rail.is_some() || strip > 0, |
| 1854 | "{panel:?} in {placement:?} should reserve a rail" |
| 1855 | ); |
| 1856 | // Work-row panels are named by their content heading; |
| 1857 | // only the Context fact list keeps a panel title. |
| 1858 | match panel { |
| 1859 | super::RailPanel::Agents => { |
| 1860 | assert!( |
| 1861 | text.contains("Subagents 1"), |
| 1862 | "{panel:?} in {placement:?} should render its \ |
| 1863 | Subagents heading; got: {text}" |
| 1864 | ); |
| 1865 | assert!( |
| 1866 | !app.work_surface.hitboxes.is_empty(), |
| 1867 | "{panel:?} in {placement:?} must record hitboxes — \ |
| 1868 | every work row is a door" |
| 1869 | ); |
| 1870 | } |
| 1871 | super::RailPanel::Pinned => { |
| 1872 | assert!( |
| 1873 | text.contains("Goal: ship the release"), |
| 1874 | "{panel:?} in {placement:?} should render the goal \ |
| 1875 | heading; got: {text}" |
| 1876 | ); |
| 1877 | } |
| 1878 | _ => { |
| 1879 | assert!( |
| 1880 | text.contains(panel.title()), |
| 1881 | "{panel:?} in {placement:?} should render its muted \ |
| 1882 | title; got: {text}" |
| 1883 | ); |
| 1884 | } |
| 1885 | } |
| 1886 | } |
| 1887 | super::WorkSurfacePlacement::Off => {} |
| 1888 | } |
| 1889 | } |
| 1890 | } |
| 1891 | } |
| 1892 | |
| 1893 | #[test] |
| 1894 | fn off_placement_reserves_no_rail_in_any_panel() { |
| 1895 | for panel in [ |
| 1896 | super::RailPanel::Tasks, |
| 1897 | super::RailPanel::Agents, |
| 1898 | super::RailPanel::Context, |
| 1899 | super::RailPanel::Pinned, |
| 1900 | ] { |
| 1901 | let mut app = app(); |
| 1902 | add_todos(&mut app, 2); |
| 1903 | app.work_surface.placement = super::WorkSurfacePlacement::Off; |
| 1904 | app.work_surface.panel = panel; |
| 1905 | let area = ratatui::layout::Rect::new(0, 0, 120, 32); |
| 1906 | |
| 1907 | assert_eq!( |
| 1908 | super::height(&mut app, area.width, area.height, AMPLE_BUDGET), |
| 1909 | 0 |
| 1910 | ); |
| 1911 | assert_eq!(super::split_chat(&mut app, area, 0), (area, None)); |
| 1912 | assert_eq!(app.work_surface.last_area, None); |
| 1913 | } |
| 1914 | } |
| 1915 | |
| 1916 | #[test] |
| 1917 | fn context_panel_renders_session_facts_in_side_rail() { |
| 1918 | let mut app = app(); |
| 1919 | app.work_surface.placement = super::WorkSurfacePlacement::Right; |
| 1920 | app.work_surface.panel = super::RailPanel::Context; |
| 1921 | let area = ratatui::layout::Rect::new(0, 0, 100, 24); |
| 1922 | |
| 1923 | let budget = working_budget(&app, area.height); |
| 1924 | let strip = super::height(&mut app, area.width, area.height, budget); |
| 1925 | assert_eq!(strip, 0, "side placements take no top strip"); |
| 1926 | let (_chat, rail) = super::split_chat(&mut app, area, 0); |
| 1927 | let rail = rail.expect("context panel reserves a side rail"); |
| 1928 | |
| 1929 | let backend = TestBackend::new(area.width, area.height); |
| 1930 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 1931 | terminal |
| 1932 | .draw(|frame| super::render(frame, rail, &mut app)) |
| 1933 | .expect("draw"); |
| 1934 | let text = terminal_text(&terminal); |
| 1935 | assert!(text.contains("Context"), "panel title; got: {text}"); |
| 1936 | assert!(text.contains("lsp:"), "session facts; got: {text}"); |
| 1937 | } |
| 1938 | |
| 1939 | #[test] |
| 1940 | fn missing_runtime_renders_disconnected_state() { |
| 1941 | let mut app = app(); |
| 1942 | app.current_session_id = Some(SESSION.to_string()); |
| 1943 | app.runtime_services.work = None; |
| 1944 | |
| 1945 | let rows = super::model::project(&mut app); |
| 1946 | |
| 1947 | assert_eq!(rows[0].label, "Work · disconnected"); |
| 1948 | } |
| 1949 | |
| 1950 | #[test] |
| 1951 | fn busy_graph_authority_renders_truthful_error_without_leaking_it_into_header() { |
| 1952 | let mut app = app(); |
| 1953 | app.current_session_id = Some(SESSION.to_string()); |
| 1954 | let todos = app.todos.clone(); |
| 1955 | let _guard = todos.try_lock().expect("hold To-do authority lock"); |
| 1956 | |
| 1957 | let rows = super::model::project(&mut app); |
| 1958 | |
| 1959 | assert_eq!(rows.len(), 1); |
| 1960 | assert_eq!(rows[0].label, "Work · error"); |
| 1961 | assert!(rows[0].detail.contains("To-do state is busy")); |
| 1962 | assert!(!rows[0].label.contains("busy")); |
| 1963 | } |
| 1964 | |
| 1965 | #[test] |
| 1966 | fn graph_error_without_an_active_session_stays_suppressed() { |
| 1967 | let mut app = app(); |
| 1968 | let todos = app.todos.clone(); |
| 1969 | let _guard = todos.try_lock().expect("hold To-do authority lock"); |
| 1970 | |
| 1971 | let rows = super::model::project(&mut app); |
| 1972 | |
| 1973 | assert!(rows.is_empty()); |
| 1974 | } |
| 1975 | |
| 1976 | #[test] |
| 1977 | fn waiting_operation_is_not_counted_as_running() { |
| 1978 | let mut app = app(); |
| 1979 | let graph = operation_graph(NodeState::Waiting); |
| 1980 | restore_graph(&mut app, &graph); |
| 1981 | app.runtime_services |
| 1982 | .work |
| 1983 | .as_ref() |
| 1984 | .expect("Work Graph runtime") |
| 1985 | .reconcile_operation( |
| 1986 | SESSION, |
| 1987 | OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Waiting, 1, 6), |
| 1988 | ) |
| 1989 | .expect("waiting shell owner"); |
| 1990 | |
| 1991 | let rows = super::model::project(&mut app); |
| 1992 | |
| 1993 | assert!( |
| 1994 | rows[0].label.starts_with("Work · Needs input:") |
| 1995 | || rows[0] |
| 1996 | .label |
| 1997 | .starts_with("Work · 0 active · 1 needs input · 0 ready · 0 recent"), |
| 1998 | "{}", |
| 1999 | rows[0].label |
| 2000 | ); |
| 2001 | assert!( |
| 2002 | rows[0].label.contains("blocked") || rows[0].label.contains("needs input"), |
| 2003 | "{}", |
| 2004 | rows[0].label |
| 2005 | ); |
| 2006 | } |
| 2007 | |
| 2008 | #[test] |
| 2009 | fn stale_operation_is_blocked_attention_with_bounded_output_section() { |
| 2010 | let mut app = app(); |
| 2011 | let graph = operation_graph(NodeState::Stale); |
| 2012 | restore_graph(&mut app, &graph); |
| 2013 | |
| 2014 | let rows = super::model::project(&mut app); |
| 2015 | assert!( |
| 2016 | rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"), |
| 2017 | "{}", |
| 2018 | rows[0].label |
| 2019 | ); |
| 2020 | let row = rows.iter().find(|row| row.selectable).expect("stale row"); |
| 2021 | assert_eq!(row.mark, "?"); |
| 2022 | assert!(row.detail.starts_with("stale · operation")); |
| 2023 | let Some(SidebarRowAction::InspectWork { |
| 2024 | body, stop_action, .. |
| 2025 | }) = row.primary_action.as_ref() |
| 2026 | else { |
| 2027 | panic!("stale row must open inspector"); |
| 2028 | }; |
| 2029 | assert!( |
| 2030 | stop_action.is_none(), |
| 2031 | "a stale owner cannot truthfully expose a stop action" |
| 2032 | ); |
| 2033 | assert!( |
| 2034 | body.contains("Last bounded output\nNo output receipt"), |
| 2035 | "{body}" |
| 2036 | ); |
| 2037 | assert!(body.contains("Owner cannot confirm liveness"), "{body}"); |
| 2038 | } |
| 2039 | |
| 2040 | /// A durable failed operation, as a fleet agent task from a crashed or |
| 2041 | /// sibling instance leaves behind in the persisted graph (#4416). |
| 2042 | fn durable_failed_operation_graph() -> crate::work_graph::WorkGraphSnapshot { |
| 2043 | let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Failed)); |
| 2044 | let operation = WorkNodeId::derive(SESSION, "operation"); |
| 2045 | graph |
| 2046 | .apply( |
| 2047 | WorkGraphChange::BindOperation { |
| 2048 | node: operation, |
| 2049 | binding: OperationBinding { |
| 2050 | external: "fleet:run_1/task_1".to_string(), |
| 2051 | durable: true, |
| 2052 | last_observation: None, |
| 2053 | }, |
| 2054 | }, |
| 2055 | ChangeCtx { |
| 2056 | session_id: SESSION.to_string(), |
| 2057 | now: 6, |
| 2058 | idempotency_key: None, |
| 2059 | }, |
| 2060 | ) |
| 2061 | .expect("durable binding"); |
| 2062 | graph.into_snapshot() |
| 2063 | } |
| 2064 | |
| 2065 | // Regression for #4416: a persisted failed-agent record stamped by |
| 2066 | // another session instance (boot id) must not appear in the default |
| 2067 | // work listing of a fresh session in the same workspace. |
| 2068 | #[test] |
| 2069 | fn prior_instance_failed_rows_stay_out_of_the_default_listing() { |
| 2070 | let dir = tempfile::tempdir().expect("tempdir"); |
| 2071 | let manager = |
| 2072 | crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager"); |
| 2073 | manager |
| 2074 | .record_session_boot_owner(SESSION, "boot_other_instance") |
| 2075 | .expect("stamp other instance"); |
| 2076 | |
| 2077 | let mut app = app(); |
| 2078 | app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf()); |
| 2079 | let graph = durable_failed_operation_graph(); |
| 2080 | restore_saved_graph(&mut app, &graph); |
| 2081 | |
| 2082 | let rows = super::model::project(&mut app); |
| 2083 | assert!( |
| 2084 | rows.iter() |
| 2085 | .all(|row| !row.label.contains("Verify installed build")), |
| 2086 | "prior-instance failed row leaked into the default listing: {rows:#?}" |
| 2087 | ); |
| 2088 | assert!( |
| 2089 | rows.iter() |
| 2090 | .all(|row| !row.label.contains("needs input") && !row.label.contains("1 active")), |
| 2091 | "prior-instance residue must not count as live work: {rows:#?}" |
| 2092 | ); |
| 2093 | // The record stays reachable through the explicit catalog, clearly |
| 2094 | // marked historical. |
| 2095 | let historical = app |
| 2096 | .work_surface |
| 2097 | .catalog_rows |
| 2098 | .iter() |
| 2099 | .find(|row| row.label.contains("Verify installed build")) |
| 2100 | .expect("historical row remains in the catalog"); |
| 2101 | assert!( |
| 2102 | historical.detail.starts_with("prior session · "), |
| 2103 | "historical row must be labeled: {}", |
| 2104 | historical.detail |
| 2105 | ); |
| 2106 | } |
| 2107 | |
| 2108 | // Ownership control for #4416: the same failed record owned by this |
| 2109 | // session instance still renders as actionable work. |
| 2110 | #[test] |
| 2111 | fn current_instance_failed_rows_still_render_in_the_default_listing() { |
| 2112 | let dir = tempfile::tempdir().expect("tempdir"); |
| 2113 | let manager = |
| 2114 | crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager"); |
| 2115 | manager |
| 2116 | .record_session_boot_owner(SESSION, crate::session_manager::current_session_boot_id()) |
| 2117 | .expect("stamp current instance"); |
| 2118 | |
| 2119 | let mut app = app(); |
| 2120 | app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf()); |
| 2121 | let graph = durable_failed_operation_graph(); |
| 2122 | restore_graph(&mut app, &graph); |
| 2123 | |
| 2124 | let rows = super::model::project(&mut app); |
| 2125 | assert!( |
| 2126 | rows.iter() |
| 2127 | .any(|row| row.label.contains("Verify installed build")), |
| 2128 | "this instance's own failed work must stay visible: {rows:#?}" |
| 2129 | ); |
| 2130 | } |
| 2131 | |
| 2132 | // Regression for review of #5063: if a prior session persisted no graph, |
| 2133 | // the first graph captured later belongs to this process and must not be |
| 2134 | // mistaken for restored residue. |
| 2135 | #[test] |
| 2136 | fn first_live_graph_after_empty_prior_restore_stays_visible() { |
| 2137 | let dir = tempfile::tempdir().expect("tempdir"); |
| 2138 | let manager = |
| 2139 | crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager"); |
| 2140 | manager |
| 2141 | .record_session_boot_owner(SESSION, "boot_other_instance") |
| 2142 | .expect("stamp other instance"); |
| 2143 | |
| 2144 | let mut app = app(); |
| 2145 | app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf()); |
| 2146 | app.current_session_id = Some(SESSION.to_string()); |
| 2147 | app.restore_work_state(SESSION, std::path::Path::new("."), None) |
| 2148 | .expect("restore empty prior session"); |
| 2149 | |
| 2150 | let graph = durable_failed_operation_graph(); |
| 2151 | restore_graph(&mut app, &graph); |
| 2152 | let rows = super::model::project(&mut app); |
| 2153 | assert!( |
| 2154 | rows.iter() |
| 2155 | .any(|row| row.label.contains("Verify installed build")), |
| 2156 | "this instance's first live graph must stay visible: {rows:#?}" |
| 2157 | ); |
| 2158 | } |
| 2159 | |
| 2160 | #[test] |
| 2161 | fn completed_operation_with_acceptance_is_not_rendered_done() { |
| 2162 | let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Ready)); |
| 2163 | let operation = WorkNodeId::derive(SESSION, "operation"); |
| 2164 | graph |
| 2165 | .apply( |
| 2166 | WorkGraphChange::UpdateNode { |
| 2167 | id: operation, |
| 2168 | patch: crate::work_graph::WorkNodePatch { |
| 2169 | state: Some(NodeState::Completed), |
| 2170 | acceptance: Some(vec![AcceptanceRequirement::EvidenceOfKind { |
| 2171 | kind: EvidenceKindTag::ToolRun, |
| 2172 | }]), |
| 2173 | ..crate::work_graph::WorkNodePatch::default() |
| 2174 | }, |
| 2175 | }, |
| 2176 | ChangeCtx { |
| 2177 | session_id: SESSION.to_string(), |
| 2178 | now: 6, |
| 2179 | idempotency_key: None, |
| 2180 | }, |
| 2181 | ) |
| 2182 | .expect("completed pending evidence"); |
| 2183 | let graph = graph.into_snapshot(); |
| 2184 | let mut app = app(); |
| 2185 | restore_graph(&mut app, &graph); |
| 2186 | |
| 2187 | let rows = super::model::project(&mut app); |
| 2188 | assert!( |
| 2189 | rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"), |
| 2190 | "{}", |
| 2191 | rows[0].label |
| 2192 | ); |
| 2193 | let row = rows |
| 2194 | .iter() |
| 2195 | .find(|row| row.selectable) |
| 2196 | .expect("operation row"); |
| 2197 | assert_eq!(row.mark, crate::tui::glyphs::ATTENTION); |
| 2198 | assert!(row.detail.contains("completed · evidence pending")); |
| 2199 | assert_ne!(row.mark, "✓"); |
| 2200 | let Some(SidebarRowAction::InspectWork { body, .. }) = row.primary_action.as_ref() else { |
| 2201 | panic!("completed operation must remain inspectable"); |
| 2202 | }; |
| 2203 | assert!(body.contains("evidence of kind tool run"), "{body}"); |
| 2204 | assert!( |
| 2205 | body.contains("acceptance evidence is still missing"), |
| 2206 | "{body}" |
| 2207 | ); |
| 2208 | } |
| 2209 | |
| 2210 | #[test] |
| 2211 | fn work_rows_open_graph_inspector_without_inline_controls() { |
| 2212 | let mut app = app(); |
| 2213 | app.work_surface.placement = WorkSurfacePlacement::Right; |
| 2214 | app.work_surface.effective_placement = WorkSurfacePlacement::Right; |
| 2215 | let graph = operation_graph(NodeState::Active); |
| 2216 | restore_graph(&mut app, &graph); |
| 2217 | app.runtime_services |
| 2218 | .work |
| 2219 | .as_ref() |
| 2220 | .expect("Work Graph runtime") |
| 2221 | .reconcile_operation( |
| 2222 | SESSION, |
| 2223 | OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Running, 1, 6), |
| 2224 | ) |
| 2225 | .expect("live shell owner"); |
| 2226 | |
| 2227 | let text = render_text(&mut app, 100, 6); |
| 2228 | assert!(!text.contains("[open]"), "{text}"); |
| 2229 | assert!(!text.contains("[stop]"), "{text}"); |
| 2230 | let row_y = app |
| 2231 | .work_surface |
| 2232 | .hitboxes |
| 2233 | .iter() |
| 2234 | .find(|hit| hit.id.0.starts_with("graph:")) |
| 2235 | .expect("graph hitbox") |
| 2236 | .row_y; |
| 2237 | let outcome = super::handle_mouse( |
| 2238 | &mut app, |
| 2239 | MouseEvent { |
| 2240 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2241 | column: 2, |
| 2242 | row: row_y, |
| 2243 | modifiers: KeyModifiers::NONE, |
| 2244 | }, |
| 2245 | ); |
| 2246 | let action = outcome.action.expect("inspector action"); |
| 2247 | let SidebarRowAction::InspectWork { |
| 2248 | body, stop_action, .. |
| 2249 | } = &action |
| 2250 | else { |
| 2251 | panic!("expected Work inspector"); |
| 2252 | }; |
| 2253 | for section in [ |
| 2254 | "Objective", |
| 2255 | "Prerequisites", |
| 2256 | "Downstream impact", |
| 2257 | "Binding + lifecycle owner", |
| 2258 | "Evidence vs acceptance", |
| 2259 | "Blockers / approvals", |
| 2260 | "Why next", |
| 2261 | "Provenance + last reconcile", |
| 2262 | ] { |
| 2263 | assert!(body.contains(section), "missing {section}: {body}"); |
| 2264 | } |
| 2265 | assert!(matches!( |
| 2266 | stop_action.as_deref(), |
| 2267 | Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_1234abcd" |
| 2268 | )); |
| 2269 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action); |
| 2270 | assert_eq!( |
| 2271 | app.view_stack.top_kind(), |
| 2272 | Some(crate::tui::views::ModalKind::Pager) |
| 2273 | ); |
| 2274 | } |
| 2275 | |
| 2276 | #[test] |
| 2277 | fn narrow_render_hover_keeps_full_untruncated_row() { |
| 2278 | let mut app = app(); |
| 2279 | app.todos.try_lock().expect("todos").add( |
| 2280 | "A deliberately long graph-owned work row".to_string(), |
| 2281 | TodoStatus::InProgress, |
| 2282 | ); |
| 2283 | |
| 2284 | let _ = render_text(&mut app, 24, 4); |
| 2285 | let hover = app |
| 2286 | .sidebar_hover |
| 2287 | .sections |
| 2288 | .last() |
| 2289 | .and_then(|section| section.rows.first()) |
| 2290 | .expect("hover row"); |
| 2291 | assert!(hover.is_truncated); |
| 2292 | assert!(hover.full_text.contains("deliberately long graph-owned")); |
| 2293 | assert!(hover.stop_action.is_none()); |
| 2294 | } |
| 2295 | |
| 2296 | #[test] |
| 2297 | fn narrow_file_activity_prioritizes_the_canonical_aggregate_label() { |
| 2298 | let mut app = app(); |
| 2299 | app.workspace = PathBuf::from("/workspace/project"); |
| 2300 | let result = crate::tools::spec::ToolResult::success("ok").with_metadata( |
| 2301 | serde_json::json!({ |
| 2302 | "mutation": { |
| 2303 | "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", |
| 2304 | "files": [ |
| 2305 | { "path": "update.rs", "outcome": "updated" }, |
| 2306 | { "path": "create.rs", "outcome": "created" }, |
| 2307 | { "path": "delete.rs", "outcome": "deleted" } |
| 2308 | ], |
| 2309 | "renames": [{ "from": "old.rs", "to": "new.rs" }] |
| 2310 | } |
| 2311 | }), |
| 2312 | ); |
| 2313 | let receipt = FileMutationReceipt::from_success(&app.workspace, &result).expect("receipt"); |
| 2314 | app.add_message(HistoryCell::Tool(ToolCell::PatchSummary( |
| 2315 | PatchSummaryCell { |
| 2316 | path: "4 files".to_string(), |
| 2317 | summary: "ok".to_string(), |
| 2318 | status: ToolStatus::Success, |
| 2319 | error: None, |
| 2320 | receipt: Some(receipt), |
| 2321 | }, |
| 2322 | ))); |
| 2323 | app.tool_details_by_cell.insert( |
| 2324 | 0, |
| 2325 | ToolDetailRecord { |
| 2326 | tool_id: "file-multi".to_string(), |
| 2327 | tool_name: "File".to_string(), |
| 2328 | input: serde_json::json!({"action": "patch"}), |
| 2329 | output: Some("ok".to_string()), |
| 2330 | }, |
| 2331 | ); |
| 2332 | |
| 2333 | app.work_surface.placement = WorkSurfacePlacement::Right; |
| 2334 | app.work_surface.effective_placement = WorkSurfacePlacement::Right; |
| 2335 | let text = render_text(&mut app, 80, 6); |
| 2336 | assert!(text.contains("Wrote 4 files"), "{text}"); |
| 2337 | } |
| 2338 | |
| 2339 | #[test] |
| 2340 | fn overflow_scroll_and_selection_remain_panel_owned() { |
| 2341 | let mut app = app(); |
| 2342 | add_todos(&mut app, 8); |
| 2343 | let _ = render_text(&mut app, 80, 5); |
| 2344 | assert!(app.work_surface.total_rows > app.work_surface.visible_rows); |
| 2345 | |
| 2346 | let transcript_delta = app.viewport.pending_scroll_delta; |
| 2347 | let outcome = super::handle_mouse( |
| 2348 | &mut app, |
| 2349 | MouseEvent { |
| 2350 | kind: MouseEventKind::ScrollDown, |
| 2351 | column: 10, |
| 2352 | row: 2, |
| 2353 | modifiers: KeyModifiers::NONE, |
| 2354 | }, |
| 2355 | ); |
| 2356 | assert!(outcome.consumed); |
| 2357 | assert_eq!(app.viewport.pending_scroll_delta, transcript_delta); |
| 2358 | assert!(app.work_surface.scroll_offset > 0); |
| 2359 | } |
| 2360 | |
| 2361 | #[test] |
| 2362 | fn mouse_wheel_reaches_last_todo_across_top_surface_heights() { |
| 2363 | for height in [3, 5, 6, 8] { |
| 2364 | let mut app = app(); |
| 2365 | add_todos(&mut app, 10); |
| 2366 | let _ = render_text(&mut app, 80, height); |
| 2367 | assert!(app.work_surface.total_rows > app.work_surface.visible_rows); |
| 2368 | let transcript_delta = app.viewport.pending_scroll_delta; |
| 2369 | |
| 2370 | let mut text = String::new(); |
| 2371 | for _ in 0..16 { |
| 2372 | let outcome = super::handle_mouse( |
| 2373 | &mut app, |
| 2374 | MouseEvent { |
| 2375 | kind: MouseEventKind::ScrollDown, |
| 2376 | column: 10, |
| 2377 | row: 1, |
| 2378 | modifiers: KeyModifiers::NONE, |
| 2379 | }, |
| 2380 | ); |
| 2381 | assert!(outcome.consumed, "height {height}"); |
| 2382 | text = render_text(&mut app, 80, height); |
| 2383 | } |
| 2384 | |
| 2385 | assert!( |
| 2386 | text.contains("work item 9"), |
| 2387 | "last To-do was unreachable at surface height {height}: {text:?}" |
| 2388 | ); |
| 2389 | assert_eq!( |
| 2390 | app.work_surface.scroll_offset, |
| 2391 | app.work_surface |
| 2392 | .total_rows |
| 2393 | .saturating_sub(app.work_surface.visible_rows.max(1)), |
| 2394 | "wheel did not reach the legal tail at surface height {height}" |
| 2395 | ); |
| 2396 | assert_eq!(app.viewport.pending_scroll_delta, transcript_delta); |
| 2397 | } |
| 2398 | } |
| 2399 | |
| 2400 | #[test] |
| 2401 | fn mouse_wheel_reaches_last_todo_in_side_rail_placements() { |
| 2402 | for placement in [ |
| 2403 | super::WorkSurfacePlacement::Left, |
| 2404 | super::WorkSurfacePlacement::Right, |
| 2405 | ] { |
| 2406 | let mut app = app(); |
| 2407 | add_todos(&mut app, 10); |
| 2408 | app.work_surface.placement = placement; |
| 2409 | app.work_surface.effective_placement = placement; |
| 2410 | let _ = render_text(&mut app, 30, 6); |
| 2411 | |
| 2412 | let mut text = String::new(); |
| 2413 | for _ in 0..16 { |
| 2414 | let outcome = super::handle_mouse( |
| 2415 | &mut app, |
| 2416 | MouseEvent { |
| 2417 | kind: MouseEventKind::ScrollDown, |
| 2418 | column: 10, |
| 2419 | row: 1, |
| 2420 | modifiers: KeyModifiers::NONE, |
| 2421 | }, |
| 2422 | ); |
| 2423 | assert!(outcome.consumed, "placement {placement:?}"); |
| 2424 | text = render_text(&mut app, 30, 6); |
| 2425 | } |
| 2426 | |
| 2427 | assert!( |
| 2428 | text.contains("work item 9"), |
| 2429 | "last To-do was unreachable in {placement:?}: {text:?}" |
| 2430 | ); |
| 2431 | } |
| 2432 | } |
| 2433 | |
| 2434 | #[test] |
| 2435 | fn keyboard_end_reveals_last_todo_after_redraw() { |
| 2436 | let mut app = app(); |
| 2437 | add_todos(&mut app, 10); |
| 2438 | let _ = render_text(&mut app, 80, 5); |
| 2439 | let _ = super::handle_key( |
| 2440 | &mut app, |
| 2441 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 2442 | ); |
| 2443 | let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); |
| 2444 | |
| 2445 | let text = render_text(&mut app, 80, 5); |
| 2446 | |
| 2447 | assert!(text.contains("work item 9"), "{text:?}"); |
| 2448 | assert_eq!( |
| 2449 | app.work_surface.scroll_offset, |
| 2450 | app.work_surface |
| 2451 | .total_rows |
| 2452 | .saturating_sub(app.work_surface.visible_rows.max(1)) |
| 2453 | ); |
| 2454 | } |
| 2455 | |
| 2456 | #[test] |
| 2457 | fn keyboard_navigation_is_panel_local_when_focused() { |
| 2458 | let mut app = app(); |
| 2459 | add_todos(&mut app, 3); |
| 2460 | app.work_surface.visible_rows = 2; |
| 2461 | assert!( |
| 2462 | super::handle_key( |
| 2463 | &mut app, |
| 2464 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT) |
| 2465 | ) |
| 2466 | .is_some() |
| 2467 | ); |
| 2468 | let first = app.work_surface.selected.clone(); |
| 2469 | let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); |
| 2470 | assert_ne!(app.work_surface.selected, first); |
| 2471 | assert!(app.work_surface.focused); |
| 2472 | } |
| 2473 | |
| 2474 | #[test] |
| 2475 | fn printable_keys_release_panel_focus_for_composer() { |
| 2476 | let mut app = app(); |
| 2477 | add_todos(&mut app, 1); |
| 2478 | let _ = super::handle_key( |
| 2479 | &mut app, |
| 2480 | KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), |
| 2481 | ); |
| 2482 | |
| 2483 | let outcome = super::handle_key( |
| 2484 | &mut app, |
| 2485 | KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), |
| 2486 | ); |
| 2487 | |
| 2488 | assert!(outcome.is_none()); |
| 2489 | assert!(!app.work_surface.focused); |
| 2490 | } |
| 2491 | |
| 2492 | #[test] |
| 2493 | fn side_placements_reuse_the_same_graph_rows() { |
| 2494 | for (placement, expected_chat_x, expected_rail_x) in [ |
| 2495 | (super::WorkSurfacePlacement::Left, 30, 0), |
| 2496 | (super::WorkSurfacePlacement::Right, 0, 70), |
| 2497 | ] { |
| 2498 | let mut app = app(); |
| 2499 | add_todos(&mut app, 2); |
| 2500 | app.work_surface.placement = placement; |
| 2501 | assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0); |
| 2502 | let area = ratatui::layout::Rect::new(0, 0, 100, 12); |
| 2503 | let (chat, rail) = super::split_chat(&mut app, area, 0); |
| 2504 | let rail = rail.expect("side rail"); |
| 2505 | assert_eq!(chat.x, expected_chat_x); |
| 2506 | assert_eq!(rail.x, expected_rail_x); |
| 2507 | assert_eq!(rail.width, 30); |
| 2508 | assert!( |
| 2509 | app.work_surface |
| 2510 | .latest_rows |
| 2511 | .iter() |
| 2512 | .any(|row| row.label == "work item 1") |
| 2513 | ); |
| 2514 | } |
| 2515 | } |
| 2516 | |
| 2517 | #[test] |
| 2518 | fn divider_drag_resizes_top_left_and_right_surfaces() { |
| 2519 | let mut top = app(); |
| 2520 | add_todos(&mut top, 3); |
| 2521 | let _ = render_text(&mut top, 80, 3); |
| 2522 | let down = super::handle_mouse( |
| 2523 | &mut top, |
| 2524 | MouseEvent { |
| 2525 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2526 | column: 20, |
| 2527 | row: 2, |
| 2528 | modifiers: KeyModifiers::NONE, |
| 2529 | }, |
| 2530 | ); |
| 2531 | assert!(down.consumed); |
| 2532 | let _ = super::handle_mouse( |
| 2533 | &mut top, |
| 2534 | MouseEvent { |
| 2535 | kind: MouseEventKind::Drag(MouseButton::Left), |
| 2536 | column: 20, |
| 2537 | row: 7, |
| 2538 | modifiers: KeyModifiers::NONE, |
| 2539 | }, |
| 2540 | ); |
| 2541 | assert_eq!(top.work_surface.top_height, 8); |
| 2542 | |
| 2543 | for (placement, drag_column, expected_width) in [ |
| 2544 | (WorkSurfacePlacement::Left, 39, 40), |
| 2545 | (WorkSurfacePlacement::Right, 10, 26), |
| 2546 | ] { |
| 2547 | let mut side = app(); |
| 2548 | add_todos(&mut side, 2); |
| 2549 | side.work_surface.placement = placement; |
| 2550 | side.work_surface.effective_placement = placement; |
| 2551 | let _ = render_text(&mut side, 30, 8); |
| 2552 | let divider_column = if placement == WorkSurfacePlacement::Left { |
| 2553 | 29 |
| 2554 | } else { |
| 2555 | 0 |
| 2556 | }; |
| 2557 | let _ = super::handle_mouse( |
| 2558 | &mut side, |
| 2559 | MouseEvent { |
| 2560 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2561 | column: divider_column, |
| 2562 | row: 2, |
| 2563 | modifiers: KeyModifiers::NONE, |
| 2564 | }, |
| 2565 | ); |
| 2566 | let _ = super::handle_mouse( |
| 2567 | &mut side, |
| 2568 | MouseEvent { |
| 2569 | kind: MouseEventKind::Drag(MouseButton::Left), |
| 2570 | column: drag_column, |
| 2571 | row: 2, |
| 2572 | modifiers: KeyModifiers::NONE, |
| 2573 | }, |
| 2574 | ); |
| 2575 | assert_eq!( |
| 2576 | side.work_surface.side_width, expected_width, |
| 2577 | "{placement:?}" |
| 2578 | ); |
| 2579 | } |
| 2580 | } |
| 2581 | |
| 2582 | #[test] |
| 2583 | fn divider_hover_and_drag_render_a_discoverable_handle() { |
| 2584 | let mut app = app(); |
| 2585 | add_todos(&mut app, 3); |
| 2586 | let resting = render_text(&mut app, 80, 3); |
| 2587 | assert!(resting.contains('─'), "{resting}"); |
| 2588 | |
| 2589 | let hover = super::handle_mouse( |
| 2590 | &mut app, |
| 2591 | MouseEvent { |
| 2592 | kind: MouseEventKind::Moved, |
| 2593 | column: 20, |
| 2594 | row: 2, |
| 2595 | modifiers: KeyModifiers::NONE, |
| 2596 | }, |
| 2597 | ); |
| 2598 | assert!(hover.consumed); |
| 2599 | assert!(app.work_surface.divider_hovered); |
| 2600 | let hovered = render_text(&mut app, 80, 3); |
| 2601 | assert!(hovered.contains('━'), "{hovered}"); |
| 2602 | |
| 2603 | let _ = super::handle_mouse( |
| 2604 | &mut app, |
| 2605 | MouseEvent { |
| 2606 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2607 | column: 20, |
| 2608 | row: 2, |
| 2609 | modifiers: KeyModifiers::NONE, |
| 2610 | }, |
| 2611 | ); |
| 2612 | let dragging = render_text(&mut app, 80, 3); |
| 2613 | assert!(dragging.contains('━'), "{dragging}"); |
| 2614 | } |
| 2615 | |
| 2616 | #[test] |
| 2617 | fn top_bar_excludes_generic_operations() { |
| 2618 | let mut operation_app = app(); |
| 2619 | let graph = operation_graph(NodeState::Failed); |
| 2620 | restore_graph(&mut operation_app, &graph); |
| 2621 | |
| 2622 | assert_eq!(super::height(&mut operation_app, 100, 24, AMPLE_BUDGET), 0); |
| 2623 | assert!(operation_app.work_surface.latest_rows.is_empty()); |
| 2624 | |
| 2625 | let mut todo_app = app(); |
| 2626 | add_todos(&mut todo_app, 2); |
| 2627 | assert!(super::height(&mut todo_app, 100, 24, AMPLE_BUDGET) > 0); |
| 2628 | assert!( |
| 2629 | todo_app |
| 2630 | .work_surface |
| 2631 | .latest_rows |
| 2632 | .iter() |
| 2633 | .all(|row| row.id.0.starts_with("graph:") || row.id.0.starts_with("worker:")) |
| 2634 | ); |
| 2635 | assert!( |
| 2636 | todo_app |
| 2637 | .work_surface |
| 2638 | .latest_rows |
| 2639 | .iter() |
| 2640 | .all(|row| !row.label.starts_with("Work ·")) |
| 2641 | ); |
| 2642 | } |
| 2643 | |
| 2644 | #[test] |
| 2645 | fn opened_row_toggles_closed_without_losing_selection() { |
| 2646 | let mut app = app(); |
| 2647 | add_todos(&mut app, 1); |
| 2648 | let row = super::model::project(&mut app) |
| 2649 | .into_iter() |
| 2650 | .find(|row| row.selectable) |
| 2651 | .expect("work row"); |
| 2652 | let open = row.primary_action.clone(); |
| 2653 | |
| 2654 | assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some()); |
| 2655 | // The action's pager is on screen, so the second activation is a |
| 2656 | // toggle-close. |
| 2657 | app.view_stack.push(crate::tui::pager::PagerView::from_text( |
| 2658 | "Work · test".to_string(), |
| 2659 | "body", |
| 2660 | 40, |
| 2661 | )); |
| 2662 | assert!(super::interaction::activate_primary(&mut app, &row.id, open).is_none()); |
| 2663 | assert!(app.work_surface.opened.is_none()); |
| 2664 | assert_eq!(app.work_surface.selected.as_ref(), Some(&row.id)); |
| 2665 | } |
| 2666 | |
| 2667 | #[test] |
| 2668 | fn a_click_after_the_pager_closed_itself_reopens_instead_of_going_dead() { |
| 2669 | // q/Esc inside the pager pops it without clearing `opened`. The next |
| 2670 | // click on that row must reopen its world, not be swallowed by a |
| 2671 | // stale toggle (owner regression report, 2026-08-04). |
| 2672 | let mut app = app(); |
| 2673 | add_todos(&mut app, 1); |
| 2674 | let row = super::model::project(&mut app) |
| 2675 | .into_iter() |
| 2676 | .find(|row| row.selectable) |
| 2677 | .expect("work row"); |
| 2678 | let open = row.primary_action.clone(); |
| 2679 | |
| 2680 | assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some()); |
| 2681 | // The pager was closed from inside itself; `opened` is now stale. |
| 2682 | assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id)); |
| 2683 | assert!(app.view_stack.is_empty()); |
| 2684 | |
| 2685 | let reopened = super::interaction::activate_primary(&mut app, &row.id, open); |
| 2686 | assert!( |
| 2687 | reopened.is_some(), |
| 2688 | "a stale opened owner must not swallow the next activation" |
| 2689 | ); |
| 2690 | assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id)); |
| 2691 | } |
| 2692 | |
| 2693 | /// Settled to-dos keep their rows across the recent-only TTL and new user |
| 2694 | /// turns. Finished sub-agents collapse into the Subagents Archived count |
| 2695 | /// (still reachable via the Agents panel) so fan-outs do not permanently |
| 2696 | /// eat the transcript. |
| 2697 | #[test] |
| 2698 | fn settled_todos_stay_and_finished_workers_collapse_after_ttl() { |
| 2699 | let mut app = app(); |
| 2700 | app.current_session_id = Some(SESSION.to_string()); |
| 2701 | { |
| 2702 | let mut todos = app.todos.try_lock().expect("todos"); |
| 2703 | todos.add("ship the fix".to_string(), TodoStatus::Completed); |
| 2704 | todos.add("verify the fix".to_string(), TodoStatus::Completed); |
| 2705 | } |
| 2706 | app.subagent_cache.push(cached_worker( |
| 2707 | "agent-settled", |
| 2708 | "builder", |
| 2709 | None, |
| 2710 | None, |
| 2711 | SubAgentStatus::Completed, |
| 2712 | )); |
| 2713 | |
| 2714 | app.work_surface.set_presentation_now_ms(0); |
| 2715 | let first = super::model::project_visible(&mut app); |
| 2716 | assert!( |
| 2717 | first.iter().any(|row| row.id.0.starts_with("graph:")), |
| 2718 | "settled to-dos must be listed: {first:?}" |
| 2719 | ); |
| 2720 | assert!( |
| 2721 | first |
| 2722 | .iter() |
| 2723 | .any(|row| { row.id.0 == "section:agents" && row.label.contains("Archived 1") }), |
| 2724 | "finished workers collapse into the header count: {first:?}" |
| 2725 | ); |
| 2726 | assert!( |
| 2727 | !first.iter().any(|row| row.id.0.starts_with("worker:")), |
| 2728 | "finished workers must leave strip rows: {first:?}" |
| 2729 | ); |
| 2730 | |
| 2731 | app.work_surface |
| 2732 | .set_presentation_now_ms(super::model::RECENT_ONLY_TTL_MS + 1); |
| 2733 | app.work_surface.note_user_turn_or_new_operation(); |
| 2734 | let later = super::model::project_visible(&mut app); |
| 2735 | assert!( |
| 2736 | later.iter().any(|row| row.id.0.starts_with("graph:")), |
| 2737 | "a settled to-do must survive the TTL and the next user turn: {later:?}" |
| 2738 | ); |
| 2739 | assert!( |
| 2740 | later |
| 2741 | .iter() |
| 2742 | .any(|row| { row.id.0 == "section:agents" && row.label.contains("Archived 1") }), |
| 2743 | "header still accounts for settled workers after TTL: {later:?}" |
| 2744 | ); |
| 2745 | assert!( |
| 2746 | super::height(&mut app, 100, 40, AMPLE_BUDGET) > 0, |
| 2747 | "the strip must keep its height while it holds settled work" |
| 2748 | ); |
| 2749 | } |
| 2750 | |
| 2751 | /// A to-do row says its state in words, in the `/task digest` vocabulary. |
| 2752 | /// Dropping the words (2011b9b11 conflated them with the redundant kind |
| 2753 | /// label) was half of owner regression A1. |
| 2754 | #[test] |
| 2755 | fn todo_rows_carry_their_status_words() { |
| 2756 | let mut app = app(); |
| 2757 | add_todos(&mut app, 3); |
| 2758 | let rows = super::model::project(&mut app); |
| 2759 | let todo_details: Vec<&str> = rows |
| 2760 | .iter() |
| 2761 | .filter(|row| row.id.0.starts_with("graph:")) |
| 2762 | .map(|row| row.detail.as_str()) |
| 2763 | .collect(); |
| 2764 | assert!( |
| 2765 | todo_details.contains(&"in progress"), |
| 2766 | "the active step says so in words: {todo_details:?}" |
| 2767 | ); |
| 2768 | assert!( |
| 2769 | todo_details.contains(&"pending"), |
| 2770 | "a pending step is labeled, not blank: {todo_details:?}" |
| 2771 | ); |
| 2772 | |
| 2773 | // And the words are painted, not just projected. |
| 2774 | let text = render_text(&mut app, 100, 6); |
| 2775 | assert!(text.contains("in progress"), "{text}"); |
| 2776 | assert!(text.contains("pending"), "{text}"); |
| 2777 | } |
| 2778 | |
| 2779 | /// Top strip collapses completed/cancelled workers into an Archived count |
| 2780 | /// while keeping live (and failed) workers as rows. Agents panel still |
| 2781 | /// lists every worker — see the click test below. |
| 2782 | #[test] |
| 2783 | fn top_strip_collapses_settled_subagents_into_header() { |
| 2784 | let mut app = app(); |
| 2785 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 2786 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 2787 | app.current_session_id = Some(SESSION.to_string()); |
| 2788 | app.subagent_cache.push(cached_worker( |
| 2789 | "agent-live", |
| 2790 | "scout", |
| 2791 | None, |
| 2792 | None, |
| 2793 | SubAgentStatus::Running, |
| 2794 | )); |
| 2795 | app.subagent_cache.push(cached_worker( |
| 2796 | "agent-done", |
| 2797 | "builder", |
| 2798 | None, |
| 2799 | None, |
| 2800 | SubAgentStatus::Completed, |
| 2801 | )); |
| 2802 | app.subagent_cache.push(cached_worker( |
| 2803 | "agent-failed", |
| 2804 | "verifier", |
| 2805 | None, |
| 2806 | None, |
| 2807 | SubAgentStatus::Failed("boom".to_string()), |
| 2808 | )); |
| 2809 | |
| 2810 | let rows = super::model::project_visible(&mut app); |
| 2811 | let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); |
| 2812 | let ids: Vec<&str> = rows.iter().map(|row| row.id.0.as_str()).collect(); |
| 2813 | |
| 2814 | assert!( |
| 2815 | labels.iter().any(|label| { |
| 2816 | label.contains("1 running") |
| 2817 | && label.contains("1 needs input") |
| 2818 | && label.contains("Archived 1") |
| 2819 | }), |
| 2820 | "header splits running / needs-input / settled: {labels:?}" |
| 2821 | ); |
| 2822 | assert!( |
| 2823 | ids.contains(&"worker:agent-live"), |
| 2824 | "running worker stays in the strip: {ids:?}" |
| 2825 | ); |
| 2826 | assert!( |
| 2827 | ids.contains(&"worker:agent-failed"), |
| 2828 | "failed worker stays (needs attention): {ids:?}" |
| 2829 | ); |
| 2830 | assert!( |
| 2831 | !ids.contains(&"worker:agent-done"), |
| 2832 | "completed worker must leave the strip: {ids:?}" |
| 2833 | ); |
| 2834 | } |
| 2835 | |
| 2836 | #[test] |
| 2837 | fn subagent_header_opens_the_full_agents_register() { |
| 2838 | let mut app = app(); |
| 2839 | app.work_surface.placement = super::WorkSurfacePlacement::Top; |
| 2840 | app.work_surface.effective_placement = super::WorkSurfacePlacement::Top; |
| 2841 | app.current_session_id = Some(SESSION.to_string()); |
| 2842 | app.subagent_cache.push(cached_worker( |
| 2843 | "agent-archived", |
| 2844 | "builder", |
| 2845 | None, |
| 2846 | None, |
| 2847 | SubAgentStatus::Completed, |
| 2848 | )); |
| 2849 | |
| 2850 | let top = render_text(&mut app, 100, 4); |
| 2851 | assert!(top.contains("Archived 1"), "{top}"); |
| 2852 | let header_y = app |
| 2853 | .work_surface |
| 2854 | .hitboxes |
| 2855 | .iter() |
| 2856 | .find(|hit| hit.id.0 == "section:agents") |
| 2857 | .expect("subagent header must be a real hit target") |
| 2858 | .row_y; |
| 2859 | let action = super::handle_mouse( |
| 2860 | &mut app, |
| 2861 | MouseEvent { |
| 2862 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2863 | column: 2, |
| 2864 | row: header_y, |
| 2865 | modifiers: KeyModifiers::NONE, |
| 2866 | }, |
| 2867 | ) |
| 2868 | .action |
| 2869 | .expect("subagent header must dispatch its primary action"); |
| 2870 | assert_eq!(action, SidebarRowAction::ShowSubagentsPanel); |
| 2871 | assert!(crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action).is_empty()); |
| 2872 | assert_eq!(app.work_surface.panel, super::RailPanel::Agents); |
| 2873 | |
| 2874 | let agents = render_text(&mut app, 100, 6); |
| 2875 | assert!( |
| 2876 | agents.contains("agent-archived") || agents.contains("builder"), |
| 2877 | "the full Agents register keeps the archived worker reachable: {agents}" |
| 2878 | ); |
| 2879 | } |
| 2880 | |
| 2881 | /// Acceptance for owner regression A2: an agent row is a door in the |
| 2882 | /// Agents panel too, and a FINISHED agent's world still opens — the |
| 2883 | /// panel is a standing register, not a live-only view. |
| 2884 | #[test] |
| 2885 | fn agents_panel_click_opens_details_even_for_finished_agents() { |
| 2886 | let mut app = app(); |
| 2887 | app.work_surface.panel = super::RailPanel::Agents; |
| 2888 | app.current_session_id = Some(SESSION.to_string()); |
| 2889 | app.subagent_cache.push(cached_worker( |
| 2890 | "agent-finished", |
| 2891 | "builder", |
| 2892 | None, |
| 2893 | None, |
| 2894 | SubAgentStatus::Completed, |
| 2895 | )); |
| 2896 | |
| 2897 | let _ = render_text(&mut app, 100, 6); |
| 2898 | let row_y = app |
| 2899 | .work_surface |
| 2900 | .hitboxes |
| 2901 | .iter() |
| 2902 | .find(|hit| hit.id.0 == "worker:agent-finished") |
| 2903 | .expect("finished agent row must keep a hitbox in the Agents panel") |
| 2904 | .row_y; |
| 2905 | let action = super::handle_mouse( |
| 2906 | &mut app, |
| 2907 | MouseEvent { |
| 2908 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2909 | column: 2, |
| 2910 | row: row_y, |
| 2911 | modifiers: KeyModifiers::NONE, |
| 2912 | }, |
| 2913 | ) |
| 2914 | .action |
| 2915 | .expect("click on a finished agent row must dispatch its primary action"); |
| 2916 | assert_eq!( |
| 2917 | action, |
| 2918 | SidebarRowAction::OpenAgentDetail { |
| 2919 | agent_id: "agent-finished".to_string() |
| 2920 | } |
| 2921 | ); |
| 2922 | crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action); |
| 2923 | assert!( |
| 2924 | !app.view_stack.is_empty(), |
| 2925 | "the finished agent's details must actually open" |
| 2926 | ); |
| 2927 | } |
| 2928 | |
| 2929 | /// Acceptance for owner regression A1: to-do rows are doors in the |
| 2930 | /// Pinned panel too — clicking one opens the work inspector. |
| 2931 | #[test] |
| 2932 | fn pinned_panel_todo_rows_stay_clickable() { |
| 2933 | let mut app = app(); |
| 2934 | app.work_surface.panel = super::RailPanel::Pinned; |
| 2935 | add_todos(&mut app, 2); |
| 2936 | |
| 2937 | let _ = render_text(&mut app, 100, 6); |
| 2938 | let hit = app |
| 2939 | .work_surface |
| 2940 | .hitboxes |
| 2941 | .iter() |
| 2942 | .find(|hit| hit.id.0.starts_with("graph:")) |
| 2943 | .expect("Pinned panel to-do rows must keep hitboxes") |
| 2944 | .clone(); |
| 2945 | let action = super::handle_mouse( |
| 2946 | &mut app, |
| 2947 | MouseEvent { |
| 2948 | kind: MouseEventKind::Down(MouseButton::Left), |
| 2949 | column: 2, |
| 2950 | row: hit.row_y, |
| 2951 | modifiers: KeyModifiers::NONE, |
| 2952 | }, |
| 2953 | ) |
| 2954 | .action |
| 2955 | .expect("click on a Pinned to-do row must dispatch its primary action"); |
| 2956 | assert!( |
| 2957 | matches!(action, SidebarRowAction::InspectWork { .. }), |
| 2958 | "a to-do row opens the work inspector: {action:?}" |
| 2959 | ); |
| 2960 | } |
| 2961 | } |
| 2962 |