返回 CodeWhale
model.rs
根目录 / crates / tui / src / tui / work_surface / model.rs
1 use std::collections::HashSet;
2 use std::fmt::Write as _;
3 use std::path::{Component, Path};
4 use std::time::Instant;
5
6 use ratatui::layout::Rect;
7
8 use crate::settings::InlineDiffMode;
9 use crate::tools::canonical_action::canonical_action_alias;
10 use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus};
11 use crate::tui::app::{
12 AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction, TaskPanelEntry,
13 };
14 use crate::tui::background_indicator::is_live_shell_entry;
15 use crate::tui::history::{
16 FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell,
17 };
18 use crate::tui::menu_style::{StatusKind, status_mark};
19 use crate::work_graph::{
20 AcceptanceRequirement, EdgeKind, EvidenceKind, EvidenceKindTag, NodeKind, NodeState,
21 OperationBinding, OwnerState, Provenance, WorkGraphSnapshot, WorkNode,
22 };
23
24 /// Persisted Ocean work-surface placement. Bottom is deliberately absent: the
25 /// composer and phase footer own the shell's lower edge. `Off` hides the rail
26 /// outright (rail unification, 0.9.4).
27 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28 pub enum WorkSurfacePlacement {
29 /// Under the composer — the default (round 3, 2026-09-01: the bar's
30 /// information lives below the composer, so scrolling up reads as
31 /// intentional history).
32 #[default]
33 Bottom,
34 Top,
35 Left,
36 Right,
37 Off,
38 }
39
40 impl WorkSurfacePlacement {
41 /// Strip placements render as a horizontal band; side placements as a
42 /// rail. Bottom shares Top's auto-fit height math — only its position
43 /// in the frame differs.
44 #[must_use]
45 pub const fn is_strip(self) -> bool {
46 matches!(self, Self::Top | Self::Bottom)
47 }
48 }
49
50 /// Which view the bottom dock shows. Orthogonal to placement: the user picks
51 /// *where* the dock sits and *what* it shows.
52 ///
53 /// One bottom view, cycled in this order (founder redirect, 2026-09-02):
54 /// agents → tasks → background → files → notepad → context → git → price.
55 /// `Pinned` folded into `Tasks` (the goal heading rides the tasks view on
56 /// side placements); `pinned` stays accepted as a setting word.
57 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
58 pub enum RailPanel {
59 /// The agent roster: one row per sub-agent, retained after completion.
60 Agents,
61 /// The to-do list: plan-step rows from the work graph.
62 #[default]
63 Tasks,
64 /// Background shells and durable tasks. Scheduled work has its own manager.
65 Background,
66 /// Files edited this session (`+/−`) and files read into context.
67 Files,
68 /// Per-workspace plain-text notes (`.codewhale/notes.md`).
69 Notepad,
70 /// The context budget: used/limit, compaction threshold, breakdown.
71 Context,
72 /// Branch, ahead/behind, dirty count, last commits.
73 Git,
74 /// Session cost, per-agent cost, cache hit %, model rate.
75 Price,
76 }
77
78 impl RailPanel {
79 /// Cycle order — also the tab order in the dock strip.
80 pub const ORDER: [RailPanel; 8] = [
81 Self::Tasks,
82 Self::Agents,
83 Self::Background,
84 Self::Files,
85 Self::Notepad,
86 Self::Context,
87 Self::Git,
88 Self::Price,
89 ];
90
91 /// Views the dock opens on its own when they have content and the user
92 /// has not picked one: the to-do list first, then live agents, then
93 /// background work (founder, 2026-09-03: "To-do and Agents as the first
94 /// two, opening on To-do"). The others open only when cycled to.
95 pub const AUTO_ORDER: [RailPanel; 3] = [Self::Tasks, Self::Agents, Self::Background];
96
97 #[must_use]
98 pub fn next(self) -> Self {
99 let index = Self::ORDER.iter().position(|p| *p == self).unwrap_or(0);
100 Self::ORDER[(index + 1) % Self::ORDER.len()]
101 }
102
103 #[must_use]
104 pub fn prev(self) -> Self {
105 let index = Self::ORDER.iter().position(|p| *p == self).unwrap_or(0);
106 Self::ORDER[(index + Self::ORDER.len() - 1) % Self::ORDER.len()]
107 }
108 }
109
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 pub(super) enum DockTabTarget {
112 Panel(RailPanel),
113 Close,
114 }
115
116 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
117 pub(super) struct DockTabHitbox {
118 pub(super) target: DockTabTarget,
119 pub(super) area: Rect,
120 }
121
122 impl RailPanel {
123 #[must_use]
124 pub fn parse(value: &str) -> Self {
125 match value.trim().to_ascii_lowercase().as_str() {
126 "agents" | "subagents" | "sub-agents" => Self::Agents,
127 "tasks" | "todo" | "todos" => Self::Tasks,
128 "background" | "shells" | "jobs" => Self::Background,
129 "files" | "changes" => Self::Files,
130 "notepad" | "notes" => Self::Notepad,
131 "context" | "session" => Self::Context,
132 "git" | "branch" => Self::Git,
133 "price" | "cost" => Self::Price,
134 _ => Self::Tasks,
135 }
136 }
137
138 #[must_use]
139 pub const fn as_setting(self) -> &'static str {
140 match self {
141 Self::Agents => "agents",
142 Self::Tasks => "tasks",
143 Self::Background => "background",
144 Self::Files => "files",
145 Self::Notepad => "notepad",
146 Self::Context => "context",
147 Self::Git => "git",
148 Self::Price => "price",
149 }
150 }
151
152 /// Tab label. CAPS on purpose (founder ruling 2026-09-03): the tabs are
153 /// the dock's primary navigation and lowercase nouns read as status
154 /// text beside them. The plan-step list reads TODO, not TASKS — the
155 /// Background panel already holds running tasks, so TASKS collides
156 /// with it. `as_setting()` stays lowercase — it is the persisted
157 /// value, never a label.
158 #[must_use]
159 pub const fn title(self) -> &'static str {
160 match self {
161 Self::Agents => "AGENTS",
162 Self::Tasks => "TODO",
163 Self::Background => "BACKGROUND",
164 Self::Files => "FILES",
165 Self::Notepad => "NOTEPAD",
166 Self::Context => "CONTEXT",
167 Self::Git => "GIT",
168 Self::Price => "PRICE",
169 }
170 }
171 }
172
173 impl WorkSurfacePlacement {
174 #[must_use]
175 pub fn parse(value: &str) -> Self {
176 match value.trim().to_ascii_lowercase().as_str() {
177 "top" => Self::Top,
178 "left" => Self::Left,
179 "right" => Self::Right,
180 "off" => Self::Off,
181 _ => Self::Bottom,
182 }
183 }
184
185 #[must_use]
186 pub const fn as_setting(self) -> &'static str {
187 match self {
188 Self::Bottom => "bottom",
189 Self::Top => "top",
190 Self::Left => "left",
191 Self::Right => "right",
192 Self::Off => "off",
193 }
194 }
195 }
196
197 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
198 pub struct WorkRowId(pub String);
199
200 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
201 pub(super) enum WorkTone {
202 Heading,
203 Live,
204 /// Consequential and waiting on someone — Cognition, not Failure. A to-do
205 /// blocked on your answer has not failed.
206 Attention,
207 /// Something actually failed. The only tone that spends Failure red.
208 Failure,
209 Success,
210 Muted,
211 }
212
213 #[derive(Debug, Clone)]
214 pub(super) struct WorkRow {
215 pub id: WorkRowId,
216 pub mark: &'static str,
217 pub label: String,
218 pub detail: String,
219 pub tone: WorkTone,
220 pub selectable: bool,
221 pub primary_action: Option<SidebarRowAction>,
222 /// Present only on sub-agent rows. Carries the fields the fleet row paints
223 /// beyond `label`, so the renderer can drop them one at a time as the
224 /// surface narrows instead of truncating one pre-joined string.
225 pub agent: Option<AgentRowFacts>,
226 }
227
228 /// The parts of a sub-agent row that are laid out as their own columns.
229 ///
230 /// `label` already carries the preferred identity column (nesting indent,
231 /// nickname when the agent has one, `(+N)` child count). This carries the
232 /// rest: the role-only spelling of that same column, what the agent is doing,
233 /// and the right-aligned receipt.
234 #[derive(Debug, Clone, Default, PartialEq, Eq)]
235 pub(super) struct AgentRowFacts {
236 /// The identity column spelled with the fleet role instead of the
237 /// nickname. Equal to `label` when the agent has no nickname. The
238 /// renderer falls back to this when a nickname is too wide for the
239 /// identity column — a name is shown whole or not at all.
240 pub role_label: String,
241 /// The status word (`running`, `completed`, `failed`, …) painted as its
242 /// own column. The glyph carries the same fact for scanning; the word is
243 /// what makes the row legible without memorizing glyph vocabulary
244 /// (owner regression report, 2026-08-04).
245 pub status: String,
246 /// What the agent was sent to do.
247 pub objective: String,
248 /// Wall-clock seconds, frozen once the agent is observed terminal so a
249 /// finished agent stops ticking. `None` when no duration is known.
250 pub elapsed_secs: Option<u64>,
251 /// Model from the child's frozen spawn route or a later effective-route
252 /// usage envelope; never inferred from the parent session's model.
253 pub model: Option<String>,
254 /// Tokens used by the child. `None` means *genuinely unknown* —
255 /// the row then renders no token figure rather than claiming zero.
256 pub tokens: Option<u64>,
257 /// Unsettled items on this child's to-do list. `None` when no list has
258 /// been published; `Some(0)` means the list exists and is fully settled
259 /// (the strip still hides a zero chip — see `agent_receipt`).
260 pub todos_remaining: Option<u32>,
261 /// Whether this row on its own keeps the work dock open
262 /// ([`live_agent_row_count`]). Running, queued and answerable work does;
263 /// a finished agent does not.
264 ///
265 /// Typed at every construction site, never sniffed back out of `status` —
266 /// a renderer must not infer lifecycle from an English word
267 /// (`crates/tui/AGENTS.md`). The derivation deliberately differs by
268 /// source because the sources carry different facts: a card in the
269 /// 45-second live cache is *news*, so a fresh failure re-opens the dock
270 /// long enough to be seen, while the same worker's retained receipt stays
271 /// readable for an hour (`COMPLETED_AGENT_RETENTION`) and must not pin the
272 /// dock open for the rest of the session.
273 pub holds_dock_open: bool,
274 }
275
276 #[derive(Debug, Clone)]
277 pub(super) struct WorkHitbox {
278 pub id: WorkRowId,
279 pub row_y: u16,
280 }
281
282 #[derive(Debug, Clone)]
283 enum WorkSourceState {
284 Error(String),
285 Disconnected,
286 }
287
288 impl WorkSourceState {
289 const fn label(&self) -> &'static str {
290 match self {
291 Self::Error(_) => "error",
292 Self::Disconnected => "disconnected",
293 }
294 }
295
296 fn detail(&self) -> &str {
297 match self {
298 Self::Error(error) => error,
299 Self::Disconnected => "Work Graph runtime is not attached",
300 }
301 }
302 }
303
304 /// Live Work summary recent-only presentation lifetime (#4688).
305 pub(super) const RECENT_ONLY_TTL_MS: u64 = 4_000;
306 /// Settled file/search/write receipt lifetime in the live strip (#4690).
307 pub(super) const ACTIVITY_RECEIPT_TTL_MS: u64 = 3_000;
308 pub(super) const TOP_HEIGHT_MIN: u16 = crate::settings::WORK_SURFACE_TOP_HEIGHT_MIN;
309 pub(super) const TOP_HEIGHT_MAX: u16 = crate::settings::WORK_SURFACE_TOP_HEIGHT_MAX;
310 pub(super) const SIDE_WIDTH_MIN: u16 = 26;
311 pub(super) const SIDE_WIDTH_MAX: u16 = 80;
312
313 /// Which restored work rows belong to a prior session instance (#4416).
314 ///
315 /// Decided once per session id and cached: this instance's own later
316 /// autosaves restamp the persisted record, and re-probing after that would
317 /// re-badge restored rows as live work.
318 #[derive(Debug, Clone)]
319 pub(crate) struct SessionInstanceScope {
320 pub(super) session_id: String,
321 pub(super) from_prior_instance: bool,
322 /// Node ids present in the graph supplied at session restore time: the
323 /// restored persisted rows, as opposed to work this instance creates
324 /// afterwards.
325 pub(super) restored_nodes: HashSet<String>,
326 }
327
328 #[derive(Debug, Clone)]
329 pub struct WorkSurfaceState {
330 pub placement: WorkSurfacePlacement,
331 pub(super) effective_placement: WorkSurfacePlacement,
332 /// Panel selection — orthogonal to placement.
333 pub panel: RailPanel,
334 /// The user picked `panel` (cycle key, tab click, `/workbar <view>`), so
335 /// the auto rule leaves it alone and an empty view still paints. Esc
336 /// clears it and the dock goes back to showing whichever work view has
337 /// content.
338 pub explicit_view: bool,
339 pub top_height: u16,
340 pub side_width: u16,
341 pub(super) resizing: bool,
342 pub(super) divider_hovered: bool,
343 pub(super) resize_anchor_column: u16,
344 pub(super) resize_anchor_row: u16,
345 pub(super) resize_anchor_size: u16,
346 /// Focus owner axis — distinct from selection and detail-open.
347 pub focused: bool,
348 /// Keyboard/mouse selection highlight.
349 pub selected: Option<WorkRowId>,
350 /// Which row currently owns an open detail (pager / agent card).
351 pub opened: Option<WorkRowId>,
352 pub scroll_offset: usize,
353 pub last_area: Option<Rect>,
354 pub visible_rows: usize,
355 pub total_rows: usize,
356 pub(super) dock_tabs: Vec<DockTabHitbox>,
357 pub(super) pressed_tab: Option<DockTabTarget>,
358 pub(super) hovered_tab: Option<DockTabTarget>,
359 pub dismissed: bool,
360 pub dismissed_at_rows: usize,
361 /// The auto view measured at dismissal: the dock re-opens when that
362 /// view grows or when the auto rule picks a different one (a worker
363 /// starting while the to-do list was closed is new work).
364 pub(super) dismissed_view: RailPanel,
365 pub(super) hovered: Option<WorkRowId>,
366 pub(super) hitboxes: Vec<WorkHitbox>,
367 pub(super) cached_graph: Option<WorkGraphSnapshot>,
368 pub(super) latest_rows: Vec<WorkRow>,
369 /// Full ranked catalog retained for inspector/history after live chrome expires.
370 pub(super) catalog_rows: Vec<WorkRow>,
371 /// Monotonic origin for presentation lifetimes (not wall-clock epoch).
372 presentation_origin: Instant,
373 /// Optional injected clock (ms since origin) for deterministic tests.
374 presentation_now_ms: Option<u64>,
375 /// When the projection last became recent-only (ms since origin).
376 recent_only_since_ms: Option<u64>,
377 /// Fingerprint of the recent-only set so a new completion can re-surface once.
378 recent_only_fingerprint: u64,
379 /// After TTL or user-turn, keep the live summary collapsed until new actionable work.
380 recent_only_suppressed: bool,
381 /// When the current activity receipt fingerprint first became live.
382 activity_since_ms: Option<u64>,
383 activity_fingerprint: u64,
384 activity_suppressed: bool,
385 /// Bumped on accepted user turns / newly started operations.
386 user_turn_epoch: u64,
387 last_handled_user_turn_epoch: u64,
388 /// Elapsed wall-clock, in ms, captured the first frame each sub-agent was
389 /// observed in a terminal state. The manager's `duration_ms` is
390 /// `started_at.elapsed()` recomputed per snapshot, so it keeps growing
391 /// after an agent finishes; latching the first terminal reading is what
392 /// makes a completed row stop ticking.
393 pub(super) frozen_agent_elapsed_ms: std::collections::HashMap<String, u64>,
394 /// Session-instance ownership of the restored session record (#4416).
395 pub(crate) session_instance: Option<SessionInstanceScope>,
396 /// Test override for the sessions directory the ownership probe reads;
397 /// production resolves the default location lazily.
398 pub(crate) session_owner_probe_dir: Option<std::path::PathBuf>,
399 }
400
401 impl Default for WorkSurfaceState {
402 fn default() -> Self {
403 Self::with_placement(WorkSurfacePlacement::Bottom)
404 }
405 }
406
407 impl WorkSurfaceState {
408 #[must_use]
409 pub(crate) fn is_resizing(&self) -> bool {
410 self.resizing
411 }
412
413 /// The placement actually rendered this frame (after the narrow-terminal
414 /// fallback), for truthful status readouts.
415 #[must_use]
416 pub fn effective_placement(&self) -> WorkSurfacePlacement {
417 self.effective_placement
418 }
419
420 #[must_use]
421 pub fn with_placement(placement: WorkSurfacePlacement) -> Self {
422 Self::with_layout(placement, 3, 30)
423 }
424
425 #[must_use]
426 pub fn with_layout(placement: WorkSurfacePlacement, top_height: u16, side_width: u16) -> Self {
427 Self {
428 placement,
429 effective_placement: placement,
430 panel: RailPanel::default(),
431 explicit_view: false,
432 top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX),
433 side_width: side_width.clamp(SIDE_WIDTH_MIN, SIDE_WIDTH_MAX),
434 resizing: false,
435 divider_hovered: false,
436 resize_anchor_column: 0,
437 resize_anchor_row: 0,
438 resize_anchor_size: 0,
439 focused: false,
440 selected: None,
441 opened: None,
442 scroll_offset: 0,
443 last_area: None,
444 visible_rows: 0,
445 total_rows: 0,
446 dock_tabs: Vec::new(),
447 pressed_tab: None,
448 hovered_tab: None,
449 dismissed: false,
450 dismissed_at_rows: 0,
451 dismissed_view: RailPanel::default(),
452 hovered: None,
453 hitboxes: Vec::new(),
454 cached_graph: None,
455 latest_rows: Vec::new(),
456 catalog_rows: Vec::new(),
457 presentation_origin: Instant::now(),
458 presentation_now_ms: None,
459 recent_only_since_ms: None,
460 recent_only_fingerprint: 0,
461 recent_only_suppressed: false,
462 activity_since_ms: None,
463 activity_fingerprint: 0,
464 activity_suppressed: false,
465 user_turn_epoch: 0,
466 last_handled_user_turn_epoch: 0,
467 frozen_agent_elapsed_ms: std::collections::HashMap::new(),
468 session_instance: None,
469 session_owner_probe_dir: None,
470 }
471 }
472
473 /// Inject a monotonic clock for presentation-lifetime tests.
474 #[cfg(test)]
475 pub(super) fn set_presentation_now_ms(&mut self, now_ms: u64) {
476 self.presentation_now_ms = Some(now_ms);
477 }
478
479 /// Signal that the user accepted a turn or a new operation started.
480 /// Recent-only live chrome collapses immediately (#4688).
481 pub fn note_user_turn_or_new_operation(&mut self) {
482 self.user_turn_epoch = self.user_turn_epoch.wrapping_add(1);
483 }
484
485 /// Record the exact graph restored from persisted session state. This
486 /// must happen at the restore boundary: the first later runtime capture
487 /// may already contain work created by this process.
488 pub(crate) fn record_restored_session(
489 &mut self,
490 session_id: &str,
491 graph: Option<&WorkGraphSnapshot>,
492 ) {
493 let from_prior_instance = session_record_from_prior_instance(self, session_id);
494 let restored_nodes = if from_prior_instance {
495 graph
496 .into_iter()
497 .flat_map(|graph| graph.nodes.iter())
498 .map(|node| node.id.as_str().to_string())
499 .collect()
500 } else {
501 HashSet::new()
502 };
503 self.session_instance = Some(SessionInstanceScope {
504 session_id: session_id.to_string(),
505 from_prior_instance,
506 restored_nodes,
507 });
508 }
509
510 /// A restored graph row owned by a prior session instance whose terminal
511 /// failure or staleness must not render as this session's live work
512 /// (#4416). Plan steps stay: the resumed to-do list is the point of
513 /// restoring; failed/stale operations and blockers are the leak.
514 pub(super) fn is_prior_instance_residue(&self, node: &WorkNode) -> bool {
515 let Some(scope) = self.session_instance.as_ref() else {
516 return false;
517 };
518 scope.from_prior_instance
519 && scope.restored_nodes.contains(node.id.as_str())
520 && node.kind != NodeKind::PlanStep
521 && matches!(node.state, NodeState::Failed | NodeState::Stale)
522 }
523
524 fn now_ms(&self) -> u64 {
525 self.presentation_now_ms.unwrap_or_else(|| {
526 u64::try_from(self.presentation_origin.elapsed().as_millis()).unwrap_or(u64::MAX)
527 })
528 }
529
530 pub(super) fn selected_index(&self, rows: &[WorkRow]) -> Option<usize> {
531 self.selected
532 .as_ref()
533 .and_then(|selected| rows.iter().position(|row| &row.id == selected))
534 }
535
536 /// Keep row identity and the viewport offset valid without moving the
537 /// viewport to the remembered keyboard selection. Mouse-wheel scrolling
538 /// is allowed to leave that selection off-screen until keyboard
539 /// navigation resumes.
540 pub(super) fn clamp_viewport(&mut self, rows: &[WorkRow]) {
541 let selectable = rows.iter().filter(|row| row.selectable).collect::<Vec<_>>();
542 if selectable.is_empty() {
543 self.selected = None;
544 // An explicitly opened empty panel still owns its tabs and Esc.
545 // Preserve that focus; ordinary typing can still release it.
546 self.focused &= self.explicit_view;
547 self.scroll_offset = 0;
548 return;
549 }
550 let established_selection = selectable
551 .iter()
552 .any(|row| Some(&row.id) == self.selected.as_ref());
553 if !established_selection {
554 let preferred = selectable
555 .iter()
556 // Both halves of the old `Attention` tone: splitting Failure out
557 // of it changed what red means, not what deserves focus first.
558 .find(|row| matches!(row.tone, WorkTone::Attention | WorkTone::Failure))
559 .or_else(|| selectable.iter().find(|row| row.tone == WorkTone::Live))
560 .copied()
561 .unwrap_or(selectable[0]);
562 self.selected = Some(preferred.id.clone());
563 // Establishing a new selection should reveal the current or
564 // needs-input item without reordering the canonical list. Later
565 // redraws keep mouse-wheel ownership and do not chase selection.
566 if let Some(selected) = rows.iter().position(|row| row.id == preferred.id) {
567 if selected < self.scroll_offset {
568 self.scroll_offset = selected;
569 } else if self.visible_rows > 0
570 && selected >= self.scroll_offset.saturating_add(self.visible_rows)
571 {
572 self.scroll_offset = selected.saturating_add(1) - self.visible_rows;
573 }
574 }
575 }
576 self.scroll_offset = self
577 .scroll_offset
578 .min(rows.len().saturating_sub(self.visible_rows.max(1)));
579 }
580
581 /// Reveal the remembered selection after keyboard navigation. Rendering
582 /// alone must use `clamp_viewport`; otherwise every redraw undoes a mouse
583 /// wheel offset when the selection is above the viewport.
584 pub(super) fn clamp_selection(&mut self, rows: &[WorkRow]) {
585 self.clamp_viewport(rows);
586 let Some(selected) = self.selected_index(rows) else {
587 return;
588 };
589 if selected < self.scroll_offset {
590 self.scroll_offset = selected;
591 } else if self.visible_rows > 0
592 && selected >= self.scroll_offset.saturating_add(self.visible_rows)
593 {
594 self.scroll_offset = selected.saturating_add(1).saturating_sub(self.visible_rows);
595 }
596 self.scroll_offset = self
597 .scroll_offset
598 .min(rows.len().saturating_sub(self.visible_rows.max(1)));
599 }
600 }
601
602 pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
603 let active_session = app.current_session_id.is_some();
604 freeze_terminal_agent_elapsed(app);
605 let agents = agent_rows(app);
606 let coordination = coordination_row(app);
607 let activity = settled_file_activity(app);
608 let capture = app.runtime_services.work.as_ref().map(|work| {
609 work.try_capture(app.current_session_id.as_deref())
610 .map(|snapshot| snapshot.map(|snapshot| snapshot.graph))
611 });
612
613 let (graph, source_state) = match capture {
614 Some(Ok(Some(graph))) => {
615 app.work_surface.cached_graph = Some(graph.clone());
616 (Some(graph), None)
617 }
618 Some(Ok(None)) => {
619 app.work_surface.cached_graph = None;
620 (None, None)
621 }
622 Some(Err(error)) => (
623 app.work_surface.cached_graph.clone(),
624 active_session.then_some(WorkSourceState::Error(error)),
625 ),
626 None => (
627 app.work_surface.cached_graph.clone(),
628 active_session.then_some(WorkSourceState::Disconnected),
629 ),
630 };
631
632 update_session_instance_scope(app);
633
634 let mut rows = match graph {
635 Some(graph) => graph_rows(
636 &mut app.work_surface,
637 &graph,
638 source_state.as_ref(),
639 agents,
640 coordination,
641 activity,
642 ),
643 None if !agents.is_empty() || coordination.is_some() || !activity.is_empty() => {
644 ordered_rows(
645 &mut app.work_surface,
646 None,
647 source_state.as_ref(),
648 agents,
649 coordination,
650 activity,
651 )
652 }
653 None => source_state.map_or_else(Vec::new, |state| {
654 vec![section_heading(
655 "work",
656 &format!("Work · {}", state.label()),
657 state.detail(),
658 )]
659 }),
660 };
661 rows = with_live_shell_rows(app, rows);
662 app.work_surface.latest_rows = rows.clone();
663 if let Some(opened) = app.work_surface.opened.as_ref()
664 && !rows.iter().any(|row| &row.id == opened)
665 && !app
666 .work_surface
667 .catalog_rows
668 .iter()
669 .any(|row| &row.id == opened)
670 {
671 app.work_surface.opened = None;
672 }
673 rows
674 }
675
676 /// The tasks view: plan-step to-dos. On strips the list is literal to-dos
677 /// only; side rails keep the rest of the graph projection (operations,
678 /// blockers, receipts) under them. Sub-agents and shells have their own
679 /// views now, so nothing here competes with a running worker for rows — the
680 /// dock opens on the agents view while one runs.
681 pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
682 let rows = project(app);
683 let todo_ids = plan_step_row_ids(app);
684 let strip = app.work_surface.effective_placement.is_strip();
685 let mut out: Vec<WorkRow> = rows
686 .into_iter()
687 .filter(|row| {
688 !row.id.0.starts_with("worker:")
689 && !row.id.0.starts_with("shell:")
690 && row.id.0 != "section:shells"
691 && row.id.0 != "section:agents"
692 && (!strip || todo_ids.contains(&row.id.0))
693 })
694 .collect();
695 // On Top the goal is already the strip title; a side column repeats it
696 // as its first row so the durable goal home survives in every placement.
697 if !strip && let Some(label) = goal_row_label(app) {
698 out.insert(0, section_heading("goal", &label, ""));
699 }
700 app.work_surface.latest_rows = out.clone();
701 out
702 }
703
704 fn goal_row_label(app: &App) -> Option<String> {
705 let (objective, paused) = crate::tui::footer_ui::active_goal_chip_state(app)?;
706 let flat = objective.trim().replace(['\n', '\r'], " ");
707 if flat.is_empty() {
708 return None;
709 }
710 Some(if paused {
711 format!("Goal (paused): {flat}")
712 } else {
713 format!("Goal: {flat}")
714 })
715 }
716
717 /// The agents view: the roster. Every worker row, live or settled, under the
718 /// `▾ Subagents N` group door, oldest-first as the runtime reports them.
719 ///
720 /// ## Order and nesting
721 ///
722 /// This list is **roster-ordered** — creation order with parked husks sunk
723 /// last (`build_agent_roster`) — not tree-ordered. The `↳ ` indent and the
724 /// `(+N)` child count that `order_agent_seeds` stamps are therefore a fact
725 /// about the *live* projection carried through this merge, never a claim that
726 /// the row directly above an indented row is its parent.
727 ///
728 /// Rows built from a retained receipt stay flat, and that is deliberate.
729 /// `AgentRosterRow` does carry `parent_run_id`, but deriving depth from it
730 /// here would put a second depth authority on one list and, because the list
731 /// is not tree-ordered, would draw exactly the dangling indent
732 /// `order_agent_seeds` refuses to draw. Nesting instead degrades to flat, and
733 /// it degrades symmetrically: once either half of a pair has outlived its
734 /// live card, the surviving half loses its `↳ ` or its `(+N)` with it,
735 /// because `order_agent_seeds` only ever sees cache seeds. A pair is nested
736 /// or it is flat; one half is never marked without the other. Pinned by
737 /// `a_retained_child_renders_flat_under_a_retained_parent` and
738 /// `a_live_child_flattens_once_its_parent_has_expired_to_a_receipt`.
739 fn agents_view_rows(app: &mut App) -> Vec<WorkRow> {
740 let rows = project(app);
741 let mut agents: Vec<WorkRow> = rows
742 .into_iter()
743 .filter(|row| row.id.0.starts_with("worker:"))
744 .collect();
745 // The compact cache expires settled cards after 45 seconds. The explicit
746 // register is session history: retain its receipt rows, preserving fresh
747 // cache/progress projections by worker ID when both sources know a worker.
748 let mut retained = Vec::new();
749 let mut seen = HashSet::new();
750 for receipt in app.current_agent_roster() {
751 let id = WorkRowId(format!("worker:{}", receipt.worker_id));
752 if !seen.insert(id.clone()) {
753 continue;
754 }
755 if let Some(index) = agents.iter().position(|row| row.id == id) {
756 retained.push(agents.remove(index));
757 continue;
758 }
759 let parked = receipt.state == crate::agent_roster::RosterState::Parked;
760 let status = if parked {
761 current_activity_status_label(AgentCurrentActivityStatus::Parked, app.ui_locale)
762 } else {
763 std::borrow::Cow::Borrowed(worker_status_label(receipt.status))
764 };
765 let activity = receipt.activity.clone().unwrap_or_default();
766 retained.push(WorkRow {
767 id,
768 mark: receipt.state.glyph(),
769 label: receipt.display_name.clone(),
770 detail: if activity.is_empty() {
771 status.to_string()
772 } else {
773 format!("{status} · {activity}")
774 },
775 tone: bucket_tone(if parked {
776 WorkBucket::Ready
777 } else {
778 worker_status_bucket(receipt.status)
779 }),
780 selectable: true,
781 primary_action: Some(SidebarRowAction::OpenAgentTranscript {
782 agent_id: receipt.worker_id.clone(),
783 }),
784 agent: Some(AgentRowFacts {
785 role_label: receipt.display_name.clone(),
786 status: status.to_string(),
787 objective: activity,
788 elapsed_secs: receipt.millis.map(|millis| millis / 1_000),
789 model: (!receipt.model.is_empty() && receipt.model != "unknown")
790 .then(|| receipt.model.clone()),
791 tokens: receipt.output_tokens,
792 todos_remaining: None,
793 // History, not news: a settled receipt has nothing running,
794 // and a parked husk asked nobody anything (#5906). Only a
795 // worker still running or still answerable holds the dock.
796 holds_dock_open: !parked && !receipt.state.is_terminal(),
797 }),
798 });
799 }
800 retained.extend(agents);
801 let agents = retained;
802 let mut out = Vec::with_capacity(agents.len() + 1);
803 if !agents.is_empty() {
804 out.push(agents_section_heading(&format!(
805 "Subagents {}",
806 agents.len()
807 )));
808 out.extend(agents);
809 }
810 app.work_surface.latest_rows = out.clone();
811 out
812 }
813
814 /// Pick the view for this frame when the user has not picked one.
815 ///
816 /// The dock is one bottom view. Cycling, a tab click, or `/workbar <view>`
817 /// makes the choice explicit and it sticks until Esc; otherwise the first of
818 /// [`RailPanel::AUTO_ORDER`] with content wins — agents while a sub-agent
819 /// runs, then the to-do list, then background work — and the persisted
820 /// `rail_panel` preference is what shows when none of them has anything.
821 pub(crate) fn resolve_view(app: &mut App) {
822 if app.work_surface.explicit_view {
823 return;
824 }
825 for panel in RailPanel::AUTO_ORDER {
826 if !view_has_work(app, panel) {
827 continue;
828 }
829 if app.work_surface.panel != panel {
830 app.work_surface.panel = panel;
831 app.work_surface.scroll_offset = 0;
832 app.work_surface.selected = None;
833 }
834 return;
835 }
836 }
837
838 /// How much work the auto-opening views hold between them: live agents,
839 /// to-dos, and running background work. The dismissed dock re-opens when
840 /// this grows, whichever view the new work lands in.
841 pub(crate) fn auto_work_rows(app: &mut App) -> usize {
842 let agents = live_agent_row_count(app);
843 let tasks = visible_rows_for(app, RailPanel::Tasks)
844 .iter()
845 .filter(|row| row.id.0.starts_with("graph:"))
846 .count();
847 let background = if background_has_live_work(app) {
848 visible_rows_for(app, RailPanel::Background).len()
849 } else {
850 0
851 };
852 agents + tasks + background
853 }
854
855 fn view_has_work(app: &mut App, panel: RailPanel) -> bool {
856 match panel {
857 RailPanel::Agents => live_agent_row_count(app) > 0,
858 // To-dos are the plan steps. A side rail also folds a summary of
859 // running work into this view; that summary is not a reason to
860 // open on TODO when the workers themselves live one tab over.
861 RailPanel::Tasks => visible_rows_for(app, panel)
862 .iter()
863 .any(|row| row.id.0.starts_with("graph:")),
864 // Scheduled automations that are not running are a fact about the
865 // account, not work in this session: they must not open the dock
866 // before the first prompt (0.9.12 defect #10). Live shells, durable
867 // tasks are work. Scheduled automation configuration belongs in /automation.
868 RailPanel::Background => background_has_live_work(app),
869 RailPanel::Files
870 | RailPanel::Notepad
871 | RailPanel::Context
872 | RailPanel::Git
873 | RailPanel::Price => false,
874 }
875 }
876
877 /// Live worker rows: the ones that make the agents view open on its own.
878 ///
879 /// Counted over [`agents_view_rows`] — the same merged projection the register
880 /// renders — not over the 45-second `subagent_cache` alone. A child that is
881 /// blocked on a person survives in the retained roster after its live card
882 /// expires, and across a session restore, where `subagent_cache` keeps only
883 /// this instance's workers; the dock has to open for it either way.
884 pub(super) fn live_agent_row_count(app: &mut App) -> usize {
885 agents_view_rows(app)
886 .iter()
887 .filter(|row| {
888 row.id.0.starts_with("worker:")
889 && row
890 .agent
891 .as_ref()
892 .is_some_and(|facts| facts.holds_dock_open)
893 })
894 .count()
895 }
896
897 /// Whether the background view holds anything actually running: a live
898 /// shell or a durable task.
899 pub(super) fn background_has_live_work(app: &mut App) -> bool {
900 !shell_work_rows(app).is_empty() || !durable_task_rows(app).is_empty()
901 }
902
903 /// The background view: live shells and durable background tasks.
904 /// The scheduled count opens the existing automations manager directly.
905 fn background_view_rows(app: &mut App) -> Vec<WorkRow> {
906 let mut out = Vec::new();
907 push_shell_group(&mut out, shell_work_rows(app));
908 out.extend(durable_task_rows(app));
909 app.work_surface.latest_rows = out.clone();
910 out
911 }
912
913 fn durable_task_rows(app: &App) -> Vec<WorkRow> {
914 app.task_panel
915 .iter()
916 .filter(|entry| !is_live_shell_entry(entry))
917 .map(|entry| {
918 let status = if entry.stale {
919 "stale"
920 } else {
921 entry.status.as_str()
922 };
923 WorkRow {
924 id: WorkRowId(format!("task:{}", entry.id)),
925 mark: agent_mark(WorkBucket::Active),
926 label: entry.prompt_summary.clone(),
927 detail: format!("{status} · {}", entry.id),
928 tone: WorkTone::Live,
929 selectable: true,
930 primary_action: Some(SidebarRowAction::Command(format!(
931 "/jobs show {}",
932 entry.id
933 ))),
934 agent: None,
935 }
936 })
937 .collect()
938 }
939
940 /// Row ids of the plan-step (to-do) nodes in the cached graph.
941 fn plan_step_row_ids(app: &App) -> HashSet<String> {
942 app.work_surface
943 .cached_graph
944 .as_ref()
945 .map(|snapshot| {
946 snapshot
947 .nodes
948 .iter()
949 .filter(|node| node.kind == NodeKind::PlanStep)
950 .map(|node| format!("graph:{}", node.id.as_str()))
951 .collect::<HashSet<_>>()
952 })
953 .unwrap_or_default()
954 }
955
956 /// Rows for the selected rail panel, routed through the same row/hitbox
957 /// machinery regardless of panel: every work row a user can see is a door
958 /// (`crates/tui/AGENTS.md`, "rows are objects"), whichever panel it appears
959 /// in.
960 ///
961 /// - `Tasks` — the full live projection ([`project_visible`]).
962 /// - `Agents` — the full sub-agent register under the `▾ Subagents N` header,
963 /// then live shells, then the durable to-do checklist: opening the register
964 /// never hides the list.
965 /// - `Pinned` — the goal, the sub-agent group, then the plan-step to-dos.
966 /// - `Context` — empty: session facts are a line list, not work rows, and
967 /// render outside the row machinery.
968 ///
969 /// **No panel choice may hide a running sub-agent.** `Pinned` used to filter
970 /// the projection down to plan steps, which meant the owner's own
971 /// `rail_panel = "pinned"` made a live worker unreachable: with no to-dos and
972 /// no goal the projection was empty, the strip collapsed to zero rows, and
973 /// the top bar was header chrome only — the 2026-08-04 "I spawned a sub agent
974 /// and the top bar showed nothing" report, pinned by
975 /// `tests/work_bar_subagents_pty.rs`. There is no header chip or phase-strip
976 /// fallback for sub-agents, so this strip is the *only* persistent surface
977 /// they have; a panel preference about which durable work to foreground is
978 /// not consent to lose the running fleet. Sub-agent rows are durable in the
979 /// same sense the panel's name means (they survive completion — see the row
980 /// lifetime rule in the module docs), so they belong here on their own terms.
981 pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
982 let panel = app.work_surface.panel;
983 visible_rows_for(app, panel)
984 }
985
986 pub(super) fn visible_rows_for(app: &mut App, panel: RailPanel) -> Vec<WorkRow> {
987 match panel {
988 RailPanel::Agents => agents_view_rows(app),
989 RailPanel::Tasks => project_visible(app),
990 RailPanel::Background => background_view_rows(app),
991 RailPanel::Files => super::views::files_rows(app),
992 RailPanel::Notepad => super::views::notepad_rows(app),
993 RailPanel::Context => super::views::context_rows(app),
994 RailPanel::Git => super::views::git_rows(app),
995 RailPanel::Price => super::views::price_rows(app),
996 }
997 }
998
999 /// Classify the current session against this process's session-instance
1000 /// boot id (#4416), mirroring the `SubAgentManager` prior-session pattern
1001 /// (#405). The probe runs once per session id. Persisted row identity is
1002 /// recorded separately at the actual session restore boundary.
1003 fn update_session_instance_scope(app: &mut App) {
1004 let Some(session_id) = app.current_session_id.clone() else {
1005 app.work_surface.session_instance = None;
1006 return;
1007 };
1008 let classified = app
1009 .work_surface
1010 .session_instance
1011 .as_ref()
1012 .is_some_and(|scope| scope.session_id == session_id);
1013 if !classified {
1014 let from_prior_instance =
1015 session_record_from_prior_instance(&app.work_surface, &session_id);
1016 app.work_surface.session_instance = Some(SessionInstanceScope {
1017 session_id,
1018 from_prior_instance,
1019 restored_nodes: HashSet::new(),
1020 });
1021 }
1022 }
1023
1024 fn session_record_from_prior_instance(surface: &WorkSurfaceState, session_id: &str) -> bool {
1025 let manager = match surface.session_owner_probe_dir.as_ref() {
1026 Some(dir) => crate::session_manager::SessionManager::new(dir.clone()),
1027 None => crate::session_manager::SessionManager::default_location(),
1028 };
1029 manager.is_ok_and(|manager| manager.session_from_prior_instance(session_id))
1030 }
1031
1032 fn graph_rows(
1033 surface: &mut WorkSurfaceState,
1034 snapshot: &WorkGraphSnapshot,
1035 source_state: Option<&WorkSourceState>,
1036 agents: Vec<RankedWorkRow>,
1037 coordination: Option<RankedWorkRow>,
1038 activity: SettledFileActivity,
1039 ) -> Vec<WorkRow> {
1040 ordered_rows(
1041 surface,
1042 Some(snapshot),
1043 source_state,
1044 agents,
1045 coordination,
1046 activity,
1047 )
1048 }
1049
1050 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1051 enum WorkBucket {
1052 Active,
1053 Attention,
1054 Ready,
1055 Recent,
1056 }
1057
1058 impl WorkBucket {
1059 /// Presentation priority: needs-input outranks running work (#4689).
1060 const fn rank(self) -> u8 {
1061 match self {
1062 Self::Attention => 0,
1063 Self::Active => 1,
1064 Self::Ready => 2,
1065 Self::Recent => 3,
1066 }
1067 }
1068
1069 const fn is_actionable(self) -> bool {
1070 !matches!(self, Self::Recent)
1071 }
1072 }
1073
1074 #[derive(Clone)]
1075 struct RankedWorkRow {
1076 bucket: WorkBucket,
1077 order: usize,
1078 is_plan_step: bool,
1079 row: WorkRow,
1080 }
1081
1082 #[derive(Default, Clone)]
1083 pub(super) struct SettledFileActivity {
1084 pub(super) summary: FileActivitySummary,
1085 pub(super) read: Vec<String>,
1086 list: Vec<String>,
1087 search: Vec<String>,
1088 pub(super) write: Vec<String>,
1089 pub(super) mutations: Vec<FileMutationReceipt>,
1090 inline_diff_mode: InlineDiffMode,
1091 }
1092
1093 impl SettledFileActivity {
1094 fn is_empty(&self) -> bool {
1095 self.summary.is_empty()
1096 }
1097 }
1098
1099 fn ordered_rows(
1100 surface: &mut WorkSurfaceState,
1101 snapshot: Option<&WorkGraphSnapshot>,
1102 source_state: Option<&WorkSourceState>,
1103 mut ranked: Vec<RankedWorkRow>,
1104 coordination: Option<RankedWorkRow>,
1105 activity: SettledFileActivity,
1106 ) -> Vec<WorkRow> {
1107 ranked.extend(coordination);
1108 if let Some(snapshot) = snapshot {
1109 ranked.extend(
1110 snapshot
1111 .nodes
1112 .iter()
1113 .filter(|node| {
1114 matches!(
1115 node.kind,
1116 NodeKind::PlanStep | NodeKind::Operation | NodeKind::Blocker
1117 )
1118 })
1119 .filter(|node| !is_settled_transient_operation(node))
1120 .filter(|node| !surface.is_prior_instance_residue(node))
1121 .enumerate()
1122 .map(|(order, node)| RankedWorkRow {
1123 bucket: node_bucket(node),
1124 order: 10_000usize.saturating_add(order),
1125 is_plan_step: node.kind == NodeKind::PlanStep,
1126 row: graph_node_row(snapshot, node),
1127 }),
1128 );
1129 }
1130
1131 // Activity is projected separately so we can apply a single aggregated
1132 // transient receipt instead of one live row per tool kind (#4690).
1133 let activity_row = aggregate_activity_row(&activity);
1134 if let Some(row) = activity_row.clone() {
1135 ranked.push(row);
1136 }
1137
1138 ranked.sort_by(|a, b| match (a.is_plan_step, b.is_plan_step) {
1139 // To-do (plan step) rows keep canonical order: a completed step must
1140 // not sink below a later pending step and lose its identity. Agent and
1141 // operation rows still sort by status bucket (#4689).
1142 (true, true) => a.order.cmp(&b.order),
1143 (true, false) => std::cmp::Ordering::Less,
1144 (false, true) => std::cmp::Ordering::Greater,
1145 (false, false) => a
1146 .bucket
1147 .rank()
1148 .cmp(&b.bucket.rank())
1149 .then_with(|| a.order.cmp(&b.order)),
1150 });
1151
1152 let active = ranked
1153 .iter()
1154 .filter(|item| item.bucket == WorkBucket::Active)
1155 .count();
1156 let attention = ranked
1157 .iter()
1158 .filter(|item| item.bucket == WorkBucket::Attention)
1159 .count();
1160 let ready = ranked
1161 .iter()
1162 .filter(|item| item.bucket == WorkBucket::Ready)
1163 .count();
1164 let recent = ranked
1165 .iter()
1166 .filter(|item| item.bucket == WorkBucket::Recent)
1167 .count();
1168 let actionable = attention + active + ready;
1169 let source = source_state
1170 .map(|state| format!(" · {}", state.label()))
1171 .unwrap_or_default();
1172 let detail = match (snapshot, source_state) {
1173 (Some(snapshot), Some(state)) => {
1174 format!("graph revision {} · {}", snapshot.revision, state.detail())
1175 }
1176 (Some(snapshot), None) => format!("graph revision {}", snapshot.revision),
1177 (None, Some(state)) => state.detail().to_string(),
1178 (None, None) => "Current session activity".to_string(),
1179 };
1180
1181 let now = surface.now_ms();
1182 let user_turn_force_hide = surface.user_turn_epoch != surface.last_handled_user_turn_epoch;
1183 if user_turn_force_hide {
1184 surface.last_handled_user_turn_epoch = surface.user_turn_epoch;
1185 if actionable == 0 {
1186 surface.recent_only_suppressed = true;
1187 }
1188 surface.activity_suppressed = true;
1189 }
1190
1191 // Recent-only lifecycle (#4688): show a brief completion, then collapse.
1192 let recent_fp = fingerprint_rows(
1193 ranked
1194 .iter()
1195 .filter(|item| item.bucket == WorkBucket::Recent)
1196 .map(|item| item.row.id.0.as_str()),
1197 );
1198 if actionable > 0 {
1199 surface.recent_only_since_ms = None;
1200 surface.recent_only_suppressed = false;
1201 surface.recent_only_fingerprint = recent_fp;
1202 } else if recent > 0 {
1203 if surface.recent_only_fingerprint != recent_fp {
1204 // A new completion after expiry may surface once.
1205 surface.recent_only_fingerprint = recent_fp;
1206 surface.recent_only_since_ms = Some(now);
1207 surface.recent_only_suppressed = false;
1208 } else if surface.recent_only_since_ms.is_none() && !surface.recent_only_suppressed {
1209 surface.recent_only_since_ms = Some(now);
1210 }
1211 if let Some(since) = surface.recent_only_since_ms
1212 && now.saturating_sub(since) >= RECENT_ONLY_TTL_MS
1213 {
1214 surface.recent_only_suppressed = true;
1215 }
1216 } else {
1217 surface.recent_only_since_ms = None;
1218 surface.recent_only_suppressed = false;
1219 surface.recent_only_fingerprint = 0;
1220 }
1221
1222 // Activity receipt lifetime (#4690): one aggregated row, 3s, no raw payloads.
1223 let activity_fp = activity_row
1224 .as_ref()
1225 .map(|row| fingerprint_rows(std::iter::once(row.row.label.as_str())))
1226 .unwrap_or(0);
1227 let show_activity = if activity_row.is_none() {
1228 surface.activity_since_ms = None;
1229 surface.activity_fingerprint = 0;
1230 surface.activity_suppressed = false;
1231 false
1232 } else {
1233 if surface.activity_fingerprint != activity_fp {
1234 surface.activity_fingerprint = activity_fp;
1235 surface.activity_since_ms = Some(now);
1236 surface.activity_suppressed = false;
1237 } else if surface.activity_since_ms.is_none() && !surface.activity_suppressed {
1238 surface.activity_since_ms = Some(now);
1239 }
1240 if let Some(since) = surface.activity_since_ms
1241 && now.saturating_sub(since) >= ACTIVITY_RECEIPT_TTL_MS
1242 {
1243 surface.activity_suppressed = true;
1244 }
1245 !surface.activity_suppressed
1246 };
1247
1248 let subject = ranked
1249 .iter()
1250 .find(|item| item.bucket.is_actionable())
1251 .map(|item| (item.bucket, sanitize_summary_title(&item.row.label)));
1252 let heading_label = match (actionable > 0, subject.as_ref()) {
1253 (true, Some((WorkBucket::Attention, title))) => {
1254 format!("Work · Needs input: {title} · {attention} blocked{source}")
1255 }
1256 (true, Some((WorkBucket::Active, title))) => {
1257 format!("Work · Running: {title} · {active} active{source}")
1258 }
1259 (true, Some((WorkBucket::Ready, title))) => {
1260 format!("Work · Ready: {title} · {ready} ready{source}")
1261 }
1262 (true, _) => format!(
1263 "Work · {active} active · {attention} needs input · {ready} ready · {recent} recent{source}"
1264 ),
1265 (false, _) => format!(
1266 "Work · {active} active · {attention} needs input · {ready} ready · {recent} recent{source}"
1267 ),
1268 };
1269
1270 // Full catalog for inspector/history even when live chrome collapses.
1271 let mut catalog = vec![section_heading("work", &heading_label, &detail)];
1272 catalog.extend(ranked.iter().map(|item| item.row.clone()));
1273 // Prior-instance terminal residue stays reachable through the explicit
1274 // catalog, labeled historical, but never as this session's live work
1275 // (#4416).
1276 if let Some(snapshot) = snapshot {
1277 catalog.extend(
1278 snapshot
1279 .nodes
1280 .iter()
1281 .filter(|node| surface.is_prior_instance_residue(node))
1282 .map(|node| {
1283 let mut row = graph_node_row(snapshot, node);
1284 row.detail = format!("prior session · {}", row.detail);
1285 row.tone = WorkTone::Muted;
1286 row
1287 }),
1288 );
1289 }
1290 surface.catalog_rows = catalog.clone();
1291
1292 // Live chrome policy (Tasks/side projections — Top uses `project_visible`):
1293 // - actionable: heading + (optional) single activity receipt
1294 // - recent-only: transient receipts collapse after the TTL / next user
1295 // turn (#4688); settled to-dos stay as durable rows. Settled sub-agents
1296 // on Top collapse into the Subagents header (see `project_visible`) and
1297 // remain reachable via the Agents panel / catalog.
1298 // - empty: no heading
1299 let is_durable =
1300 |item: &RankedWorkRow| item.is_plan_step || item.row.id.0.starts_with("worker:");
1301 let has_durable = ranked.iter().any(is_durable);
1302 if ranked.is_empty() && source_state.is_none() {
1303 return Vec::new();
1304 }
1305 if actionable == 0 && recent == 0 {
1306 // Source-only error/disconnected heading is still useful.
1307 return if source_state.is_some() {
1308 vec![section_heading("work", &heading_label, &detail)]
1309 } else {
1310 Vec::new()
1311 };
1312 }
1313 let suppress_transient_recent = actionable == 0 && surface.recent_only_suppressed;
1314 if suppress_transient_recent && !has_durable {
1315 return Vec::new();
1316 }
1317
1318 // The live heading must count the rows the live list actually shows.
1319 // Once transient recent rows are suppressed, quoting the unfiltered
1320 // `recent` total would claim receipts the reader cannot see — the
1321 // catalog heading keeps the full count because the catalog keeps the
1322 // full rows.
1323 let live_heading = if suppress_transient_recent {
1324 let live_recent = ranked
1325 .iter()
1326 .filter(|item| item.bucket == WorkBucket::Recent && is_durable(item))
1327 .count();
1328 format!(
1329 "Work · {active} active · {attention} needs input · {ready} ready · {live_recent} recent{source}"
1330 )
1331 } else {
1332 heading_label.clone()
1333 };
1334
1335 // Full ordered children remain in the projection for side rails, inspector
1336 // selection, and durable recent visibility. Live Top height is capped in
1337 // render (#4690). Recent-only *summary* lifetime is handled above (#4688).
1338 let mut live = vec![section_heading("work", &live_heading, &detail)];
1339 for item in ranked {
1340 if item.row.id.0 == "activity:aggregate" && !show_activity {
1341 continue;
1342 }
1343 if suppress_transient_recent && !is_durable(&item) {
1344 continue;
1345 }
1346 live.push(item.row);
1347 }
1348 live
1349 }
1350
1351 fn fingerprint_rows<'a>(ids: impl Iterator<Item = &'a str>) -> u64 {
1352 use std::hash::{Hash, Hasher};
1353 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1354 for id in ids {
1355 id.hash(&mut hasher);
1356 }
1357 hasher.finish()
1358 }
1359
1360 fn sanitize_summary_title(raw: &str) -> String {
1361 let single_line = raw
1362 .chars()
1363 .map(|ch| {
1364 if ch.is_control() || ch == '\n' || ch == '\r' || ch == '\t' {
1365 ' '
1366 } else {
1367 ch
1368 }
1369 })
1370 .collect::<String>();
1371 let collapsed = single_line.split_whitespace().collect::<Vec<_>>().join(" ");
1372 let trimmed = collapsed.trim();
1373 if trimmed.is_empty() {
1374 return "work item".to_string();
1375 }
1376 let mut chars = trimmed.chars();
1377 let prefix = chars.by_ref().take(72).collect::<String>();
1378 if chars.next().is_some() {
1379 format!("{prefix}…")
1380 } else {
1381 prefix
1382 }
1383 }
1384
1385 fn coordination_row(app: &App) -> Option<RankedWorkRow> {
1386 let projection = app.coordination_detail.as_ref()?;
1387 let has_context_receipt = projection.context_projections.iter().any(|receipt| {
1388 !receipt.decision_ids.is_empty()
1389 || receipt.projected_bytes > 0
1390 || receipt.deduplicated > 0
1391 || receipt.omitted > 0
1392 });
1393 let has_metrics = !projection.metrics.hottest_paths.is_empty()
1394 || projection.metrics.package_or_module_growth.is_some()
1395 || projection.metrics.route_or_cost.is_some();
1396 if projection.decisions.is_empty()
1397 && projection.write_claims.is_empty()
1398 && projection.reconciliations.is_empty()
1399 && projection.contentions.is_empty()
1400 && !has_context_receipt
1401 && !has_metrics
1402 {
1403 return None;
1404 }
1405 let attention = crate::tui::coordination_detail::needs_attention(projection);
1406 let bucket = if attention {
1407 WorkBucket::Attention
1408 } else {
1409 WorkBucket::Recent
1410 };
1411 let title = app
1412 .tr(codewhale_localization::MessageId::CoordinationWorkTitle)
1413 .into_owned();
1414 Some(RankedWorkRow {
1415 bucket,
1416 // Coordination is a session-wide receipt, before individual workers
1417 // within the same bucket but after live/attention priority sorting.
1418 order: 100,
1419 is_plan_step: false,
1420 row: WorkRow {
1421 id: WorkRowId("coordination".to_string()),
1422 mark: if attention {
1423 crate::tui::glyphs::ATTENTION
1424 } else {
1425 crate::tui::glyphs::DONE
1426 },
1427 label: title.clone(),
1428 detail: crate::tui::coordination_detail::summary(app.ui_locale, projection),
1429 tone: bucket_tone(bucket),
1430 selectable: true,
1431 primary_action: Some(SidebarRowAction::InspectWork {
1432 title,
1433 body: crate::tui::coordination_detail::format(app.ui_locale, projection),
1434 stop_action: None,
1435 }),
1436 agent: None,
1437 },
1438 })
1439 }
1440
1441 fn node_bucket(node: &WorkNode) -> WorkBucket {
1442 match node.state {
1443 NodeState::Initializing | NodeState::Active => WorkBucket::Active,
1444 NodeState::Failed if is_transient_failed_operation(node) => WorkBucket::Recent,
1445 NodeState::Waiting | NodeState::Blocked | NodeState::Stale | NodeState::Failed => {
1446 WorkBucket::Attention
1447 }
1448 NodeState::Completed if !node.acceptance.is_empty() => WorkBucket::Attention,
1449 NodeState::Ready => WorkBucket::Ready,
1450 NodeState::Completed
1451 | NodeState::Verified
1452 | NodeState::Superseded
1453 | NodeState::Cancelled => WorkBucket::Recent,
1454 }
1455 }
1456
1457 fn is_transient_failed_operation(node: &WorkNode) -> bool {
1458 node.kind == NodeKind::Operation
1459 && node
1460 .binding
1461 .as_ref()
1462 .is_some_and(|binding| !binding.durable)
1463 && node.acceptance.is_empty()
1464 && node.state == NodeState::Failed
1465 }
1466
1467 /// One worker row before display ordering: the rendered row plus the parent
1468 /// link and fleet identity the strip uses to number, order, and indent
1469 /// nested spawns (#36).
1470 struct AgentRowSeed {
1471 agent_id: String,
1472 parent_run_id: Option<String>,
1473 role: String,
1474 /// A real nickname or stable label, never the raw agent id (#36).
1475 name: Option<String>,
1476 ranked: RankedWorkRow,
1477 }
1478
1479 /// Indent marker for a nested spawn: nothing at the top level (no permanent
1480 /// chrome for the common flat fan-out), `↳` once nesting is actually
1481 /// present, with two extra spaces per additional level (#36).
1482 fn agent_nesting_indent(depth: usize) -> String {
1483 match depth {
1484 0 => String::new(),
1485 level => format!("{}↳ ", " ".repeat(level.saturating_sub(1))),
1486 }
1487 }
1488
1489 /// Compose the sub-agent identity column: nesting indent, who the agent is,
1490 /// and `(+N)` when that agent has spawned children of its own.
1491 ///
1492 /// `who` is the agent's nickname when it has one and its fleet role when it
1493 /// does not. A nickname is identity that CodeWhale actually has, so it leads;
1494 /// the role is the honest fallback. The raw agent-id hash is never a name and
1495 /// is never rendered (#36).
1496 fn agent_strip_label(indent: &str, who: &str, children: usize) -> String {
1497 if children == 0 {
1498 format!("{indent}{who}")
1499 } else {
1500 format!("{indent}{who} (+{children})")
1501 }
1502 }
1503
1504 /// Order worker rows so nested spawns sit directly under their parent, then
1505 /// stamp each label with its display depth and a sequential number. Rows
1506 /// whose parent is not visible (e.g. the parent finished and left the cache)
1507 /// stay at the top level — honest flat rendering beats a dangling indent.
1508 fn order_agent_seeds(seeds: Vec<AgentRowSeed>) -> Vec<RankedWorkRow> {
1509 let known_ids: HashSet<&str> = seeds.iter().map(|seed| seed.agent_id.as_str()).collect();
1510 let mut children: std::collections::HashMap<&str, Vec<usize>> =
1511 std::collections::HashMap::new();
1512 let mut roots = Vec::new();
1513 for (idx, seed) in seeds.iter().enumerate() {
1514 if let Some(parent) = seed.parent_run_id.as_deref()
1515 && known_ids.contains(parent)
1516 {
1517 children.entry(parent).or_default().push(idx);
1518 continue;
1519 }
1520 roots.push(idx);
1521 }
1522
1523 fn push_tree(
1524 idx: usize,
1525 depth: usize,
1526 seeds: &[AgentRowSeed],
1527 children: &std::collections::HashMap<&str, Vec<usize>>,
1528 seen: &mut HashSet<usize>,
1529 order: &mut Vec<(usize, usize)>,
1530 ) {
1531 if !seen.insert(idx) {
1532 return;
1533 }
1534 order.push((idx, depth));
1535 if let Some(child_indices) = children.get(seeds[idx].agent_id.as_str()) {
1536 for child_idx in child_indices {
1537 push_tree(*child_idx, depth + 1, seeds, children, seen, order);
1538 }
1539 }
1540 }
1541
1542 let mut order = Vec::with_capacity(seeds.len());
1543 let mut seen = HashSet::new();
1544 for idx in roots {
1545 push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
1546 }
1547 // Cycle/orphan backstop: emit anything the walk missed at the top level.
1548 for idx in 0..seeds.len() {
1549 push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
1550 }
1551
1552 // `(+N)` counts children that are actually on this surface: the same map
1553 // the tree walk used, so the badge can never promise a child the list does
1554 // not show. Snapshot it before `seeds` is consumed — `children` borrows it.
1555 let child_counts: Vec<usize> = seeds
1556 .iter()
1557 .map(|seed| {
1558 children
1559 .get(seed.agent_id.as_str())
1560 .map_or(0, |indices| indices.len())
1561 })
1562 .collect();
1563
1564 let mut slots: Vec<Option<AgentRowSeed>> = seeds.into_iter().map(Some).collect();
1565 order
1566 .into_iter()
1567 .enumerate()
1568 .map(|(position, (idx, depth))| {
1569 let seed = slots[idx].take().expect("each row emitted exactly once");
1570 let mut ranked = seed.ranked;
1571 let indent = agent_nesting_indent(depth.min(3));
1572 let role_label = agent_strip_label(&indent, &seed.role, child_counts[idx]);
1573 ranked.row.label = match seed.name.as_deref() {
1574 Some(name) => agent_strip_label(&indent, name, child_counts[idx]),
1575 None => role_label.clone(),
1576 };
1577 if let Some(facts) = ranked.row.agent.as_mut() {
1578 facts.role_label = role_label;
1579 }
1580 // `ordered_rows` re-sorts within status buckets by `order`; stamp
1581 // the tree position so a child sorts directly under its parent
1582 // whenever they share a bucket.
1583 ranked.order = position;
1584 ranked
1585 })
1586 .collect()
1587 }
1588
1589 fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
1590 let cached_ids = app
1591 .subagent_cache
1592 .iter()
1593 .filter(|agent| !agent.from_prior_session)
1594 .map(|agent| agent.agent_id.as_str())
1595 .collect::<HashSet<_>>();
1596 let mut seeds = app
1597 .subagent_cache
1598 .iter()
1599 .filter(|agent| !agent.from_prior_session)
1600 .enumerate()
1601 .map(|(order, agent)| {
1602 let meta = app.agent_progress_meta.get(&agent.agent_id);
1603 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
1604 let status = current_activity
1605 .map(|activity| current_activity_status_label(activity.status, app.ui_locale))
1606 .or_else(|| {
1607 agent
1608 .worker_status
1609 .map(|status| std::borrow::Cow::Borrowed(worker_status_label(status)))
1610 })
1611 .unwrap_or_else(|| {
1612 std::borrow::Cow::Borrowed(subagent_status_label(&agent.status))
1613 });
1614 let bucket = current_activity
1615 .map(|activity| current_activity_status_bucket(activity.status))
1616 .or_else(|| agent.worker_status.map(worker_status_bucket))
1617 .unwrap_or_else(|| subagent_status_bucket(&agent.status));
1618 // Read failure from the same source the bucket came from, so the
1619 // tone can never disagree with the row it is painting.
1620 let failed = current_activity.map_or_else(
1621 || {
1622 agent.worker_status.map_or_else(
1623 || matches!(agent.status, SubAgentStatus::Failed(_)),
1624 |status| matches!(status, AgentWorkerStatus::Failed),
1625 )
1626 },
1627 |activity| matches!(activity.status, AgentCurrentActivityStatus::Failed),
1628 );
1629 let resolved_profile = agent
1630 .child_route
1631 .as_ref()
1632 .and_then(|route| route.resolved_profile_id.as_deref())
1633 .map(str::trim)
1634 .filter(|profile| !profile.is_empty());
1635 let role = resolved_profile
1636 .or(agent
1637 .assignment
1638 .role
1639 .as_deref()
1640 .filter(|role| !role.trim().is_empty()))
1641 .unwrap_or_else(|| agent.agent_type.as_str())
1642 .to_string();
1643 // The dispatch name leads (#5287); a nickname or stable label
1644 // names the agents dispatched without one. Never the bare agent
1645 // id (#36) — absent rather than fabricated, so the identity
1646 // column falls back to the role.
1647 let name = crate::tui::sidebar::dispatched_agent_name(agent)
1648 .map(str::to_string)
1649 .or_else(|| resolved_profile.map(str::to_string))
1650 .or_else(|| {
1651 agent
1652 .nickname
1653 .clone()
1654 .filter(|name| !name.trim().is_empty() && name != &agent.agent_id)
1655 })
1656 .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned());
1657 let terminal = agent_is_terminal(agent, meta);
1658 let objective = summarize_assignment(&agent.assignment.objective);
1659 let mut facts = vec![status.to_string(), objective.clone()];
1660 // Quiet completion (#36): a finished agent keeps its one-line
1661 // status and objective; in-flight metadata (current tool, step
1662 // counters, file tallies) is working state, not a receipt, and
1663 // must not linger as a spawn-metadata dump after the run ends.
1664 if !terminal {
1665 if let Some(detail) =
1666 current_activity.and_then(|activity| activity.detail.as_deref())
1667 {
1668 facts.push(detail.to_string());
1669 }
1670 if let Some(tool) =
1671 current_activity.and_then(|activity| activity.current_tool.as_deref())
1672 {
1673 facts.push(format!("using {tool}"));
1674 }
1675 if let Some(step) = current_activity.and_then(|activity| activity.step) {
1676 facts.push(format!("step {step}"));
1677 }
1678 if let Some(files) = meta
1679 .map(|meta| meta.files_touched)
1680 .filter(|count| *count > 0)
1681 {
1682 facts.push(format!("{files} files changed"));
1683 }
1684 }
1685 AgentRowSeed {
1686 agent_id: agent.agent_id.clone(),
1687 parent_run_id: agent.parent_run_id.clone(),
1688 role,
1689 name,
1690 ranked: RankedWorkRow {
1691 bucket,
1692 order,
1693 is_plan_step: false,
1694 row: WorkRow {
1695 id: WorkRowId(format!("worker:{}", agent.agent_id)),
1696 mark: agent_mark(bucket),
1697 // Stamped by `order_agent_seeds` once the display
1698 // depth (and therefore the indent) is known.
1699 label: String::new(),
1700 detail: facts.join(" · "),
1701 tone: agent_tone(bucket, failed),
1702 selectable: true,
1703 // One agent, one destination (v0.9.7): activation
1704 // opens the agent's transcript directly; Agent
1705 // Details is the secondary action from there.
1706 primary_action: Some(SidebarRowAction::OpenAgentTranscript {
1707 agent_id: agent.agent_id.clone(),
1708 }),
1709 agent: Some(AgentRowFacts {
1710 // Stamped by `order_agent_seeds`, which is where
1711 // the indent and child count become known.
1712 role_label: String::new(),
1713 status: status.to_string(),
1714 objective,
1715 elapsed_secs: Some(agent_elapsed_ms(app, agent) / 1_000),
1716 model: meta.and_then(|meta| meta.resolved_model.clone()),
1717 tokens: meta.and_then(|meta| meta.received_tokens),
1718 todos_remaining: meta.and_then(|meta| meta.todos_remaining),
1719 holds_dock_open: bucket.is_actionable(),
1720 }),
1721 },
1722 },
1723 }
1724 })
1725 .collect::<Vec<_>>();
1726
1727 let mut progress_only = app
1728 .agent_progress
1729 .iter()
1730 .filter(|(id, _)| !cached_ids.contains(id.as_str()))
1731 .collect::<Vec<_>>();
1732 progress_only.sort_by_key(|(id, _)| (*id).clone());
1733 seeds.extend(
1734 progress_only
1735 .into_iter()
1736 .enumerate()
1737 .map(|(order, (id, _progress))| {
1738 let meta = app.agent_progress_meta.get(id);
1739 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
1740 let status = current_activity
1741 .map(|activity| current_activity_status_label(activity.status, app.ui_locale))
1742 .unwrap_or(std::borrow::Cow::Borrowed("running"));
1743 let bucket = current_activity
1744 .map(|activity| current_activity_status_bucket(activity.status))
1745 .unwrap_or(WorkBucket::Active);
1746 let failed = current_activity
1747 .is_some_and(|a| matches!(a.status, AgentCurrentActivityStatus::Failed));
1748 let name = app.agent_label_map.get(id).cloned();
1749 let mut facts = vec![status.to_string()];
1750 if let Some(detail) =
1751 current_activity.and_then(|activity| activity.detail.as_deref())
1752 {
1753 facts.push(detail.to_string());
1754 }
1755 if let Some(tool) =
1756 current_activity.and_then(|activity| activity.current_tool.as_deref())
1757 {
1758 facts.push(format!("using {tool}"));
1759 }
1760 if let Some(step) = current_activity.and_then(|activity| activity.step) {
1761 facts.push(format!("step {step}"));
1762 }
1763 if let Some(files) = meta
1764 .map(|meta| meta.files_touched)
1765 .filter(|count| *count > 0)
1766 {
1767 facts.push(format!("{files} files changed"));
1768 }
1769 AgentRowSeed {
1770 agent_id: id.clone(),
1771 parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()),
1772 // Role is unknown until the manager snapshot arrives;
1773 // "agent" is the honest fallback, not a fabrication.
1774 role: "agent".to_string(),
1775 name,
1776 ranked: RankedWorkRow {
1777 bucket,
1778 order: 5_000usize.saturating_add(order),
1779 is_plan_step: false,
1780 row: WorkRow {
1781 id: WorkRowId(format!("worker:{id}")),
1782 mark: agent_mark(bucket),
1783 label: String::new(),
1784 detail: facts.join(" · "),
1785 tone: agent_tone(bucket, failed),
1786 selectable: true,
1787 // Same destination as the cached-seed rows above.
1788 primary_action: Some(SidebarRowAction::OpenAgentTranscript {
1789 agent_id: id.clone(),
1790 }),
1791 agent: Some(AgentRowFacts {
1792 role_label: String::new(),
1793 status: status.to_string(),
1794 // No manager snapshot yet, so there is no
1795 // assignment to quote: the live activity line
1796 // is the honest answer to "what is it doing".
1797 // The status word itself is the status
1798 // column's job, so it is not repeated here.
1799 objective: facts[1..].join(" · "),
1800 // Neither a duration nor a usage envelope has
1801 // been seen for this id. Both render as
1802 // nothing rather than as `0s` / `0 tokens`.
1803 elapsed_secs: None,
1804 model: meta.and_then(|meta| meta.resolved_model.clone()),
1805 tokens: meta.and_then(|meta| meta.received_tokens),
1806 todos_remaining: meta.and_then(|meta| meta.todos_remaining),
1807 holds_dock_open: bucket.is_actionable(),
1808 }),
1809 },
1810 },
1811 }
1812 }),
1813 );
1814 order_agent_seeds(seeds)
1815 }
1816
1817 fn summarize_assignment(value: &str) -> String {
1818 // Flatten newlines the way the goal-title path does: a multi-line
1819 // objective must not break the one-line work-bar row (2026-08-04 review).
1820 let summary = crate::tui::history::summarize_tool_output(value);
1821 if summary.contains(['\n', '\r']) {
1822 summary.replace(['\n', '\r'], " ")
1823 } else {
1824 summary
1825 }
1826 }
1827
1828 /// Has this agent stopped working? Typed live activity wins over the worker
1829 /// status, which in turn wins over the coarse manager status — the same
1830 /// precedence the row's status label and bucket already use.
1831 fn agent_is_terminal(agent: &SubAgentResult, meta: Option<&AgentProgressMeta>) -> bool {
1832 meta.and_then(|meta| meta.current_activity.as_ref())
1833 .map(|activity| {
1834 matches!(
1835 activity.status,
1836 AgentCurrentActivityStatus::Done
1837 | AgentCurrentActivityStatus::Canceled
1838 | AgentCurrentActivityStatus::Failed
1839 | AgentCurrentActivityStatus::Interrupted
1840 )
1841 })
1842 .or_else(|| {
1843 agent.worker_status.map(|worker_status| {
1844 matches!(
1845 worker_status,
1846 AgentWorkerStatus::Completed
1847 | AgentWorkerStatus::Cancelled
1848 | AgentWorkerStatus::Failed
1849 | AgentWorkerStatus::Interrupted
1850 )
1851 })
1852 })
1853 .unwrap_or(matches!(
1854 agent.status,
1855 SubAgentStatus::Completed
1856 | SubAgentStatus::Cancelled
1857 | SubAgentStatus::Failed(_)
1858 | SubAgentStatus::Interrupted(_)
1859 | SubAgentStatus::BudgetExhausted
1860 ))
1861 }
1862
1863 /// Latch each finished agent's elapsed time the first frame it is observed
1864 /// terminal, and forget agents that have left the cache.
1865 ///
1866 /// The manager recomputes `SubAgentResult::duration_ms` as
1867 /// `started_at.elapsed()` on every snapshot, so a completed agent's duration
1868 /// keeps growing for as long as it stays listed. Without this pass a finished
1869 /// row would tick forever, which is exactly the thing a receipt must not do.
1870 fn freeze_terminal_agent_elapsed(app: &mut App) {
1871 let live: HashSet<&str> = app
1872 .subagent_cache
1873 .iter()
1874 .map(|agent| agent.agent_id.as_str())
1875 .collect();
1876 app.work_surface
1877 .frozen_agent_elapsed_ms
1878 .retain(|id, _| live.contains(id.as_str()));
1879
1880 for agent in &app.subagent_cache {
1881 if !agent_is_terminal(agent, app.agent_progress_meta.get(&agent.agent_id)) {
1882 continue;
1883 }
1884 app.work_surface
1885 .frozen_agent_elapsed_ms
1886 .entry(agent.agent_id.clone())
1887 .or_insert(agent.duration_ms);
1888 }
1889 }
1890
1891 /// Frozen elapsed for a finished agent, live elapsed for a running one.
1892 fn agent_elapsed_ms(app: &App, agent: &crate::tools::subagent::SubAgentResult) -> u64 {
1893 // Live ticking (4b): derive from start timestamp at render when running,
1894 // otherwise use frozen snapshot. The redraw already happens; stale cached
1895 // duration is the bug.
1896 if matches!(
1897 agent.status,
1898 crate::tools::subagent::SubAgentStatus::Running
1899 ) && let Some(started_at) = agent.started_at
1900 {
1901 return u64::try_from(started_at.elapsed().as_millis()).unwrap_or(agent.duration_ms);
1902 }
1903 app.work_surface
1904 .frozen_agent_elapsed_ms
1905 .get(&agent.agent_id)
1906 .copied()
1907 .unwrap_or(agent.duration_ms)
1908 }
1909
1910 fn current_activity_status_bucket(status: AgentCurrentActivityStatus) -> WorkBucket {
1911 match status {
1912 AgentCurrentActivityStatus::Waiting
1913 | AgentCurrentActivityStatus::Interrupted
1914 | AgentCurrentActivityStatus::Failed => WorkBucket::Attention,
1915 // Not Attention (#5906): a parked husk asked nobody anything, so it
1916 // must not sort above live work nor be counted in the `needs input`
1917 // chip. Ready ranks below Attention and Active and stays actionable,
1918 // which is what it is — resumable, or dismissable.
1919 AgentCurrentActivityStatus::Parked | AgentCurrentActivityStatus::Queued => {
1920 WorkBucket::Ready
1921 }
1922 AgentCurrentActivityStatus::Done | AgentCurrentActivityStatus::Canceled => {
1923 WorkBucket::Recent
1924 }
1925 AgentCurrentActivityStatus::Starting
1926 | AgentCurrentActivityStatus::Running
1927 | AgentCurrentActivityStatus::ModelWait
1928 | AgentCurrentActivityStatus::RunningTool => WorkBucket::Active,
1929 }
1930 }
1931
1932 fn current_activity_status_label(
1933 status: AgentCurrentActivityStatus,
1934 locale: codewhale_localization::Locale,
1935 ) -> std::borrow::Cow<'static, str> {
1936 // `parked` is the one word here that has to be translated: it is new
1937 // vocabulary a reader has never seen on this row, and the whole point is
1938 // that it does not read as "waiting for input" (#5906).
1939 if status == AgentCurrentActivityStatus::Parked {
1940 return codewhale_localization::tr(
1941 locale,
1942 codewhale_localization::MessageId::AgentStatusParked,
1943 );
1944 }
1945 std::borrow::Cow::Borrowed(match status {
1946 AgentCurrentActivityStatus::Queued => "queued",
1947 AgentCurrentActivityStatus::Starting => "starting",
1948 AgentCurrentActivityStatus::Running => "running",
1949 AgentCurrentActivityStatus::ModelWait => "waiting for model",
1950 AgentCurrentActivityStatus::RunningTool => "running tool",
1951 AgentCurrentActivityStatus::Waiting => "waiting for input",
1952 AgentCurrentActivityStatus::Done => "completed",
1953 AgentCurrentActivityStatus::Failed => "failed",
1954 AgentCurrentActivityStatus::Canceled => "cancelled",
1955 AgentCurrentActivityStatus::Interrupted => "interrupted",
1956 AgentCurrentActivityStatus::Parked => unreachable!("handled above"),
1957 })
1958 }
1959
1960 fn worker_status_bucket(status: AgentWorkerStatus) -> WorkBucket {
1961 match status {
1962 AgentWorkerStatus::WaitingForUser
1963 | AgentWorkerStatus::Interrupted
1964 | AgentWorkerStatus::Failed => WorkBucket::Attention,
1965 AgentWorkerStatus::Queued => WorkBucket::Ready,
1966 AgentWorkerStatus::Completed | AgentWorkerStatus::Cancelled => WorkBucket::Recent,
1967 AgentWorkerStatus::Starting
1968 | AgentWorkerStatus::Running
1969 | AgentWorkerStatus::ModelWait
1970 | AgentWorkerStatus::RunningTool => WorkBucket::Active,
1971 }
1972 }
1973
1974 fn worker_status_label(status: AgentWorkerStatus) -> &'static str {
1975 match status {
1976 AgentWorkerStatus::Queued => "queued",
1977 AgentWorkerStatus::Starting => "starting",
1978 AgentWorkerStatus::Running => "running",
1979 AgentWorkerStatus::WaitingForUser => "waiting for input",
1980 AgentWorkerStatus::ModelWait => "waiting for model",
1981 AgentWorkerStatus::RunningTool => "running tool",
1982 AgentWorkerStatus::Completed => "completed",
1983 AgentWorkerStatus::Failed => "failed",
1984 AgentWorkerStatus::Cancelled => "cancelled",
1985 AgentWorkerStatus::Interrupted => "interrupted",
1986 }
1987 }
1988
1989 fn subagent_status_bucket(status: &SubAgentStatus) -> WorkBucket {
1990 match status {
1991 SubAgentStatus::Running => WorkBucket::Active,
1992 SubAgentStatus::Interrupted(_)
1993 | SubAgentStatus::Failed(_)
1994 | SubAgentStatus::BudgetExhausted => WorkBucket::Attention,
1995 SubAgentStatus::Completed | SubAgentStatus::Cancelled => WorkBucket::Recent,
1996 }
1997 }
1998
1999 fn subagent_status_label(status: &SubAgentStatus) -> &'static str {
2000 match status {
2001 SubAgentStatus::Running => "running",
2002 SubAgentStatus::Completed => "completed",
2003 SubAgentStatus::Interrupted(_) => "interrupted",
2004 SubAgentStatus::Failed(_) => "failed",
2005 SubAgentStatus::Cancelled => "cancelled",
2006 SubAgentStatus::BudgetExhausted => "budget exhausted",
2007 }
2008 }
2009
2010 /// `WorkBucket::Attention` deliberately groups a wait with a failure so they
2011 /// sort together — both need you. Tone must not follow it that far: only an
2012 /// actual failure spends Failure red.
2013 const fn agent_tone(bucket: WorkBucket, failed: bool) -> WorkTone {
2014 match bucket {
2015 WorkBucket::Attention if failed => WorkTone::Failure,
2016 other => bucket_tone(other),
2017 }
2018 }
2019
2020 const fn bucket_tone(bucket: WorkBucket) -> WorkTone {
2021 match bucket {
2022 WorkBucket::Active => WorkTone::Live,
2023 WorkBucket::Attention => WorkTone::Attention,
2024 WorkBucket::Ready => WorkTone::Muted,
2025 WorkBucket::Recent => WorkTone::Success,
2026 }
2027 }
2028
2029 const fn agent_mark(bucket: WorkBucket) -> &'static str {
2030 match bucket {
2031 WorkBucket::Active => crate::tui::glyphs::SELECTION,
2032 WorkBucket::Attention => crate::tui::glyphs::ATTENTION,
2033 WorkBucket::Ready => crate::tui::glyphs::READY,
2034 WorkBucket::Recent => crate::tui::glyphs::DONE,
2035 }
2036 }
2037
2038 pub(super) fn settled_file_activity(app: &App) -> SettledFileActivity {
2039 let mut activity = SettledFileActivity {
2040 inline_diff_mode: app.inline_diff_mode,
2041 ..SettledFileActivity::default()
2042 };
2043 let mut seen = HashSet::new();
2044 for index in 0..app.virtual_cell_count() {
2045 let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index(index) else {
2046 continue;
2047 };
2048 if !cell.is_success() {
2049 continue;
2050 }
2051 let Some(detail) = app.tool_detail_record_for_cell(index) else {
2052 continue;
2053 };
2054 let activity_tool_name = canonical_action_alias(&detail.tool_name, &detail.input);
2055 let kind = if matches!(cell, ToolCell::PatchSummary(_)) {
2056 Some(FileActivityKind::Write)
2057 } else {
2058 FileActivitySummary::from_tool_name(activity_tool_name)
2059 };
2060 let Some(kind) = kind else {
2061 continue;
2062 };
2063 if !seen.insert(detail.tool_id.as_str()) {
2064 continue;
2065 }
2066 activity.summary.record(kind);
2067 if kind == FileActivityKind::Write
2068 && let ToolCell::PatchSummary(mutation) = cell
2069 && let Some(receipt) = mutation.receipt.as_ref()
2070 {
2071 let additional_files =
2072 u32::try_from(receipt.files.len().saturating_sub(1)).unwrap_or(u32::MAX);
2073 activity.summary.files_written = activity
2074 .summary
2075 .files_written
2076 .saturating_add(additional_files);
2077 activity.mutations.push(receipt.clone());
2078 }
2079 let target = activity_target(&app.workspace, activity_tool_name, &detail.input, kind);
2080 let details = match kind {
2081 FileActivityKind::Read => &mut activity.read,
2082 FileActivityKind::List => &mut activity.list,
2083 FileActivityKind::Search => &mut activity.search,
2084 FileActivityKind::Write => &mut activity.write,
2085 };
2086 if let Some(target) = target
2087 && details.len() < 12
2088 && !details.contains(&target)
2089 {
2090 details.push(target);
2091 }
2092 }
2093 activity
2094 }
2095
2096 fn aggregate_activity_row(activity: &SettledFileActivity) -> Option<RankedWorkRow> {
2097 if activity.is_empty() {
2098 return None;
2099 }
2100 let summaries = activity.summary.compact_display();
2101 if summaries.is_empty() {
2102 return None;
2103 }
2104 // Single aggregated live receipt; never inline raw patterns/commands (#4690).
2105 let label = if summaries.len() == 1 {
2106 summaries[0].clone()
2107 } else {
2108 summaries.join(" · ")
2109 };
2110 let mutation_detail = activity.mutations.last().map(|receipt| {
2111 if activity.inline_diff_mode == InlineDiffMode::Off {
2112 receipt.outcome_label()
2113 } else {
2114 receipt.semantic_summary()
2115 }
2116 });
2117 let mutation_body = settled_mutation_body(&activity.mutations, activity.inline_diff_mode);
2118 let mut body_parts = Vec::new();
2119 if !mutation_body.is_empty() {
2120 body_parts.push(mutation_body);
2121 }
2122 for (kind, details) in [
2123 ("Read", &activity.read),
2124 ("List", &activity.list),
2125 ("Search", &activity.search),
2126 ("Write", &activity.write),
2127 ] {
2128 if details.is_empty() {
2129 continue;
2130 }
2131 body_parts.push(format!("{kind}:\n{}", details.join("\n")));
2132 }
2133 if body_parts.is_empty() {
2134 body_parts.push("No safe target detail retained".to_string());
2135 }
2136 let detail = mutation_detail
2137 .or_else(|| {
2138 activity
2139 .write
2140 .first()
2141 .cloned()
2142 .or_else(|| activity.read.first().cloned())
2143 .or_else(|| activity.search.first().map(|_| "patterns".to_string()))
2144 .or_else(|| activity.list.first().cloned())
2145 })
2146 .unwrap_or_else(|| "settled".to_string());
2147 let detail = sanitize_summary_title(&detail);
2148 Some(RankedWorkRow {
2149 bucket: WorkBucket::Recent,
2150 order: 20_000,
2151 is_plan_step: false,
2152 row: WorkRow {
2153 id: WorkRowId("activity:aggregate".to_string()),
2154 mark: crate::tui::glyphs::DONE,
2155 label,
2156 detail,
2157 tone: WorkTone::Success,
2158 selectable: true,
2159 primary_action: Some(SidebarRowAction::InspectWork {
2160 title: "Work · file activity".to_string(),
2161 body: body_parts.join("\n\n"),
2162 stop_action: None,
2163 }),
2164 agent: None,
2165 },
2166 })
2167 }
2168
2169 #[cfg(test)]
2170 fn activity_rows(activity: SettledFileActivity) -> Vec<RankedWorkRow> {
2171 aggregate_activity_row(&activity).into_iter().collect()
2172 }
2173
2174 fn settled_mutation_body(receipts: &[FileMutationReceipt], mode: InlineDiffMode) -> String {
2175 let Some(receipt) = receipts.last() else {
2176 return String::new();
2177 };
2178 let details = crate::tui::key_shortcuts::tool_details_shortcut_action_hint(
2179 "exact change evidence on the matching File receipt",
2180 );
2181 let hint = format!("Select the matching File receipt; {details}.");
2182 match mode {
2183 InlineDiffMode::Off => format!("{}\n\n{hint}", receipt.outcome_label()),
2184 InlineDiffMode::Summary => format!("{}\n\n{hint}", receipt.semantic_summary()),
2185 InlineDiffMode::Full => {
2186 let diff = receipt
2187 .display_diff
2188 .lines()
2189 .take(40)
2190 .collect::<Vec<_>>()
2191 .join("\n");
2192 if diff.trim().is_empty() {
2193 format!("{}\n\n{hint}", receipt.semantic_summary())
2194 } else {
2195 format!("{}\n\n{diff}\n\n{hint}", receipt.semantic_summary())
2196 }
2197 }
2198 }
2199 }
2200
2201 fn activity_target(
2202 workspace: &Path,
2203 tool_name: &str,
2204 input: &serde_json::Value,
2205 kind: FileActivityKind,
2206 ) -> Option<String> {
2207 if tool_name == "apply_patch"
2208 && let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input)
2209 {
2210 let targets = preflight
2211 .touched_files
2212 .iter()
2213 .filter_map(|path| privacy_safe_path(workspace, path))
2214 .take(4)
2215 .collect::<Vec<_>>();
2216 if !targets.is_empty() {
2217 return Some(targets.join(", "));
2218 }
2219 }
2220 let keys: &[&str] = match kind {
2221 FileActivityKind::Search => &["pattern", "query", "path"],
2222 _ => &["path", "file_path"],
2223 };
2224 keys.iter().find_map(|key| {
2225 let value = input.get(*key)?.as_str()?.trim();
2226 if value.is_empty() {
2227 return None;
2228 }
2229 if kind == FileActivityKind::Search && *key != "path" {
2230 return Some(safe_pattern(value));
2231 }
2232 privacy_safe_path(workspace, value)
2233 })
2234 }
2235
2236 fn privacy_safe_path(workspace: &Path, raw: &str) -> Option<String> {
2237 let path = Path::new(raw);
2238 let normalized_raw = raw.replace('\\', "/");
2239 let normalized_workspace = workspace.to_string_lossy().replace('\\', "/");
2240 let relative = if path.is_absolute() || normalized_raw.starts_with('/') {
2241 let workspace_prefix = normalized_workspace.trim_end_matches('/');
2242 if normalized_raw == workspace_prefix {
2243 ""
2244 } else {
2245 normalized_raw.strip_prefix(&format!("{workspace_prefix}/"))?
2246 }
2247 } else {
2248 normalized_raw.as_str()
2249 };
2250 let relative = Path::new(relative);
2251 if relative.components().any(|component| {
2252 matches!(
2253 component,
2254 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2255 )
2256 }) {
2257 return None;
2258 }
2259 let display = relative.to_string_lossy().replace('\\', "/");
2260 (!display.is_empty()).then_some(display)
2261 }
2262
2263 fn safe_pattern(raw: &str) -> String {
2264 let single_line = raw.replace(['\n', '\r', '\t'], " ");
2265 let mut chars = single_line.chars();
2266 let prefix = chars.by_ref().take(80).collect::<String>();
2267 if chars.next().is_some() {
2268 format!("{prefix}…")
2269 } else {
2270 prefix
2271 }
2272 }
2273
2274 fn is_settled_transient_operation(node: &WorkNode) -> bool {
2275 node.kind == NodeKind::Operation
2276 && node
2277 .binding
2278 .as_ref()
2279 .is_some_and(|binding| !binding.durable)
2280 && match node.state {
2281 NodeState::Completed => node.acceptance.is_empty(),
2282 NodeState::Verified | NodeState::Superseded | NodeState::Cancelled => true,
2283 _ => false,
2284 }
2285 }
2286
2287 fn section_heading(id: &str, label: &str, detail: &str) -> WorkRow {
2288 WorkRow {
2289 id: WorkRowId(format!("section:{id}")),
2290 mark: "▾",
2291 label: label.to_string(),
2292 detail: detail.to_string(),
2293 tone: WorkTone::Heading,
2294 selectable: false,
2295 primary_action: None,
2296 agent: None,
2297 }
2298 }
2299
2300 /// The sub-agent heading is a real group door: selecting it reveals the full
2301 /// Agents panel, including settled workers whose exact transcripts remain
2302 /// available after the compact strip archives them.
2303 fn agents_section_heading(label: &str) -> WorkRow {
2304 WorkRow {
2305 id: WorkRowId("section:agents".to_string()),
2306 mark: "▾",
2307 label: label.to_string(),
2308 detail: "Open the full subagent register".to_string(),
2309 tone: WorkTone::Heading,
2310 selectable: true,
2311 primary_action: Some(SidebarRowAction::ShowSubagentsPanel),
2312 agent: None,
2313 }
2314 }
2315
2316 fn with_live_shell_rows(app: &App, mut rows: Vec<WorkRow>) -> Vec<WorkRow> {
2317 rows.retain(|row| !row.id.0.starts_with("shell:") && row.id.0 != "section:shells");
2318 let shells = shell_work_rows(app);
2319 if shells.is_empty() {
2320 return rows;
2321 }
2322 let mut out = Vec::with_capacity(rows.len() + shells.len() + 1);
2323 push_shell_group(&mut out, shells);
2324 out.extend(rows);
2325 out
2326 }
2327
2328 fn push_shell_group(out: &mut Vec<WorkRow>, shells: Vec<WorkRow>) {
2329 if shells.is_empty() {
2330 return;
2331 }
2332 out.push(shells_section_heading(&shells));
2333 out.extend(shells);
2334 }
2335
2336 fn shell_work_rows(app: &App) -> Vec<WorkRow> {
2337 app.task_panel
2338 .iter()
2339 .filter(|entry| is_live_shell_entry(entry))
2340 .map(|entry| {
2341 let command = entry
2342 .prompt_summary
2343 .strip_prefix("shell: ")
2344 .unwrap_or(entry.prompt_summary.as_str())
2345 .trim();
2346 let status = if entry.stale {
2347 "stale"
2348 } else {
2349 entry.status.as_str()
2350 };
2351 let elapsed_secs = entry.duration_ms.map(|ms| ms / 1_000);
2352 let objective = if command.is_empty() {
2353 entry.id.clone()
2354 } else {
2355 command.to_string()
2356 };
2357 WorkRow {
2358 id: WorkRowId(format!("shell:{}", entry.id)),
2359 mark: agent_mark(WorkBucket::Active),
2360 label: entry.id.clone(),
2361 detail: format!("{status} · {}", entry.id),
2362 tone: WorkTone::Live,
2363 selectable: true,
2364 primary_action: Some(SidebarRowAction::InspectWork {
2365 title: format!("Shell {}", entry.id),
2366 body: shell_inspector_body(app, entry, command, status),
2367 stop_action: Some(Box::new(SidebarRowAction::Command(format!(
2368 "/jobs cancel {}",
2369 entry.id
2370 )))),
2371 }),
2372 agent: Some(AgentRowFacts {
2373 role_label: "shell".to_string(),
2374 status: status.to_string(),
2375 objective,
2376 elapsed_secs,
2377 model: None,
2378 tokens: None,
2379 todos_remaining: None,
2380 // Only live shells reach this list; the agents counter
2381 // never sees a `shell:` row either way.
2382 holds_dock_open: true,
2383 }),
2384 }
2385 })
2386 .collect()
2387 }
2388
2389 fn shells_section_heading(shells: &[WorkRow]) -> WorkRow {
2390 let ids: Vec<&str> = shells
2391 .iter()
2392 .filter_map(|row| row.id.0.strip_prefix("shell:"))
2393 .collect();
2394 let listing = if ids.is_empty() {
2395 String::new()
2396 } else {
2397 format!(
2398 "{}\n\n",
2399 ids.iter()
2400 .map(|id| format!("- {id}"))
2401 .collect::<Vec<_>>()
2402 .join("\n")
2403 )
2404 };
2405 WorkRow {
2406 id: WorkRowId("section:shells".to_string()),
2407 mark: "▾",
2408 label: format!("Shells {}", shells.len()),
2409 detail: "Watch live output · cancel by the id on the row".to_string(),
2410 tone: WorkTone::Heading,
2411 selectable: true,
2412 primary_action: Some(SidebarRowAction::InspectWork {
2413 title: format!("Shells {}", shells.len()),
2414 body: format!(
2415 "Running shells ({})\n\n{listing}Open a row to watch output. Cancel with /jobs cancel <id> or /jobs cancel all.",
2416 shells.len()
2417 ),
2418 stop_action: Some(Box::new(SidebarRowAction::Command(
2419 "/jobs cancel all".to_string(),
2420 ))),
2421 }),
2422 agent: None,
2423 }
2424 }
2425
2426 fn shell_inspector_body(app: &App, entry: &TaskPanelEntry, command: &str, status: &str) -> String {
2427 let mut body = format!(
2428 "Job: {}\nStatus: {status}\nCommand: {command}\n\nCancel: /jobs cancel {}\n",
2429 entry.id, entry.id
2430 );
2431 let session = app.current_session_id.as_deref().unwrap_or("");
2432 if let Some(manager) = app.runtime_services.shell_manager.as_ref()
2433 && let Ok(mut manager) = manager.try_lock()
2434 && let Ok(detail) = manager.inspect_job_for_session(session, &entry.id)
2435 {
2436 if !detail.stdout.is_empty() {
2437 body.push_str("\nSTDOUT:\n");
2438 body.push_str(&detail.stdout);
2439 }
2440 if !detail.stderr.is_empty() {
2441 body.push_str("\nSTDERR:\n");
2442 body.push_str(&detail.stderr);
2443 }
2444 if detail.stdout.is_empty() && detail.stderr.is_empty() {
2445 body.push_str("\n(no output yet — reopen this row to watch the shell)");
2446 }
2447 } else {
2448 body.push_str("\nOpen this row to watch live output.");
2449 }
2450 body
2451 }
2452
2453 fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
2454 let (mark, tone) = match node.state {
2455 NodeState::Ready => (crate::tui::glyphs::READY, WorkTone::Muted),
2456 NodeState::Initializing => (crate::tui::glyphs::SELECTION, WorkTone::Live),
2457 NodeState::Active => (crate::tui::glyphs::SELECTION, WorkTone::Live),
2458 NodeState::Waiting => (crate::tui::glyphs::ATTENTION, WorkTone::Attention),
2459 NodeState::Blocked => (
2460 status_mark(StatusKind::Attention).glyph,
2461 WorkTone::Attention,
2462 ),
2463 NodeState::Completed if node.acceptance.is_empty() => {
2464 (status_mark(StatusKind::Done).glyph, WorkTone::Success)
2465 }
2466 NodeState::Completed => (
2467 status_mark(StatusKind::Attention).glyph,
2468 WorkTone::Attention,
2469 ),
2470 NodeState::Verified => (status_mark(StatusKind::Done).glyph, WorkTone::Success),
2471 NodeState::Stale => ("?", WorkTone::Attention),
2472 NodeState::Superseded | NodeState::Cancelled => ("−", WorkTone::Muted),
2473 NodeState::Failed => (crate::tui::glyphs::FAILED, WorkTone::Failure),
2474 };
2475 let state = state_label(node);
2476 let kind = kind_label(node.kind);
2477 // A to-do row always carries its status word in the detail column, using
2478 // the same vocabulary as `/task digest` (pending / in progress /
2479 // completed / cancelled). Only the redundant `· plan step` KIND suffix is
2480 // dropped — the strip's checkbox marks already say the row is a plan
2481 // step, but they do not say its state in words, and dropping the state
2482 // itself was the 0.9.4 regression (a pending to-do rendered no label at
2483 // all). Non-step nodes keep the state · kind pair.
2484 let detail = if node.kind == NodeKind::PlanStep {
2485 todo_state_label(node).to_string()
2486 } else {
2487 format!("{state} · {kind}")
2488 };
2489 let stop_action = node
2490 .state
2491 .is_live()
2492 .then(|| stop_action(node.binding.as_ref()))
2493 .flatten();
2494 WorkRow {
2495 id: WorkRowId(format!("graph:{}", node.id.as_str())),
2496 mark,
2497 label: node.title.clone(),
2498 detail,
2499 tone,
2500 selectable: true,
2501 primary_action: Some(SidebarRowAction::InspectWork {
2502 title: format!("Work · {}", node.title),
2503 body: inspector_text(snapshot, node),
2504 stop_action: stop_action.map(Box::new),
2505 }),
2506 agent: None,
2507 }
2508 }
2509
2510 /// Status word for a plan-step (to-do) row, aligned with the four-state
2511 /// To-do vocabulary the `/task digest` text surface uses. Graph-only states
2512 /// keep their graph names.
2513 fn todo_state_label(node: &WorkNode) -> &'static str {
2514 match node.state {
2515 NodeState::Ready => "pending",
2516 NodeState::Initializing | NodeState::Active => "in progress",
2517 _ => state_label(node),
2518 }
2519 }
2520
2521 fn state_label(node: &WorkNode) -> &'static str {
2522 match node.state {
2523 NodeState::Ready => "ready",
2524 NodeState::Initializing => "initializing",
2525 NodeState::Active => "running",
2526 NodeState::Waiting => "waiting",
2527 NodeState::Blocked => "blocked",
2528 NodeState::Completed if node.acceptance.is_empty() => "completed",
2529 NodeState::Completed => "completed · evidence pending",
2530 NodeState::Verified => "verified",
2531 NodeState::Stale => "stale",
2532 NodeState::Superseded => "superseded",
2533 NodeState::Cancelled => "cancelled",
2534 NodeState::Failed => "failed",
2535 }
2536 }
2537
2538 const fn kind_label(kind: NodeKind) -> &'static str {
2539 match kind {
2540 NodeKind::Objective => "objective",
2541 NodeKind::PlanStep => "plan step",
2542 NodeKind::Operation => "operation",
2543 NodeKind::Evidence => "evidence",
2544 NodeKind::Blocker => "blocker",
2545 NodeKind::Approval => "approval",
2546 NodeKind::RuntimeRef => "runtime",
2547 NodeKind::LaneRef => "lane",
2548 }
2549 }
2550
2551 fn stop_action(binding: Option<&OperationBinding>) -> Option<SidebarRowAction> {
2552 let binding = binding?;
2553 if let Some(id) = binding.external.strip_prefix("task:") {
2554 Some(SidebarRowAction::Command(format!("/task cancel {id}")))
2555 } else if let Some(id) = binding.external.strip_prefix("shell:") {
2556 Some(SidebarRowAction::Command(format!("/jobs cancel {id}")))
2557 } else if let Some(id) = binding.external.strip_prefix("worker:") {
2558 Some(SidebarRowAction::CancelAgent {
2559 agent_id: id.to_string(),
2560 })
2561 } else {
2562 binding
2563 .external
2564 .strip_prefix("workflow:")
2565 .map(|id| SidebarRowAction::Command(format!("/workflow cancel {id}")))
2566 }
2567 }
2568
2569 fn inspector_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2570 let mut out = String::new();
2571 section_text(
2572 &mut out,
2573 "Objective",
2574 objective_for(snapshot, node)
2575 .as_deref()
2576 .unwrap_or("Not connected"),
2577 );
2578 section_list(
2579 &mut out,
2580 "Prerequisites",
2581 related_nodes(snapshot, node, EdgeKind::DependsOn, true),
2582 );
2583 section_text(
2584 &mut out,
2585 "Current",
2586 &format!("{} · {}", state_label(node), kind_label(node.kind)),
2587 );
2588 section_list(
2589 &mut out,
2590 "Downstream impact",
2591 related_nodes(snapshot, node, EdgeKind::DependsOn, false),
2592 );
2593 section_text(&mut out, "Binding + lifecycle owner", &binding_text(node));
2594 section_text(
2595 &mut out,
2596 "Evidence vs acceptance",
2597 &evidence_text(snapshot, node),
2598 );
2599 section_text(
2600 &mut out,
2601 "Blockers / approvals",
2602 &blockers_approvals_text(snapshot, node),
2603 );
2604 section_text(&mut out, "Why next", &why_next(snapshot, node));
2605 section_text(
2606 &mut out,
2607 "Provenance + last reconcile",
2608 &provenance_text(node),
2609 );
2610 if node.state == NodeState::Stale {
2611 section_text(
2612 &mut out,
2613 "Last bounded output",
2614 last_output_ref(snapshot, node)
2615 .as_deref()
2616 .unwrap_or("No output receipt"),
2617 );
2618 }
2619 out.trim_end().to_string()
2620 }
2621
2622 fn objective_for(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> Option<String> {
2623 if node.kind == NodeKind::Objective {
2624 return Some(node.title.clone());
2625 }
2626 let mut current = node.id.clone();
2627 let mut seen = HashSet::new();
2628 while seen.insert(current.clone()) {
2629 let Some(parent) = snapshot.edges.iter().find_map(|edge| {
2630 (edge.kind == EdgeKind::Contains && edge.to == current).then(|| edge.from.clone())
2631 }) else {
2632 break;
2633 };
2634 let Some(parent_node) = snapshot.node(&parent) else {
2635 break;
2636 };
2637 if parent_node.kind == NodeKind::Objective {
2638 return Some(parent_node.title.clone());
2639 }
2640 current = parent;
2641 }
2642 snapshot.compat.plan.objective.clone()
2643 }
2644
2645 fn related_nodes(
2646 snapshot: &WorkGraphSnapshot,
2647 node: &WorkNode,
2648 kind: EdgeKind,
2649 outgoing: bool,
2650 ) -> Vec<String> {
2651 snapshot
2652 .edges
2653 .iter()
2654 .filter(|edge| edge.kind == kind)
2655 .filter_map(|edge| {
2656 let related = if outgoing && edge.from == node.id {
2657 Some(&edge.to)
2658 } else if !outgoing && edge.to == node.id {
2659 Some(&edge.from)
2660 } else {
2661 None
2662 }?;
2663 snapshot
2664 .node(related)
2665 .map(|related| format!("{} · {}", related.title, state_label(related)))
2666 })
2667 .collect()
2668 }
2669
2670 fn binding_text(node: &WorkNode) -> String {
2671 let Some(binding) = node.binding.as_ref() else {
2672 return "Not bound".to_string();
2673 };
2674 let mut text = format!(
2675 "Owner: {}\nDurable: {}",
2676 binding.external,
2677 if binding.durable { "yes" } else { "no" }
2678 );
2679 if let Some(observation) = binding.last_observation.as_ref() {
2680 let owner_state = match observation.owner_state {
2681 OwnerState::Initializing => "initializing",
2682 OwnerState::Running => "running",
2683 OwnerState::Waiting => "waiting",
2684 OwnerState::Completed => "completed",
2685 OwnerState::Degraded => "degraded (finished with dropped slots)",
2686 OwnerState::Failed => "failed",
2687 OwnerState::Cancelled => "cancelled",
2688 };
2689 let _ = write!(
2690 text,
2691 "\nLast owner state: {owner_state}\nLast reconcile: {} ms UTC · sequence {}",
2692 observation.observed_at, observation.seq
2693 );
2694 } else {
2695 text.push_str("\nLast reconcile: never");
2696 }
2697 text
2698 }
2699
2700 fn evidence_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2701 let acceptance = if node.acceptance.is_empty() {
2702 vec!["- No evidence requirement".to_string()]
2703 } else {
2704 node.acceptance
2705 .iter()
2706 .map(|requirement| format!("- {}", acceptance_label(requirement)))
2707 .collect()
2708 };
2709 let evidence = evidence_for(snapshot, node);
2710 let evidence = if evidence.is_empty() {
2711 vec!["- None attached".to_string()]
2712 } else {
2713 evidence
2714 .into_iter()
2715 .map(|evidence| {
2716 let reference = evidence
2717 .evidence
2718 .as_ref()
2719 .map(|item| item.reference())
2720 .unwrap_or("invalid evidence node");
2721 format!("- {reference} · {}", state_label(evidence))
2722 })
2723 .collect()
2724 };
2725 format!(
2726 "Acceptance:\n{}\nEvidence:\n{}",
2727 acceptance.join("\n"),
2728 evidence.join("\n")
2729 )
2730 }
2731
2732 fn acceptance_label(requirement: &AcceptanceRequirement) -> String {
2733 match requirement {
2734 AcceptanceRequirement::EvidenceOfKind { kind } => {
2735 let kind = match kind {
2736 EvidenceKindTag::ToolRun => "tool run",
2737 EvidenceKindTag::Artifact => "artifact",
2738 EvidenceKindTag::TestSummary => "test summary",
2739 EvidenceKindTag::Receipt => "receipt",
2740 EvidenceKindTag::Approval => "approval",
2741 EvidenceKindTag::Route => "route",
2742 EvidenceKindTag::WebCitation => "web citation",
2743 };
2744 format!("evidence of kind {kind}")
2745 }
2746 }
2747 }
2748
2749 fn evidence_for<'a>(snapshot: &'a WorkGraphSnapshot, node: &WorkNode) -> Vec<&'a WorkNode> {
2750 snapshot
2751 .edges
2752 .iter()
2753 .filter(|edge| edge.kind == EdgeKind::Verifies && edge.to == node.id)
2754 .filter_map(|edge| snapshot.node(&edge.from))
2755 .collect()
2756 }
2757
2758 fn blockers_approvals_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2759 let mut lines = Vec::new();
2760 lines.extend(
2761 related_nodes(snapshot, node, EdgeKind::Blocks, false)
2762 .into_iter()
2763 .map(|item| format!("- Blocked by {item}")),
2764 );
2765 lines.extend(
2766 related_nodes(snapshot, node, EdgeKind::RequiresApproval, true)
2767 .into_iter()
2768 .map(|item| format!("- Approval {item}")),
2769 );
2770 if node.kind == NodeKind::PlanStep {
2771 lines.extend(
2772 snapshot
2773 .nodes
2774 .iter()
2775 .filter(|candidate| candidate.kind == NodeKind::Approval)
2776 .map(|approval| format!("- {} · {}", approval.title, state_label(approval))),
2777 );
2778 }
2779 if lines.is_empty() {
2780 "None".to_string()
2781 } else {
2782 lines.join("\n")
2783 }
2784 }
2785
2786 fn why_next(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2787 match node.state {
2788 NodeState::Ready => {
2789 let pending = related_nodes(snapshot, node, EdgeKind::DependsOn, true);
2790 if pending.is_empty() {
2791 "Ready with no recorded prerequisite".to_string()
2792 } else {
2793 format!("Ready after: {}", pending.join(", "))
2794 }
2795 }
2796 NodeState::Initializing => "Spawn intent is registered; awaiting owner handle".to_string(),
2797 NodeState::Active => "Lifecycle owner reports active work".to_string(),
2798 NodeState::Waiting => "Waiting on an owner or approval".to_string(),
2799 NodeState::Blocked => "Blocked; resolve the causes above".to_string(),
2800 NodeState::Completed if !node.acceptance.is_empty() => {
2801 "Execution ended, but acceptance evidence is still missing".to_string()
2802 }
2803 NodeState::Stale => "Owner cannot confirm liveness after reconciliation".to_string(),
2804 NodeState::Verified => "Acceptance evidence is satisfied".to_string(),
2805 NodeState::Completed => "Completed with no evidence requirement".to_string(),
2806 NodeState::Superseded => "A replacement node owns this work".to_string(),
2807 NodeState::Cancelled => "Cancelled by lifecycle owner".to_string(),
2808 NodeState::Failed => "Failed; inspect owner output before retrying".to_string(),
2809 }
2810 }
2811
2812 fn provenance_text(node: &WorkNode) -> String {
2813 let provenance = match &node.provenance {
2814 Provenance::Import { ordinal, .. } => ordinal
2815 .map(|ordinal| format!("legacy import · ordinal {ordinal}"))
2816 .unwrap_or_else(|| "legacy import".to_string()),
2817 Provenance::ToolUpdate { tool, call_id } => {
2818 format!("tool {tool} · call {call_id}")
2819 }
2820 Provenance::RuntimeReconcile {
2821 source,
2822 observed_at,
2823 } => format!("runtime {source} · {observed_at} ms UTC"),
2824 Provenance::UserEdit { proposal_id } => format!("user-approved diff {proposal_id}"),
2825 };
2826 let reconcile = node
2827 .binding
2828 .as_ref()
2829 .and_then(|binding| binding.last_observation.as_ref())
2830 .map(|observation| format!("{} ms UTC", observation.observed_at))
2831 .unwrap_or_else(|| "never".to_string());
2832 format!("Source: {provenance}\nLast reconcile: {reconcile}")
2833 }
2834
2835 fn last_output_ref(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> Option<String> {
2836 node.binding
2837 .as_ref()
2838 .and_then(|binding| binding.last_observation.as_ref())
2839 .and_then(|observation| observation.output.as_ref())
2840 .map(format_evidence_ref)
2841 .or_else(|| {
2842 evidence_for(snapshot, node)
2843 .into_iter()
2844 .max_by_key(|evidence| evidence.updated_at)
2845 .and_then(|evidence| evidence.evidence.as_ref())
2846 .map(format_evidence_ref)
2847 })
2848 }
2849
2850 fn format_evidence_ref(evidence: &crate::work_graph::EvidenceRef) -> String {
2851 let kind = match evidence.kind() {
2852 EvidenceKind::ToolRun => "tool run".to_string(),
2853 EvidenceKind::Artifact { .. } => "artifact".to_string(),
2854 EvidenceKind::TestSummary => "test summary".to_string(),
2855 EvidenceKind::Receipt { .. } => "receipt".to_string(),
2856 EvidenceKind::Approval => "approval".to_string(),
2857 EvidenceKind::Route => "route".to_string(),
2858 EvidenceKind::WebCitation {
2859 url, retrieved_at, ..
2860 } => format!("web citation · {url} · retrieved {retrieved_at}"),
2861 };
2862 let bytes = evidence
2863 .raw_bytes()
2864 .map(|bytes| format!(" · {bytes} raw bytes"))
2865 .unwrap_or_default();
2866 let truncation = if evidence.truncated() {
2867 " · truncated"
2868 } else {
2869 ""
2870 };
2871 format!("{} · {kind}{bytes}{truncation}", evidence.reference())
2872 }
2873
2874 fn section_text(out: &mut String, title: &str, body: &str) {
2875 let _ = writeln!(out, "{title}\n{body}\n");
2876 }
2877
2878 fn section_list(out: &mut String, title: &str, items: Vec<String>) {
2879 if items.is_empty() {
2880 section_text(out, title, "None");
2881 } else {
2882 section_text(
2883 out,
2884 title,
2885 &items
2886 .into_iter()
2887 .map(|item| format!("- {item}"))
2888 .collect::<Vec<_>>()
2889 .join("\n"),
2890 );
2891 }
2892 }
2893
2894 #[cfg(test)]
2895 mod tests {
2896 use super::*;
2897 use crate::config::Config;
2898 use crate::tools::spec::ToolResult;
2899 use crate::tui::app::TuiOptions;
2900 use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started};
2901 use crate::work_graph::{CompatTodoBinding, OperationBinding, WorkNodeId};
2902
2903 fn test_app() -> App {
2904 App::new(
2905 TuiOptions {
2906 model: "deepseek-v4-flash".to_string(),
2907 start_in_agent_mode: true,
2908 ..crate::test_support::test_tui_options(std::path::PathBuf::from(
2909 "/workspace/project",
2910 ))
2911 },
2912 &Config::default(),
2913 )
2914 }
2915
2916 fn surface() -> WorkSurfaceState {
2917 WorkSurfaceState::default()
2918 }
2919
2920 fn operation(state: NodeState, suffix: &str) -> WorkNode {
2921 WorkNode {
2922 id: WorkNodeId::derive("work-surface-test", suffix),
2923 kind: NodeKind::Operation,
2924 title: format!("operation {suffix}"),
2925 state,
2926 acceptance: Vec::new(),
2927 binding: Some(OperationBinding {
2928 external: format!("shell:{suffix}"),
2929 durable: false,
2930 last_observation: None,
2931 }),
2932 evidence: None,
2933 provenance: Provenance::ToolUpdate {
2934 tool: "test".to_string(),
2935 call_id: suffix.to_string(),
2936 },
2937 created_at: 1,
2938 updated_at: 1,
2939 }
2940 }
2941
2942 fn running_agent(agent_id: &str) -> SubAgentResult {
2943 SubAgentResult {
2944 usage: None,
2945 name: agent_id.to_string(),
2946 agent_id: agent_id.to_string(),
2947 context_mode: "fresh".to_string(),
2948 fork_context: false,
2949 workspace: None,
2950 git_branch: None,
2951 agent_type: crate::tools::subagent::FleetRole::Worker,
2952 assignment: crate::tools::subagent::SubAgentAssignment {
2953 objective: "sweep the lane".to_string(),
2954 role: Some("builder".to_string()),
2955 },
2956 model: "test-model".to_string(),
2957 nickname: Some("Blue Whale".to_string()),
2958 status: SubAgentStatus::Running,
2959 worker_status: None,
2960 runtime_permissions: None,
2961 parent_run_id: None,
2962 spawn_depth: 0,
2963 child_route: None,
2964 result: None,
2965 steps_taken: 1,
2966 checkpoint: None,
2967 needs_input: None,
2968 duration_ms: 100,
2969 started_at: None,
2970 from_prior_session: false,
2971 }
2972 }
2973
2974 fn retained_agent_receipt(
2975 id: &str,
2976 status: AgentWorkerStatus,
2977 state: crate::agent_roster::RosterState,
2978 ) -> crate::agent_roster::AgentRosterRow {
2979 crate::agent_roster::AgentRosterRow {
2980 worker_id: id.to_string(),
2981 display_name: format!("retained {id}"),
2982 model: "test-model".to_string(),
2983 state,
2984 status,
2985 activity: None,
2986 millis: Some(3_000),
2987 input_tokens: None,
2988 output_tokens: None,
2989 cost_microusd: None,
2990 steps_taken: 3,
2991 parent_run_id: None,
2992 run_id: id.to_string(),
2993 }
2994 }
2995
2996 /// The register merges the retained roster with the live cache; the
2997 /// counter that opens the dock has to read the same merged view, or a
2998 /// child blocked on a person goes unseen the moment its 45-second card
2999 /// expires (or a session restore filters it out of `subagent_cache`).
3000 #[test]
3001 fn a_roster_only_waiting_worker_opens_the_dock() {
3002 use crate::agent_roster::RosterState;
3003
3004 let mut app = test_app();
3005 app.current_session_id = Some("roster-owner".to_string());
3006 app.agent_roster_session_id = app.current_session_id.clone();
3007 assert!(app.subagent_cache.is_empty());
3008 app.agent_roster = vec![retained_agent_receipt(
3009 "blocked",
3010 AgentWorkerStatus::WaitingForUser,
3011 RosterState::Waiting,
3012 )];
3013
3014 assert_eq!(live_agent_row_count(&mut app), 1);
3015 assert!(view_has_work(&mut app, RailPanel::Agents));
3016 }
3017
3018 /// The other half of reading the roster: it is retained for an hour, so a
3019 /// settled receipt must not pin the dock open for the rest of the session.
3020 /// A parked husk counts as settled too — nothing will answer it (#5906).
3021 #[test]
3022 fn retained_settled_receipts_do_not_hold_the_dock_open() {
3023 use crate::agent_roster::RosterState;
3024
3025 let mut app = test_app();
3026 app.current_session_id = Some("roster-owner".to_string());
3027 app.agent_roster_session_id = app.current_session_id.clone();
3028 app.agent_roster = vec![
3029 retained_agent_receipt("done", AgentWorkerStatus::Completed, RosterState::Done),
3030 retained_agent_receipt("failed", AgentWorkerStatus::Failed, RosterState::Failed),
3031 retained_agent_receipt(
3032 "cancelled",
3033 AgentWorkerStatus::Cancelled,
3034 RosterState::Cancelled,
3035 ),
3036 retained_agent_receipt(
3037 "parked",
3038 AgentWorkerStatus::WaitingForUser,
3039 RosterState::Parked,
3040 ),
3041 ];
3042
3043 // Every receipt is still readable in the register...
3044 assert_eq!(
3045 agents_view_rows(&mut app)
3046 .iter()
3047 .filter(|row| row.agent.is_some())
3048 .count(),
3049 4
3050 );
3051 // ...and none of them re-opens the dock.
3052 assert_eq!(live_agent_row_count(&mut app), 0);
3053 assert!(!view_has_work(&mut app, RailPanel::Agents));
3054 }
3055
3056 /// #36 nesting is a fact about the *live* projection: `order_agent_seeds`
3057 /// indents a child only when its parent is on the same surface, and
3058 /// `(+N)` counts only children the list actually shows.
3059 #[test]
3060 fn nesting_survives_while_both_rows_are_live() {
3061 let mut app = test_app();
3062 app.current_session_id = Some("roster-owner".to_string());
3063 app.agent_roster_session_id = app.current_session_id.clone();
3064 let mut child = running_agent("child");
3065 child.parent_run_id = Some("parent".to_string());
3066 app.subagent_cache = vec![running_agent("parent"), child];
3067
3068 let rows = agents_view_rows(&mut app);
3069 let label = |id: &str| {
3070 rows.iter()
3071 .find(|row| row.id.0 == format!("worker:{id}"))
3072 .unwrap_or_else(|| panic!("{id} row: {rows:?}"))
3073 .label
3074 .clone()
3075 };
3076 assert!(label("child").starts_with("↳ "), "{:?}", label("child"));
3077 assert!(label("parent").contains("(+1)"), "{:?}", label("parent"));
3078 }
3079
3080 /// Deliberate, and asserted so it is not "fixed" into a lie: a receipt
3081 /// that has outlived its live card renders FLAT, with no `↳ ` and no
3082 /// `(+N)`, even when the roster still knows its `parent_run_id`.
3083 ///
3084 /// The register is ordered by the roster (creation order, parked last),
3085 /// not by the tree, so an indent here would be an adjacency claim the
3086 /// list cannot keep — exactly the dangling indent `order_agent_seeds`
3087 /// refuses to draw. Deriving depth here instead would also put a second
3088 /// depth authority on the same list. Nesting degrades to flat, and it
3089 /// degrades symmetrically: see the sibling test below.
3090 #[test]
3091 fn a_retained_child_renders_flat_under_a_retained_parent() {
3092 use crate::agent_roster::RosterState;
3093
3094 let mut app = test_app();
3095 app.current_session_id = Some("roster-owner".to_string());
3096 app.agent_roster_session_id = app.current_session_id.clone();
3097 let mut child =
3098 retained_agent_receipt("child", AgentWorkerStatus::Completed, RosterState::Done);
3099 child.parent_run_id = Some("parent".to_string());
3100 app.agent_roster = vec![
3101 retained_agent_receipt("parent", AgentWorkerStatus::Completed, RosterState::Done),
3102 child,
3103 ];
3104
3105 let rows = agents_view_rows(&mut app);
3106 for id in ["parent", "child"] {
3107 let label = rows
3108 .iter()
3109 .find(|row| row.id.0 == format!("worker:{id}"))
3110 .unwrap_or_else(|| panic!("{id} row: {rows:?}"))
3111 .label
3112 .clone();
3113 assert!(!label.contains('↳'), "{id}: {label:?}");
3114 assert!(!label.contains("(+"), "{id}: {label:?}");
3115 }
3116 }
3117
3118 /// The other direction, which is what makes the flattening honest rather
3119 /// than half-applied: when the PARENT has expired to a receipt and only
3120 /// the child is still live, the live child flattens too — its parent is
3121 /// not among the cache seeds, so `order_agent_seeds` leaves it at the top
3122 /// level. Nesting is present for a pair or absent for a pair; it is never
3123 /// stamped on one half of one.
3124 #[test]
3125 fn a_live_child_flattens_once_its_parent_has_expired_to_a_receipt() {
3126 use crate::agent_roster::RosterState;
3127
3128 let mut app = test_app();
3129 app.current_session_id = Some("roster-owner".to_string());
3130 app.agent_roster_session_id = app.current_session_id.clone();
3131 let mut child = running_agent("child");
3132 child.parent_run_id = Some("parent".to_string());
3133 app.subagent_cache = vec![child];
3134 let mut retained_child =
3135 retained_agent_receipt("child", AgentWorkerStatus::Running, RosterState::Running);
3136 retained_child.parent_run_id = Some("parent".to_string());
3137 app.agent_roster = vec![
3138 retained_agent_receipt("parent", AgentWorkerStatus::Completed, RosterState::Done),
3139 retained_child,
3140 ];
3141
3142 let rows = agents_view_rows(&mut app);
3143 let child_label = rows
3144 .iter()
3145 .find(|row| row.id.0 == "worker:child")
3146 .unwrap_or_else(|| panic!("child row: {rows:?}"))
3147 .label
3148 .clone();
3149 assert!(!child_label.contains('↳'), "{child_label:?}");
3150 let parent_label = rows
3151 .iter()
3152 .find(|row| row.id.0 == "worker:parent")
3153 .unwrap_or_else(|| panic!("parent row: {rows:?}"))
3154 .label
3155 .clone();
3156 assert!(!parent_label.contains("(+"), "{parent_label:?}");
3157 }
3158
3159 #[test]
3160 fn agents_register_retains_expired_receipts_alongside_fresh_live_rows() {
3161 use crate::agent_roster::RosterState;
3162 use crate::tui::subagent_routing::reconcile_subagent_activity_state_at;
3163 use std::time::Duration;
3164
3165 let mut app = test_app();
3166 app.current_session_id = Some("roster-owner".to_string());
3167 app.agent_roster_session_id = app.current_session_id.clone();
3168 let mut done = running_agent("done");
3169 done.status = SubAgentStatus::Completed;
3170 app.subagent_cache = vec![done, running_agent("live")];
3171 let receipt =
3172 retained_agent_receipt("done", AgentWorkerStatus::Completed, RosterState::Done);
3173 app.agent_roster = vec![
3174 receipt.clone(),
3175 receipt,
3176 // A newer live cache row must win over an older retained receipt.
3177 retained_agent_receipt("live", AgentWorkerStatus::Completed, RosterState::Done),
3178 ];
3179 app.agent_progress
3180 .insert("done".to_string(), "old progress".to_string());
3181 let observed = Instant::now();
3182 reconcile_subagent_activity_state_at(&mut app, observed);
3183 reconcile_subagent_activity_state_at(&mut app, observed + Duration::from_secs(46));
3184 assert!(
3185 !app.subagent_cache
3186 .iter()
3187 .any(|agent| agent.agent_id == "done")
3188 );
3189 assert!(!app.agent_progress.contains_key("done"));
3190
3191 let rows = agents_view_rows(&mut app);
3192 let workers: Vec<_> = rows.iter().filter(|row| row.agent.is_some()).collect();
3193 assert_eq!(workers.len(), 2, "one row per retained/live worker ID");
3194 let done = workers
3195 .iter()
3196 .find(|row| row.id.0 == "worker:done")
3197 .unwrap();
3198 assert_eq!(done.agent.as_ref().unwrap().status, "completed");
3199 assert_eq!(done.agent.as_ref().unwrap().elapsed_secs, Some(3));
3200 assert_eq!(done.agent.as_ref().unwrap().tokens, None);
3201 let live = workers
3202 .iter()
3203 .find(|row| row.id.0 == "worker:live")
3204 .unwrap();
3205 assert_eq!(live.agent.as_ref().unwrap().status, "running");
3206
3207 app.subagent_cache[0].status = SubAgentStatus::Completed;
3208 reconcile_subagent_activity_state_at(&mut app, observed + Duration::from_secs(47));
3209 reconcile_subagent_activity_state_at(&mut app, observed + Duration::from_secs(93));
3210 assert!(app.subagent_cache.is_empty());
3211 assert!(app.agent_progress.is_empty());
3212 let rows = agents_view_rows(&mut app);
3213 assert_eq!(rows.iter().filter(|row| row.agent.is_some()).count(), 2);
3214 }
3215
3216 #[test]
3217 fn retained_agents_register_is_bound_to_the_exact_parent_session() {
3218 use crate::agent_roster::RosterState;
3219
3220 let mut app = test_app();
3221 app.agent_roster_session_id = Some("original-session".to_string());
3222 app.agent_roster = vec![retained_agent_receipt(
3223 "private-worker",
3224 AgentWorkerStatus::Completed,
3225 RosterState::Done,
3226 )];
3227 app.agent_roster[0].cost_microusd = Some(1_000);
3228 for owner in [None, Some(""), Some("other-session")] {
3229 app.current_session_id = owner.map(str::to_string);
3230 assert!(app.current_agent_roster().is_empty());
3231 assert!(
3232 !agents_view_rows(&mut app)
3233 .iter()
3234 .any(|row| row.agent.is_some())
3235 );
3236 assert!(
3237 !super::super::views::price_rows(&mut app)
3238 .iter()
3239 .any(|row| row.id.0.starts_with("price:agent:"))
3240 );
3241 }
3242 app.current_session_id = Some("original-session".to_string());
3243 assert_eq!(app.current_agent_roster().len(), 1);
3244 assert_eq!(
3245 agents_view_rows(&mut app)
3246 .iter()
3247 .filter(|row| row.agent.is_some())
3248 .count(),
3249 1
3250 );
3251 }
3252
3253 #[test]
3254 fn retained_agents_register_distinguishes_parked_from_waiting_for_input() {
3255 use crate::agent_roster::RosterState;
3256
3257 let mut app = test_app();
3258 app.current_session_id = Some("roster-owner".to_string());
3259 app.agent_roster_session_id = app.current_session_id.clone();
3260 app.agent_roster = vec![
3261 retained_agent_receipt(
3262 "parked",
3263 AgentWorkerStatus::WaitingForUser,
3264 RosterState::Parked,
3265 ),
3266 retained_agent_receipt(
3267 "asked",
3268 AgentWorkerStatus::WaitingForUser,
3269 RosterState::Waiting,
3270 ),
3271 ];
3272 let rows = agents_view_rows(&mut app);
3273 let parked = rows.iter().find(|row| row.id.0 == "worker:parked").unwrap();
3274 let asked = rows.iter().find(|row| row.id.0 == "worker:asked").unwrap();
3275 assert_eq!(parked.mark, RosterState::Parked.glyph());
3276 assert_eq!(parked.tone, WorkTone::Muted);
3277 assert_ne!(parked.agent.as_ref().unwrap().status, "waiting for input");
3278 assert_eq!(asked.agent.as_ref().unwrap().status, "waiting for input");
3279 assert_eq!(asked.tone, WorkTone::Attention);
3280 assert!(
3281 matches!(&parked.primary_action, Some(SidebarRowAction::OpenAgentTranscript { agent_id }) if agent_id == "parked")
3282 );
3283 }
3284
3285 /// #5287: the identity column leads with the name the lane was dispatched
3286 /// under; the whale only names an agent that was dispatched without one.
3287 #[test]
3288 fn agent_identity_column_leads_with_the_dispatch_name() {
3289 let mut app = test_app();
3290 let mut named = running_agent("agent_named_lane");
3291 named.name = "branch-triage".to_string();
3292 app.subagent_cache.push(named);
3293 app.subagent_cache.push(running_agent("agent_plain_lane"));
3294
3295 let rows = agent_rows(&app);
3296 let label = |id: &str| {
3297 rows.iter()
3298 .find(|ranked| ranked.row.id.0 == format!("worker:{id}"))
3299 .unwrap_or_else(|| panic!("row for {id}"))
3300 .row
3301 .label
3302 .clone()
3303 };
3304 assert_eq!(label("agent_named_lane"), "branch-triage");
3305 assert_eq!(label("agent_plain_lane"), "Blue Whale");
3306 }
3307
3308 #[test]
3309 fn agent_identity_column_prefers_resolved_profile_over_generated_whale() {
3310 let mut app = test_app();
3311 let mut agent = running_agent("agent_flash_lane");
3312 agent.child_route = Some(crate::tools::subagent::ChildRouteReceipt {
3313 requested_type: "custom".to_string(),
3314 requested_profile: Some("DeepSeek V4 Flash".to_string()),
3315 resolved_profile_id: Some("flash-scout".to_string()),
3316 profile_origin: Some("fleet:release".to_string()),
3317 canonical_role: "scout".to_string(),
3318 provider_id: "deepseek".to_string(),
3319 model_id: "deepseek-v4-flash-vision-exp".to_string(),
3320 route_source: "fleet".to_string(),
3321 fallback_note: None,
3322 requested_reasoning: "inherit".to_string(),
3323 effective_reasoning: None,
3324 runtime_version: "test".to_string(),
3325 runtime_build_sha: "unknown".to_string(),
3326 });
3327 app.subagent_cache.push(agent);
3328
3329 let row = agent_rows(&app)
3330 .into_iter()
3331 .find(|ranked| ranked.row.id.0 == "worker:agent_flash_lane")
3332 .expect("resolved Fleet row")
3333 .row;
3334 assert_eq!(row.label, "flash-scout");
3335 assert_eq!(
3336 row.agent.as_ref().map(|facts| facts.role_label.as_str()),
3337 Some("flash-scout")
3338 );
3339 }
3340
3341 /// After the recent-only TTL suppresses transient receipts, the live
3342 /// heading must count only the recent rows the live list still shows —
3343 /// quoting the unfiltered total would claim receipts the reader cannot
3344 /// see (2026-08-04 adversarial review of the durable-row exemption).
3345 #[test]
3346 fn suppressed_transients_leave_the_live_heading_count_honest() {
3347 let mut plan_step = operation(NodeState::Completed, "shipped-step");
3348 plan_step.kind = NodeKind::PlanStep;
3349 plan_step.binding = None;
3350 let mut transient = operation(NodeState::Completed, "settled-op");
3351 transient.binding.as_mut().expect("binding").durable = true;
3352 let mut snapshot = WorkGraphSnapshot::new();
3353 snapshot.nodes = vec![plan_step, transient];
3354
3355 let mut surface = surface();
3356 surface.set_presentation_now_ms(0);
3357 let _ = graph_rows(
3358 &mut surface,
3359 &snapshot,
3360 None,
3361 Vec::new(),
3362 None,
3363 SettledFileActivity::default(),
3364 );
3365 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 1);
3366 let rows = graph_rows(
3367 &mut surface,
3368 &snapshot,
3369 None,
3370 Vec::new(),
3371 None,
3372 SettledFileActivity::default(),
3373 );
3374 let heading = &rows[0];
3375 assert!(
3376 heading.label.contains("1 recent"),
3377 "live heading counts only the surviving durable row: {}",
3378 heading.label
3379 );
3380 assert!(
3381 rows.iter().any(|row| row.label.contains("shipped-step")),
3382 "durable to-do row survives: {rows:?}"
3383 );
3384 assert!(
3385 !rows.iter().any(|row| row.label.contains("settled-op")),
3386 "transient receipt is suppressed: {rows:?}"
3387 );
3388 // The catalog keeps the full rows, so it keeps the full count.
3389 assert!(
3390 surface
3391 .catalog_rows
3392 .first()
3393 .is_some_and(|row| row.label.contains("2 recent")),
3394 "catalog heading keeps the unfiltered count: {:?}",
3395 surface.catalog_rows.first()
3396 );
3397 }
3398
3399 #[test]
3400 fn heading_counts_initializing_and_active_operations_as_running() {
3401 let mut snapshot = WorkGraphSnapshot::new();
3402 snapshot.nodes = vec![
3403 operation(NodeState::Initializing, "initializing"),
3404 operation(NodeState::Active, "active"),
3405 operation(NodeState::Ready, "ready"),
3406 ];
3407
3408 let rows = graph_rows(
3409 &mut surface(),
3410 &snapshot,
3411 None,
3412 Vec::new(),
3413 None,
3414 SettledFileActivity::default(),
3415 );
3416
3417 assert_eq!(
3418 rows.first().map(|row| row.label.as_str()),
3419 Some("Work · Running: operation initializing · 2 active")
3420 );
3421 }
3422
3423 #[test]
3424 fn live_projection_hides_clean_transient_receipts_without_duplicate_todo_group() {
3425 let todo_id = WorkNodeId::derive("work-surface-test", "todo:1");
3426 let todo = WorkNode {
3427 id: todo_id.clone(),
3428 kind: NodeKind::PlanStep,
3429 title: "Keep the durable checklist visible".to_string(),
3430 state: NodeState::Ready,
3431 acceptance: Vec::new(),
3432 binding: None,
3433 evidence: None,
3434 provenance: Provenance::ToolUpdate {
3435 tool: "work_update".to_string(),
3436 call_id: "todo-1".to_string(),
3437 },
3438 created_at: 1,
3439 updated_at: 1,
3440 };
3441 let mut snapshot = WorkGraphSnapshot::new();
3442 snapshot.nodes = vec![
3443 operation(NodeState::Completed, "settled"),
3444 operation(NodeState::Active, "running"),
3445 todo,
3446 ];
3447 snapshot.compat.todos.push(CompatTodoBinding {
3448 legacy_id: 1,
3449 node: todo_id,
3450 plan_index: None,
3451 });
3452
3453 let rows = graph_rows(
3454 &mut surface(),
3455 &snapshot,
3456 None,
3457 Vec::new(),
3458 None,
3459 SettledFileActivity::default(),
3460 );
3461 let labels = rows
3462 .iter()
3463 .map(|row| row.label.as_str())
3464 .collect::<Vec<_>>();
3465
3466 assert!(labels.contains(&"operation running"), "{labels:?}");
3467 assert!(!labels.contains(&"operation settled"), "{labels:?}");
3468 assert_eq!(
3469 labels
3470 .iter()
3471 .filter(|label| **label == "Keep the durable checklist visible")
3472 .count(),
3473 1,
3474 "one plan node must produce one Work row: {labels:?}"
3475 );
3476 assert!(
3477 !labels.iter().any(|label| label.starts_with("To-do")),
3478 "the ordered Work projection must not add a duplicate To-do heading: {labels:?}"
3479 );
3480 assert!(
3481 labels.contains(&"Keep the durable checklist visible"),
3482 "{labels:?}"
3483 );
3484 assert!(
3485 snapshot
3486 .nodes
3487 .iter()
3488 .any(|node| node.title == "operation settled"),
3489 "projection filtering must retain the historical graph receipt"
3490 );
3491 }
3492
3493 #[test]
3494 fn projection_keeps_durable_and_evidence_gated_terminal_operations() {
3495 let mut durable = operation(NodeState::Completed, "durable");
3496 durable.binding.as_mut().expect("binding").durable = true;
3497 let mut failed = operation(NodeState::Failed, "failed");
3498 failed.binding.as_mut().expect("binding").durable = true;
3499 let mut evidence_pending = operation(NodeState::Completed, "evidence-pending");
3500 evidence_pending.acceptance = vec![AcceptanceRequirement::EvidenceOfKind {
3501 kind: EvidenceKindTag::ToolRun,
3502 }];
3503 let mut snapshot = WorkGraphSnapshot::new();
3504 snapshot.nodes = vec![durable, failed, evidence_pending];
3505
3506 let rows = graph_rows(
3507 &mut surface(),
3508 &snapshot,
3509 None,
3510 Vec::new(),
3511 None,
3512 SettledFileActivity::default(),
3513 );
3514 let labels = rows
3515 .iter()
3516 .map(|row| row.label.as_str())
3517 .collect::<Vec<_>>();
3518
3519 for expected in [
3520 "operation durable",
3521 "operation failed",
3522 "operation evidence-pending",
3523 ] {
3524 assert!(labels.contains(&expected), "missing {expected}: {labels:?}");
3525 }
3526 }
3527
3528 #[test]
3529 fn transient_failed_operation_is_recent_while_durable_failure_needs_input() {
3530 let transient = operation(NodeState::Failed, "shell transient");
3531 let mut durable = operation(NodeState::Failed, "durable");
3532 durable.binding.as_mut().expect("binding").durable = true;
3533
3534 assert_eq!(node_bucket(&transient), WorkBucket::Recent);
3535 assert_eq!(node_bucket(&durable), WorkBucket::Attention);
3536 }
3537
3538 #[test]
3539 fn projection_orders_attention_before_ready_and_recent() {
3540 let mut recent = operation(NodeState::Completed, "recent");
3541 recent.binding.as_mut().expect("binding").durable = true;
3542 let mut snapshot = WorkGraphSnapshot::new();
3543 snapshot.nodes = vec![
3544 recent,
3545 operation(NodeState::Ready, "ready"),
3546 operation(NodeState::Blocked, "blocked"),
3547 operation(NodeState::Active, "active"),
3548 ];
3549
3550 let labels = graph_rows(
3551 &mut surface(),
3552 &snapshot,
3553 None,
3554 Vec::new(),
3555 None,
3556 SettledFileActivity::default(),
3557 )
3558 .into_iter()
3559 .map(|row| row.label)
3560 .collect::<Vec<_>>();
3561
3562 assert_eq!(
3563 labels,
3564 [
3565 "Work · Needs input: operation blocked · 1 blocked",
3566 "operation blocked",
3567 "operation active",
3568 "operation ready",
3569 "operation recent",
3570 ]
3571 );
3572 }
3573
3574 #[test]
3575 fn activity_targets_keep_workspace_relative_paths_and_hide_external_paths() {
3576 let workspace = Path::new("/workspace/project");
3577 assert_eq!(
3578 privacy_safe_path(workspace, "/workspace/project/src/lib.rs").as_deref(),
3579 Some("src/lib.rs")
3580 );
3581 assert_eq!(
3582 privacy_safe_path(workspace, "/Users/alice/private.txt"),
3583 None
3584 );
3585 assert_eq!(privacy_safe_path(workspace, "../private.txt"), None);
3586 assert_eq!(safe_pattern("needle\nsecret"), "needle secret");
3587 }
3588
3589 #[test]
3590 fn settled_canonical_file_actions_keep_aggregates_and_safe_targets() {
3591 let mut app = test_app();
3592 let calls = [
3593 ("read", serde_json::json!({"path": "src/read.rs"})),
3594 ("list", serde_json::json!({"path": "src"})),
3595 ("search_name", serde_json::json!({"query": "lib.rs"})),
3596 (
3597 "search_content",
3598 serde_json::json!({"pattern": "needle\nprivate", "path": "src"}),
3599 ),
3600 (
3601 "write",
3602 serde_json::json!({"path": "src/new.rs", "content": "new\n"}),
3603 ),
3604 (
3605 "edit",
3606 serde_json::json!({
3607 "path": "src/edit.rs",
3608 "search": "old",
3609 "replace": "new"
3610 }),
3611 ),
3612 (
3613 "patch",
3614 serde_json::json!({
3615 "patch": "diff --git a/src/patch.rs b/src/patch.rs\n--- a/src/patch.rs\n+++ b/src/patch.rs\n@@ -1 +1 @@\n-old\n+new\n"
3616 }),
3617 ),
3618 ];
3619
3620 for (action, payload) in calls {
3621 let id = format!("file-{action}");
3622 let mut input = payload;
3623 input["action"] = serde_json::json!(action);
3624 handle_tool_call_started(&mut app, &id, "File", &input);
3625 handle_tool_call_complete(&mut app, &id, "File", &Ok(ToolResult::success("ok")));
3626 app.flush_active_cell();
3627 }
3628
3629 let activity = settled_file_activity(&app);
3630 assert_eq!(
3631 activity.summary,
3632 FileActivitySummary {
3633 files_read: 1,
3634 dirs_listed: 1,
3635 patterns_searched: 2,
3636 files_written: 3,
3637 }
3638 );
3639 assert_eq!(activity.read, ["src/read.rs"]);
3640 assert_eq!(activity.list, ["src"]);
3641 assert_eq!(activity.search, ["lib.rs", "needle private"]);
3642 assert_eq!(
3643 activity.write,
3644 ["src/new.rs", "src/edit.rs", "src/patch.rs"]
3645 );
3646 }
3647
3648 #[test]
3649 fn multifile_receipt_counts_semantic_file_outcomes_in_work_label() {
3650 let mut app = test_app();
3651 let input = serde_json::json!({
3652 "action": "patch",
3653 "patch": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n"
3654 });
3655 handle_tool_call_started(&mut app, "file-multi", "File", &input);
3656 let result = ToolResult::success("ok").with_metadata(serde_json::json!({
3657 "mutation": {
3658 "diff": "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n--- 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",
3659 "files": [
3660 { "path": "update.rs", "outcome": "updated" },
3661 { "path": "create.rs", "outcome": "created" },
3662 { "path": "delete.rs", "outcome": "deleted" }
3663 ],
3664 "renames": [{ "from": "old.rs", "to": "new.rs" }]
3665 }
3666 }));
3667 handle_tool_call_complete(&mut app, "file-multi", "File", &Ok(result));
3668 app.flush_active_cell();
3669
3670 let activity = settled_file_activity(&app);
3671 assert_eq!(activity.summary.files_written, 4);
3672 let write_row = activity_rows(activity)
3673 .into_iter()
3674 .find(|row| row.row.label.starts_with("Wrote"))
3675 .expect("write row");
3676 assert_eq!(write_row.row.label, "Wrote 4 files");
3677 assert_eq!(
3678 write_row.row.detail,
3679 "4 files · 1 created · 1 updated · 1 deleted · 1 renamed · +2 -2"
3680 );
3681 }
3682
3683 fn mutation_activity(mode: InlineDiffMode) -> SettledFileActivity {
3684 let result = ToolResult::success("ok").with_metadata(serde_json::json!({
3685 "mutation": {
3686 "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -1 +1 @@\n-old\n+new\n",
3687 "files": [{
3688 "path": "/Users/alice/private.rs",
3689 "outcome": "updated"
3690 }],
3691 "renames": []
3692 }
3693 }));
3694 let receipt = FileMutationReceipt::from_success(Path::new("/workspace/project"), &result)
3695 .expect("receipt");
3696 SettledFileActivity {
3697 summary: FileActivitySummary {
3698 files_written: 1,
3699 ..FileActivitySummary::default()
3700 },
3701 write: vec!["src/public.rs".to_string()],
3702 mutations: vec![receipt],
3703 inline_diff_mode: mode,
3704 ..SettledFileActivity::default()
3705 }
3706 }
3707
3708 fn mutation_activity_body(mode: InlineDiffMode) -> (String, String, String) {
3709 let row = activity_rows(mutation_activity(mode))
3710 .into_iter()
3711 .next()
3712 .expect("activity row")
3713 .row;
3714 let SidebarRowAction::InspectWork { body, .. } =
3715 row.primary_action.expect("inspect action")
3716 else {
3717 panic!("write row must open Work inspection")
3718 };
3719 (row.label, row.detail, body)
3720 }
3721
3722 #[test]
3723 fn work_mutation_rows_keep_labels_privacy_and_all_inline_modes() {
3724 let (label, detail, full) = mutation_activity_body(InlineDiffMode::Full);
3725 assert_eq!(label, "Wrote 1 files");
3726 assert_eq!(detail, "Updated <external file> · +1 -1");
3727 assert!(full.contains("-old"), "{full}");
3728 assert!(full.contains("+new"), "{full}");
3729 assert!(!full.contains("alice"), "{full}");
3730 assert!(full.contains("exact change evidence"), "{full}");
3731
3732 let (_, _, summary) = mutation_activity_body(InlineDiffMode::Summary);
3733 assert!(
3734 summary.contains("Updated <external file> · +1 -1"),
3735 "{summary}"
3736 );
3737 assert!(!summary.contains("-old"), "{summary}");
3738 assert!(!summary.contains("+new"), "{summary}");
3739 assert!(!summary.contains("alice"), "{summary}");
3740
3741 let (_, detail, off) = mutation_activity_body(InlineDiffMode::Off);
3742 assert_eq!(detail, "Updated <external file>");
3743 assert!(off.contains("Updated <external file>"), "{off}");
3744 assert!(!off.contains("+1 -1"), "{off}");
3745 assert!(!off.contains("-old"), "{off}");
3746 assert!(!off.contains("alice"), "{off}");
3747 assert!(off.contains("exact change evidence"), "{off}");
3748 }
3749
3750 #[test]
3751 fn recent_only_summary_expires_after_ttl_and_user_turn() {
3752 let mut recent = operation(NodeState::Completed, "recent");
3753 recent.binding.as_mut().expect("binding").durable = true;
3754 let mut snapshot = WorkGraphSnapshot::new();
3755 snapshot.nodes = vec![recent];
3756
3757 let mut surface = surface();
3758 surface.set_presentation_now_ms(0);
3759 let rows = graph_rows(
3760 &mut surface,
3761 &snapshot,
3762 None,
3763 Vec::new(),
3764 None,
3765 SettledFileActivity::default(),
3766 );
3767 assert!(
3768 rows.iter().any(|row| row.id.0.starts_with("section:")),
3769 "recent-only should surface briefly: {rows:?}"
3770 );
3771
3772 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS);
3773 let expired = graph_rows(
3774 &mut surface,
3775 &snapshot,
3776 None,
3777 Vec::new(),
3778 None,
3779 SettledFileActivity::default(),
3780 );
3781 assert!(
3782 expired.is_empty(),
3783 "recent-only must collapse after TTL: {expired:?}"
3784 );
3785 // Catalog retains durable history for inspector/history.
3786 assert!(
3787 surface
3788 .catalog_rows
3789 .iter()
3790 .any(|row| row.label == "operation recent"),
3791 "catalog must keep recent work after live expiry"
3792 );
3793
3794 // New completion fingerprint re-surfaces once.
3795 let mut newer = operation(NodeState::Completed, "newer");
3796 newer.binding.as_mut().expect("binding").durable = true;
3797 snapshot.nodes.push(newer);
3798 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 10);
3799 let resurfaced = graph_rows(
3800 &mut surface,
3801 &snapshot,
3802 None,
3803 Vec::new(),
3804 None,
3805 SettledFileActivity::default(),
3806 );
3807 assert!(
3808 !resurfaced.is_empty(),
3809 "a new completion may surface once after expiry"
3810 );
3811
3812 // User turn hides immediately while still recent-only.
3813 surface.note_user_turn_or_new_operation();
3814 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 11);
3815 let after_turn = graph_rows(
3816 &mut surface,
3817 &snapshot,
3818 None,
3819 Vec::new(),
3820 None,
3821 SettledFileActivity::default(),
3822 );
3823 assert!(
3824 after_turn.is_empty(),
3825 "user turn must hide recent-only immediately: {after_turn:?}"
3826 );
3827 }
3828
3829 #[test]
3830 fn needs_input_and_ready_never_expire_with_clock() {
3831 let mut snapshot = WorkGraphSnapshot::new();
3832 snapshot.nodes = vec![
3833 operation(NodeState::Blocked, "blocked"),
3834 operation(NodeState::Ready, "ready"),
3835 ];
3836 let mut surface = surface();
3837 surface.set_presentation_now_ms(0);
3838 let _ = graph_rows(
3839 &mut surface,
3840 &snapshot,
3841 None,
3842 Vec::new(),
3843 None,
3844 SettledFileActivity::default(),
3845 );
3846 surface.set_presentation_now_ms(60_000);
3847 let rows = graph_rows(
3848 &mut surface,
3849 &snapshot,
3850 None,
3851 Vec::new(),
3852 None,
3853 SettledFileActivity::default(),
3854 );
3855 assert!(
3856 rows[0].label.starts_with("Work · Needs input:"),
3857 "{}",
3858 rows[0].label
3859 );
3860 assert!(rows.iter().any(|row| row.label == "operation blocked"));
3861 assert!(rows.iter().any(|row| row.label == "operation ready"));
3862 }
3863
3864 #[test]
3865 fn activity_receipts_aggregate_and_expire_without_raw_payloads() {
3866 let activity = SettledFileActivity {
3867 summary: FileActivitySummary {
3868 files_read: 1,
3869 patterns_searched: 2,
3870 files_written: 1,
3871 ..FileActivitySummary::default()
3872 },
3873 read: vec!["src/lib.rs".to_string()],
3874 search: vec!["(?i)super_secret_pattern_xyz".to_string()],
3875 write: vec!["src/main.rs".to_string()],
3876 ..SettledFileActivity::default()
3877 };
3878 let mut surface = surface();
3879 surface.set_presentation_now_ms(0);
3880 let rows = ordered_rows(&mut surface, None, None, Vec::new(), None, activity.clone());
3881 let activity_row = rows
3882 .iter()
3883 .find(|row| row.id.0 == "activity:aggregate")
3884 .expect("aggregate activity");
3885 assert!(activity_row.label.contains("Read 1 files"));
3886 assert!(activity_row.label.contains("Searched 2 patterns"));
3887 assert!(!activity_row.label.contains("super_secret_pattern_xyz"));
3888 assert!(!activity_row.detail.contains("super_secret_pattern_xyz"));
3889
3890 surface.set_presentation_now_ms(ACTIVITY_RECEIPT_TTL_MS);
3891 let expired = ordered_rows(&mut surface, None, None, Vec::new(), None, activity);
3892 assert!(
3893 expired.iter().all(|row| row.id.0 != "activity:aggregate"),
3894 "activity receipt must expire after TTL: {expired:?}"
3895 );
3896 }
3897
3898 #[test]
3899 fn summary_subject_prefers_attention_over_active_and_ready() {
3900 let mut snapshot = WorkGraphSnapshot::new();
3901 snapshot.nodes = vec![
3902 operation(NodeState::Active, "running"),
3903 operation(NodeState::Blocked, "choose a release target"),
3904 operation(NodeState::Ready, "review rebuilt binary"),
3905 ];
3906 let rows = graph_rows(
3907 &mut surface(),
3908 &snapshot,
3909 None,
3910 Vec::new(),
3911 None,
3912 SettledFileActivity::default(),
3913 );
3914 assert_eq!(
3915 rows[0].label,
3916 "Work · Needs input: operation choose a release target · 1 blocked"
3917 );
3918 }
3919
3920 fn running_shell_entry(id: &str, command: &str) -> crate::tui::app::TaskPanelEntry {
3921 crate::tui::app::TaskPanelEntry {
3922 id: id.to_string(),
3923 status: "running".to_string(),
3924 prompt_summary: format!("shell: {command}"),
3925 duration_ms: Some(42_000),
3926 kind: crate::tui::app::TaskPanelEntryKind::Background,
3927 stale: false,
3928 elapsed_since_output_ms: None,
3929 owner_agent_id: None,
3930 owner_agent_name: None,
3931 current_tool: None,
3932 role: None,
3933 files_touched: 0,
3934 }
3935 }
3936
3937 #[test]
3938 fn live_shells_are_first_class_background_rows() {
3939 let mut app = test_app();
3940 app.work_surface.placement = WorkSurfacePlacement::Top;
3941 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
3942 app.task_panel.push(running_shell_entry(
3943 "shell_a1b2c3d4",
3944 "cd /workspace/example-project",
3945 ));
3946 app.subagent_cache
3947 .push(running_agent("doc-scout-spec-arch"));
3948
3949 // Shells never crowd the agents or tasks views; they are the
3950 // background view, and the dock badges them there.
3951 let agents = visible_rows_for(&mut app, RailPanel::Agents);
3952 assert!(
3953 agents
3954 .iter()
3955 .any(|row| row.id.0 == "section:agents" && row.label.contains("Subagents")),
3956 "subagent visibility must not regress: {agents:?}"
3957 );
3958 assert!(!agents.iter().any(|row| row.id.0.starts_with("shell:")));
3959 let rows = visible_rows_for(&mut app, RailPanel::Background);
3960 let heading = rows
3961 .iter()
3962 .find(|row| row.id.0 == "section:shells")
3963 .expect("Shells group heading");
3964 assert_eq!(heading.label, "Shells 1");
3965 assert!(heading.selectable);
3966 let shell = rows
3967 .iter()
3968 .find(|row| row.id.0 == "shell:shell_a1b2c3d4")
3969 .expect("navigable shell row");
3970 assert_eq!(shell.label, "shell_a1b2c3d4");
3971 let Some(SidebarRowAction::InspectWork {
3972 title,
3973 body,
3974 stop_action,
3975 }) = shell.primary_action.as_ref()
3976 else {
3977 panic!(
3978 "shell row must open live output, got {:?}",
3979 shell.primary_action
3980 );
3981 };
3982 assert!(title.contains("shell_a1b2c3d4"), "{title}");
3983 assert!(body.contains("Job: shell_a1b2c3d4"), "{body}");
3984 assert!(body.contains("/jobs cancel shell_a1b2c3d4"), "{body}");
3985 assert!(matches!(
3986 stop_action.as_deref(),
3987 Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_a1b2c3d4"
3988 ));
3989 let facts = shell
3990 .agent
3991 .as_ref()
3992 .expect("shell uses the same row columns");
3993 assert_eq!(facts.role_label, "shell");
3994 assert_eq!(facts.status, "running");
3995 assert!(
3996 facts.objective.contains("example-project"),
3997 "{}",
3998 facts.objective
3999 );
4000 }
4001
4002 #[test]
4003 fn durable_tasks_are_not_promoted_as_shell_rows() {
4004 let mut app = test_app();
4005 app.work_surface.placement = WorkSurfacePlacement::Top;
4006 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
4007 app.task_panel.push(crate::tui::app::TaskPanelEntry {
4008 id: "run".to_string(),
4009 status: "running".to_string(),
4010 prompt_summary: "background confirmation test".to_string(),
4011 duration_ms: Some(99_000),
4012 kind: crate::tui::app::TaskPanelEntryKind::Background,
4013 stale: false,
4014 elapsed_since_output_ms: None,
4015 owner_agent_id: None,
4016 owner_agent_name: None,
4017 current_tool: None,
4018 role: None,
4019 files_touched: 0,
4020 });
4021 let rows = project_visible(&mut app);
4022 assert!(
4023 rows.iter()
4024 .all(|row| !row.id.0.starts_with("shell:") && row.id.0 != "section:shells"),
4025 "non-shell task_panel entries must not become Shells rows: {rows:?}"
4026 );
4027 }
4028 }
4029
4029 lines RUST