返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / work_surface / mod.rs
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(&current), "{text:?}");
566 assert!(text.contains(&next), "{text:?}");
567 assert!(
568 text.find(&done) < text.find(&current) && text.find(&current) < 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
2962 lines RUST