返回 CodeWhale
sidebar.rs
根目录 / crates / tui / src / tui / sidebar.rs
1 //! Sub-agent row projection shared by the agent details view and the
2 //! roster: display name, typed status word, and child progress per worker.
3 //!
4 //! This is what is left of the classic sidebar (Pinned / Activity / Agents /
5 //! Context line panels). Those panels were retired when every dock view moved
6 //! onto the work surface's row pipeline (2026-09-02); the projection below is
7 //! the part other surfaces still read.
8
9 use crate::tools::subagent::{AgentWorkerStatus, SubAgentStatus, localized_whale_display_names};
10
11 use super::app::{AgentCurrentActivityStatus, App};
12
13 #[derive(Debug, Clone, Default)]
14 pub struct SidebarAgentRow {
15 pub id: String,
16 pub parent_run_id: Option<String>,
17 pub name: String,
18 pub model: Option<String>,
19 pub status: String,
20 pub steps_taken: u32,
21 pub duration_ms: Option<u64>,
22 /// `(settled, total)` over this row's direct children, when it has any
23 /// (#5479). A fan-out parent's own status says nothing about whether the
24 /// work it launched is finished; this is the "5/6 agents done" fact the
25 /// rail otherwise makes you count by eye. `None` for a leaf.
26 pub children_settled: Option<(usize, usize)>,
27 }
28
29 /// The name a sub-agent was dispatched under, when it has one (#5287).
30 ///
31 /// `SubAgentResult::name` carries the session name, which the manager seeds
32 /// with the agent id and only replaces when the dispatch supplied a name. An
33 /// id is a lookup handle, never the identity an operator dispatched by, so it
34 /// is reported as absent here and the caller falls back to its own chain.
35 pub(crate) fn dispatched_agent_name(
36 agent: &crate::tools::subagent::SubAgentResult,
37 ) -> Option<&str> {
38 let name = agent.name.trim();
39 (!name.is_empty() && name != agent.agent_id).then_some(name)
40 }
41
42 pub(crate) fn sidebar_agent_rows(app: &App) -> Vec<SidebarAgentRow> {
43 let cached_ids: std::collections::HashSet<&str> = app
44 .subagent_cache
45 .iter()
46 .map(|agent| agent.agent_id.as_str())
47 .collect();
48 let display_names = localized_whale_display_names(
49 app.subagent_cache
50 .iter()
51 .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())),
52 app.ui_locale.tag(),
53 );
54 let mut rows: Vec<SidebarAgentRow> = app
55 .subagent_cache
56 .iter()
57 .map(|agent| {
58 let current_activity = app
59 .agent_progress_meta
60 .get(&agent.agent_id)
61 .and_then(|meta| meta.current_activity.as_ref());
62 // The dispatch name leads (#5287). Generated whales name the
63 // agents that have none, locale-derived from the neutral agent
64 // id; never replay a persisted label from another language.
65 let display_name = dispatched_agent_name(agent)
66 .map(str::to_string)
67 .or_else(|| {
68 agent
69 .child_route
70 .as_ref()
71 .and_then(|route| route.resolved_profile_id.as_deref())
72 .map(str::trim)
73 .filter(|profile| !profile.is_empty())
74 .map(str::to_string)
75 })
76 .or_else(|| display_names.get(&agent.agent_id).cloned())
77 .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned())
78 .unwrap_or_else(|| agent.name.clone());
79 SidebarAgentRow {
80 id: agent.agent_id.clone(),
81 parent_run_id: agent.parent_run_id.clone(),
82 name: display_name,
83 model: Some(agent.model.clone()).filter(|model| !model.trim().is_empty()),
84 status: current_activity
85 .map(|activity| {
86 sidebar_current_activity_status_text(activity.status, app.ui_locale)
87 })
88 .or_else(|| {
89 agent.worker_status.map(|status| {
90 std::borrow::Cow::Borrowed(sidebar_worker_status_text(status))
91 })
92 })
93 .unwrap_or_else(|| {
94 std::borrow::Cow::Borrowed(subagent_status_text(&agent.status))
95 })
96 .into_owned(),
97 steps_taken: agent.steps_taken,
98 duration_ms: Some(agent.duration_ms),
99 // Filled in by `annotate_child_progress` once every row exists.
100 children_settled: None,
101 }
102 })
103 .collect();
104
105 rows.extend(
106 app.agent_progress
107 .iter()
108 .filter(|(id, _)| !cached_ids.contains(id.as_str()))
109 .map(|(id, _progress)| {
110 // Progress-only rows do not carry a generated whale name yet;
111 // keep their existing stable Agent-N placeholder until the
112 // manager snapshot arrives.
113 let display_name = app
114 .agent_label_map
115 .get(id.as_str())
116 .cloned()
117 .unwrap_or_else(|| id.clone());
118 let meta = app.agent_progress_meta.get(id.as_str());
119 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
120 SidebarAgentRow {
121 id: id.clone(),
122 parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()),
123 name: display_name,
124 model: meta.and_then(|meta| meta.resolved_model.clone()),
125 status: current_activity
126 .map(|activity| {
127 sidebar_current_activity_status_text(activity.status, app.ui_locale)
128 })
129 .unwrap_or(std::borrow::Cow::Borrowed(sidebar_worker_status_text(
130 AgentWorkerStatus::Running,
131 )))
132 .into_owned(),
133 steps_taken: 0,
134 duration_ms: None,
135 children_settled: None,
136 }
137 }),
138 );
139
140 let mut rows = sort_sidebar_agent_rows_as_tree(rows);
141 annotate_child_progress(&mut rows);
142 rows
143 }
144
145 /// Fill in each row's `children_settled` from its direct children.
146 ///
147 /// Counted over the rows actually present: a child whose record has aged out of
148 /// the ledger cannot be counted, and inventing a denominator that included it
149 /// would misreport progress as worse than it is.
150 fn annotate_child_progress(rows: &mut [SidebarAgentRow]) {
151 let mut totals: std::collections::HashMap<String, (usize, usize)> =
152 std::collections::HashMap::new();
153 for row in rows.iter() {
154 let Some(parent) = row.parent_run_id.as_deref() else {
155 continue;
156 };
157 let entry = totals.entry(parent.to_string()).or_insert((0, 0));
158 entry.1 += 1;
159 if sidebar_agent_status_is_terminal(row.status.as_str()) {
160 entry.0 += 1;
161 }
162 }
163 for row in rows.iter_mut() {
164 row.children_settled = totals.get(&row.id).copied();
165 }
166 }
167
168 fn sort_sidebar_agent_rows_as_tree(rows: Vec<SidebarAgentRow>) -> Vec<SidebarAgentRow> {
169 let known_ids: std::collections::HashSet<String> =
170 rows.iter().map(|row| row.id.clone()).collect();
171 let mut children: std::collections::HashMap<String, Vec<usize>> =
172 std::collections::HashMap::new();
173 let mut roots = Vec::new();
174
175 for (idx, row) in rows.iter().enumerate() {
176 if let Some(parent) = row.parent_run_id.as_deref()
177 && known_ids.contains(parent)
178 {
179 children.entry(parent.to_string()).or_default().push(idx);
180 continue;
181 }
182 roots.push(idx);
183 }
184
185 fn push_tree(
186 idx: usize,
187 rows: &[SidebarAgentRow],
188 children: &std::collections::HashMap<String, Vec<usize>>,
189 seen: &mut std::collections::HashSet<usize>,
190 order: &mut Vec<usize>,
191 ) {
192 if !seen.insert(idx) {
193 return;
194 }
195 order.push(idx);
196 if let Some(child_indices) = children.get(&rows[idx].id) {
197 for child_idx in child_indices {
198 push_tree(*child_idx, rows, children, seen, order);
199 }
200 }
201 }
202
203 let mut order = Vec::with_capacity(rows.len());
204 let mut seen = std::collections::HashSet::new();
205 for idx in roots {
206 push_tree(idx, &rows, &children, &mut seen, &mut order);
207 }
208 for idx in 0..rows.len() {
209 push_tree(idx, &rows, &children, &mut seen, &mut order);
210 }
211
212 // Materialize by move instead of cloning each row a second time (#3898):
213 // `seen` guarantees every index lands in `order` exactly once, so each
214 // slot is taken exactly once and no row is dropped.
215 let mut slots: Vec<Option<SidebarAgentRow>> = rows.into_iter().map(Some).collect();
216 order
217 .into_iter()
218 .map(|idx| slots[idx].take().expect("each row emitted exactly once"))
219 .collect()
220 }
221
222 fn subagent_status_text(status: &SubAgentStatus) -> &'static str {
223 match status {
224 SubAgentStatus::Running => "running",
225 SubAgentStatus::Completed => "done",
226 SubAgentStatus::Interrupted(_) => "interrupted",
227 SubAgentStatus::Failed(_) => "failed",
228 SubAgentStatus::Cancelled => "canceled",
229 SubAgentStatus::BudgetExhausted => "budget",
230 }
231 }
232
233 fn sidebar_worker_status_text(status: AgentWorkerStatus) -> &'static str {
234 match status {
235 AgentWorkerStatus::Queued => "queued",
236 AgentWorkerStatus::Starting => "starting",
237 AgentWorkerStatus::Running => "running",
238 AgentWorkerStatus::WaitingForUser => "waiting",
239 AgentWorkerStatus::ModelWait => "model wait",
240 AgentWorkerStatus::RunningTool => "tool",
241 AgentWorkerStatus::Completed => "done",
242 AgentWorkerStatus::Failed => "failed",
243 AgentWorkerStatus::Cancelled => "canceled",
244 AgentWorkerStatus::Interrupted => "interrupted",
245 }
246 }
247
248 fn sidebar_current_activity_status_text(
249 status: AgentCurrentActivityStatus,
250 locale: codewhale_localization::Locale,
251 ) -> std::borrow::Cow<'static, str> {
252 // A parked husk gets its own word, translated (#5906) — "waiting" here
253 // would be the same lie the work surface used to tell.
254 if status == AgentCurrentActivityStatus::Parked {
255 return codewhale_localization::tr(
256 locale,
257 codewhale_localization::MessageId::AgentStatusParked,
258 );
259 }
260 std::borrow::Cow::Borrowed(match status {
261 AgentCurrentActivityStatus::Queued => "queued",
262 AgentCurrentActivityStatus::Starting => "starting",
263 AgentCurrentActivityStatus::Running => "running",
264 AgentCurrentActivityStatus::ModelWait => "model wait",
265 AgentCurrentActivityStatus::RunningTool => "tool",
266 AgentCurrentActivityStatus::Waiting => "waiting",
267 AgentCurrentActivityStatus::Done => "done",
268 AgentCurrentActivityStatus::Failed => "failed",
269 AgentCurrentActivityStatus::Canceled => "canceled",
270 AgentCurrentActivityStatus::Interrupted => "interrupted",
271 AgentCurrentActivityStatus::Parked => unreachable!("handled above"),
272 })
273 }
274
275 fn sidebar_agent_status_is_terminal(status: &str) -> bool {
276 matches!(
277 status,
278 "done" | "canceled" | "failed" | "interrupted" | "budget"
279 )
280 }
281
282 #[cfg(test)]
283 mod tests {
284 use super::sidebar_agent_rows;
285 use crate::config::Config;
286 use crate::tui::app::{
287 AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, App,
288 SidebarHoverSection, SidebarHoverState, TuiOptions,
289 };
290 use codewhale_localization::Locale;
291 use std::path::PathBuf;
292
293 fn create_test_app() -> App {
294 let options = TuiOptions {
295 ..crate::test_support::test_tui_options(PathBuf::from("."))
296 };
297 let mut app = App::new(options, &Config::default());
298 // Legacy strip geometry (see ui.rs); Bottom default has its own tests.
299 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
300 app
301 }
302
303 // ---- Sidebar hover tooltip tests ----
304
305 #[test]
306 fn sidebar_hover_state_default_is_empty() {
307 let state = SidebarHoverState::default();
308 assert!(state.sections.is_empty());
309 }
310
311 #[test]
312 fn sidebar_hover_section_stores_lines() {
313 use ratatui::layout::Rect;
314 let section = SidebarHoverSection {
315 content_area: Rect::new(1, 1, 38, 8),
316 lines: vec!["line 1".to_string(), "line 2".to_string()],
317 rows: vec![],
318 };
319 assert_eq!(section.lines.len(), 2);
320 assert_eq!(section.lines[0], "line 1");
321 assert!(section.content_area.x > 0);
322 }
323
324 #[test]
325 fn hover_line_matching_respects_content_area_offset() {
326 use ratatui::layout::Rect;
327 let section = SidebarHoverSection {
328 content_area: Rect::new(62, 2, 36, 6),
329 lines: vec![
330 "first".to_string(),
331 "second".to_string(),
332 "third".to_string(),
333 ],
334 rows: vec![],
335 };
336
337 // Mouse within content area, first line
338 let line_idx = (2u16.saturating_sub(section.content_area.y)) as usize;
339 assert_eq!(section.lines[line_idx], "first");
340
341 // Mouse within content area, second line
342 let line_idx = (3u16.saturating_sub(section.content_area.y)) as usize;
343 assert_eq!(section.lines[line_idx], "second");
344
345 // Mouse outside content area (above) — row < content_area.y
346 assert!((1u16) < section.content_area.y);
347 }
348
349 // ── #3030: stable labels instead of raw internal ids ───────────────────
350
351 #[test]
352 fn ensure_agent_label_assigns_stable_sequential_labels() {
353 let mut app = create_test_app();
354 assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1");
355 assert_eq!(app.ensure_agent_label("agent_bbb222"), "Agent 2");
356 // Re-seeing a known agent keeps its original label.
357 assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1");
358 assert_eq!(app.agent_counter, 2);
359 // Read-only lookup falls back to the raw id for unknown agents.
360 assert_eq!(app.agent_display_label("agent_bbb222"), "Agent 2");
361 assert_eq!(app.agent_display_label("agent_zzz999"), "agent_zzz999");
362 }
363
364 #[test]
365 fn ensure_agent_label_prefers_identity_over_the_counter() {
366 let mut app = create_test_app();
367 let route = |profile: Option<&str>, role: &str| {
368 Some(crate::tools::subagent::ChildRouteReceipt {
369 requested_type: "custom".to_string(),
370 requested_profile: profile.map(str::to_string),
371 resolved_profile_id: None,
372 profile_origin: None,
373 canonical_role: role.to_string(),
374 provider_id: "deepseek".to_string(),
375 model_id: "deepseek-v4-pro".to_string(),
376 route_source: "roster".to_string(),
377 fallback_note: None,
378 requested_reasoning: "inherit".to_string(),
379 effective_reasoning: None,
380 runtime_version: "test".to_string(),
381 runtime_build_sha: "unknown".to_string(),
382 })
383 };
384
385 let mut named = cached_agent("agent_named", None);
386 named.name = "branch-triage".to_string();
387 app.subagent_cache.push(named);
388
389 let mut role = cached_agent("agent_role", None);
390 role.assignment.role = Some("reviewer".to_string());
391 app.subagent_cache.push(role);
392
393 let mut profile = cached_agent("agent_profile", None);
394 profile.assignment.role = None;
395 profile.child_route = route(Some("release-lead"), "custom");
396 app.subagent_cache.push(profile);
397
398 let mut canonical = cached_agent("agent_canonical", None);
399 canonical.assignment.role = None;
400 canonical.child_route = route(None, "planner");
401 app.subagent_cache.push(canonical);
402
403 let mut typed = cached_agent("agent_typed", None);
404 typed.assignment.role = None;
405 typed.agent_type = crate::tools::subagent::FleetRole::Builder;
406 app.subagent_cache.push(typed);
407
408 // The dispatch name leads, annotated with the role when the role is
409 // not already part of the name.
410 assert_eq!(
411 app.ensure_agent_label("agent_named"),
412 "branch-triage · general"
413 );
414 // Unnamed children are disambiguated per role (each role's counter
415 // starts at 1).
416 assert_eq!(app.ensure_agent_label("agent_role"), "reviewer · 1");
417 assert_eq!(app.ensure_agent_label("agent_profile"), "release-lead · 1");
418 assert_eq!(app.ensure_agent_label("agent_canonical"), "planner · 1");
419 assert_eq!(app.ensure_agent_label("agent_typed"), "implement · 1");
420
421 // A progress-only agent first seen before its metadata arrives gets a
422 // counter placeholder, then upgrades once the identity is observed.
423 assert_eq!(app.ensure_agent_label("agent_late"), "Agent 1");
424 let mut late = cached_agent("agent_late", None);
425 late.assignment.role = Some("verifier".to_string());
426 app.subagent_cache.push(late);
427 assert_eq!(app.ensure_agent_label("agent_late"), "test · 1");
428 }
429
430 #[test]
431 fn ensure_agent_label_disambiguates_concurrent_same_role_children() {
432 let mut app = create_test_app();
433
434 let mut first = cached_agent("agent_builder_a", None);
435 first.assignment.role = None;
436 first.agent_type = crate::tools::subagent::FleetRole::Builder;
437 app.subagent_cache.push(first);
438
439 let mut second = cached_agent("agent_builder_b", None);
440 second.assignment.role = None;
441 second.agent_type = crate::tools::subagent::FleetRole::Builder;
442 app.subagent_cache.push(second);
443
444 assert_eq!(app.ensure_agent_label("agent_builder_a"), "implement · 1");
445 assert_eq!(app.ensure_agent_label("agent_builder_b"), "implement · 2");
446 // Stability: re-seeing a known builder keeps its assigned label.
447 assert_eq!(app.ensure_agent_label("agent_builder_a"), "implement · 1");
448 assert_eq!(app.ensure_agent_label("agent_builder_b"), "implement · 2");
449
450 // A different role has its own sequence.
451 let mut reviewer = cached_agent("agent_reviewer_a", None);
452 reviewer.assignment.role = Some("reviewer".to_string());
453 app.subagent_cache.push(reviewer);
454 assert_eq!(app.ensure_agent_label("agent_reviewer_a"), "reviewer · 1");
455 }
456
457 #[test]
458 fn ensure_agent_label_named_child_skips_role_suffix_when_present() {
459 let mut app = create_test_app();
460
461 let mut named = cached_agent("agent_named", None);
462 named.name = "release-lead".to_string();
463 named.assignment.role = None;
464 named.child_route = Some(crate::tools::subagent::ChildRouteReceipt {
465 requested_type: "custom".to_string(),
466 requested_profile: Some("release-lead".to_string()),
467 resolved_profile_id: None,
468 profile_origin: None,
469 canonical_role: "release-lead".to_string(),
470 provider_id: "deepseek".to_string(),
471 model_id: "deepseek-v4-pro".to_string(),
472 route_source: "roster".to_string(),
473 fallback_note: None,
474 requested_reasoning: "inherit".to_string(),
475 effective_reasoning: None,
476 runtime_version: "test".to_string(),
477 runtime_build_sha: "unknown".to_string(),
478 });
479 app.subagent_cache.push(named);
480
481 // The role is already part of the name, so no duplicate suffix.
482 assert_eq!(app.ensure_agent_label("agent_named"), "release-lead");
483 }
484
485 fn cached_agent(
486 agent_id: &str,
487 nickname: Option<&str>,
488 ) -> crate::tools::subagent::SubAgentResult {
489 crate::tools::subagent::SubAgentResult {
490 usage: None,
491 // An unnamed dispatch: the manager seeds `name` with the agent id
492 // and only replaces it when the caller supplied one.
493 name: agent_id.to_string(),
494 agent_id: agent_id.to_string(),
495 context_mode: "fresh".to_string(),
496 fork_context: false,
497 workspace: None,
498 git_branch: None,
499 agent_type: crate::tools::subagent::FleetRole::Worker,
500 assignment: crate::tools::subagent::SubAgentAssignment {
501 objective: "task".to_string(),
502 role: Some("worker".to_string()),
503 },
504 model: String::new(),
505 nickname: nickname.map(str::to_string),
506 status: crate::tools::subagent::SubAgentStatus::Running,
507 worker_status: None,
508 runtime_permissions: None,
509 parent_run_id: None,
510 spawn_depth: 0,
511 child_route: None,
512 result: None,
513 steps_taken: 1,
514 checkpoint: None,
515 needs_input: None,
516 duration_ms: 100,
517 started_at: None,
518 from_prior_session: false,
519 }
520 }
521
522 // === #5479: a fan-out parent shows how much of its fan-out is done ===
523
524 #[test]
525 fn a_fanout_parent_row_reports_how_many_children_have_settled() {
526 let mut app = create_test_app();
527 let parent = cached_agent("workflow_parent", None);
528 for index in 0..6 {
529 let mut child = cached_agent(&format!("child_{index}"), None);
530 child.parent_run_id = Some("workflow_parent".to_string());
531 child.spawn_depth = 1;
532 if index < 5 {
533 child.status = crate::tools::subagent::SubAgentStatus::Completed;
534 child.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::Completed);
535 }
536 app.subagent_cache.push(child);
537 }
538 app.subagent_cache.push(parent);
539
540 let rows = sidebar_agent_rows(&app);
541 let parent_row = rows
542 .iter()
543 .find(|row| row.id == "workflow_parent")
544 .expect("parent row");
545 assert_eq!(
546 parent_row.children_settled,
547 Some((5, 6)),
548 "the parent's own status says nothing about its fan-out"
549 );
550 for row in rows.iter().filter(|row| row.id != "workflow_parent") {
551 assert_eq!(
552 row.children_settled, None,
553 "a leaf must not claim a fan-out it does not have"
554 );
555 }
556 }
557
558 #[test]
559 fn a_parent_whose_children_aged_out_reports_no_progress_rather_than_zero() {
560 // A denominator that counted rows no longer in the ledger would report
561 // progress as worse than it is.
562 let mut app = create_test_app();
563 app.subagent_cache.push(cached_agent("lonely_parent", None));
564 let rows = sidebar_agent_rows(&app);
565 assert_eq!(rows[0].children_settled, None);
566 }
567
568 #[test]
569 fn sidebar_agent_rows_use_worker_status_from_cached_agents() {
570 let mut app = create_test_app();
571 let mut agent = cached_agent("agent_model_wait", Some("Blue"));
572 agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::ModelWait);
573 app.subagent_cache.push(agent);
574
575 let rows = sidebar_agent_rows(&app);
576
577 assert_eq!(rows.len(), 1);
578 assert_eq!(rows[0].status, "model wait");
579 }
580
581 #[test]
582 fn sidebar_agent_rows_project_typed_lifecycle_fixtures() {
583 let mut app = create_test_app();
584 let fixtures = [
585 (
586 "agent_running",
587 "Running",
588 crate::tools::subagent::SubAgentStatus::Running,
589 crate::tools::subagent::AgentWorkerStatus::RunningTool,
590 AgentCurrentActivityStatus::RunningTool,
591 "tool",
592 ),
593 (
594 "agent_waiting",
595 "Waiting",
596 crate::tools::subagent::SubAgentStatus::Interrupted("approval".to_string()),
597 crate::tools::subagent::AgentWorkerStatus::WaitingForUser,
598 AgentCurrentActivityStatus::Waiting,
599 "waiting",
600 ),
601 (
602 "agent_failed",
603 "Failed",
604 crate::tools::subagent::SubAgentStatus::Failed("verification".to_string()),
605 crate::tools::subagent::AgentWorkerStatus::Failed,
606 AgentCurrentActivityStatus::Failed,
607 "failed",
608 ),
609 (
610 "agent_done",
611 "Done",
612 crate::tools::subagent::SubAgentStatus::Completed,
613 crate::tools::subagent::AgentWorkerStatus::Completed,
614 AgentCurrentActivityStatus::Done,
615 "done",
616 ),
617 ];
618 for (id, nickname, status, worker_status, activity_status, _) in &fixtures {
619 let mut agent = cached_agent(id, Some(nickname));
620 agent.status = status.clone();
621 agent.worker_status = Some(*worker_status);
622 app.subagent_cache.push(agent);
623 app.agent_progress_meta.insert(
624 (*id).to_string(),
625 AgentProgressMeta {
626 current_activity: Some(AgentCurrentActivity::bounded(
627 *activity_status,
628 (*id == "agent_waiting").then_some("approval required".to_string()),
629 (*id == "agent_running").then_some("read_file".to_string()),
630 Some(2),
631 )),
632 ..AgentProgressMeta::default()
633 },
634 );
635 }
636
637 let rows = sidebar_agent_rows(&app);
638 for (id, _, _, _, _, expected_status) in fixtures {
639 let row = rows
640 .iter()
641 .find(|row| row.id == id)
642 .expect("typed lifecycle row");
643 assert_eq!(row.status, expected_status);
644 }
645 }
646
647 #[test]
648 fn sidebar_progress_only_rows_never_infer_status_from_display_text() {
649 let mut app = create_test_app();
650 app.ensure_agent_label("agent_queued");
651 app.agent_progress.insert(
652 "agent_queued".to_string(),
653 "queued waiting failed completed".to_string(),
654 );
655
656 let rows = sidebar_agent_rows(&app);
657
658 assert_eq!(rows.len(), 1);
659 assert_eq!(rows[0].name, "Agent 1");
660 assert_eq!(rows[0].status, "running");
661
662 app.agent_progress_meta.insert(
663 "agent_queued".to_string(),
664 AgentProgressMeta {
665 current_activity: Some(AgentCurrentActivity::bounded(
666 AgentCurrentActivityStatus::Queued,
667 Some("waiting for launch permit".to_string()),
668 None,
669 None,
670 )),
671 ..AgentProgressMeta::default()
672 },
673 );
674 crate::tui::ui::record_agent_spawned_route(&mut app, "agent_queued", "deepseek-v4-pro");
675 let rows = sidebar_agent_rows(&app);
676 assert_eq!(rows[0].status, "queued");
677 assert_eq!(rows[0].model.as_deref(), Some("deepseek-v4-pro"));
678 }
679
680 #[test]
681 fn sidebar_agent_rows_preserve_explicit_names_and_derive_whales_from_locale() {
682 let mut app = create_test_app();
683 let agent_id = "agent_cafe0123";
684 app.ensure_agent_label(agent_id);
685 app.subagent_cache
686 .push(cached_agent(agent_id, Some("doc-fixer")));
687
688 let rows = super::sidebar_agent_rows(&app);
689 assert_eq!(
690 rows[0].name, "doc-fixer",
691 "an explicit custom nickname remains user-owned"
692 );
693
694 // Without an explicit nickname, display is derived from the neutral id
695 // in the active UI locale rather than from the old Agent-N label.
696 app.subagent_cache[0].nickname = None;
697 let rows = super::sidebar_agent_rows(&app);
698 assert_eq!(
699 rows[0].name,
700 crate::tools::subagent::whale_name_for_id_in_locale(agent_id, "en")
701 );
702 }
703
704 #[test]
705 fn sidebar_agent_rows_lead_with_the_dispatch_name() {
706 // #5287: operators dispatch by name and think by name, so the session
707 // name outranks both the generated whale and the Agent-N label.
708 let mut app = create_test_app();
709 let agent_id = "agent_cafe0123";
710 app.ensure_agent_label(agent_id);
711 let mut agent = cached_agent(agent_id, Some("Blue Whale"));
712 agent.name = "branch-triage".to_string();
713 app.subagent_cache.push(agent);
714
715 let rows = super::sidebar_agent_rows(&app);
716 assert_eq!(rows[0].name, "branch-triage");
717 }
718
719 #[test]
720 fn sidebar_agent_rows_prefer_resolved_profile_over_generated_whale() {
721 let mut app = create_test_app();
722 let agent_id = "agent_cafe0123";
723 app.ensure_agent_label(agent_id);
724 let mut agent = cached_agent(agent_id, Some("Blue Whale"));
725 agent.child_route = Some(crate::tools::subagent::ChildRouteReceipt {
726 requested_type: "custom".to_string(),
727 requested_profile: Some("DeepSeek V4 Flash".to_string()),
728 resolved_profile_id: Some("flash-scout".to_string()),
729 profile_origin: Some("fleet:release".to_string()),
730 canonical_role: "scout".to_string(),
731 provider_id: "deepseek".to_string(),
732 model_id: "deepseek-v4-flash-vision-exp".to_string(),
733 route_source: "fleet".to_string(),
734 fallback_note: None,
735 requested_reasoning: "inherit".to_string(),
736 effective_reasoning: None,
737 runtime_version: "test".to_string(),
738 runtime_build_sha: "unknown".to_string(),
739 });
740 app.subagent_cache.push(agent);
741
742 let rows = super::sidebar_agent_rows(&app);
743 assert_eq!(rows[0].name, "flash-scout");
744 }
745
746 #[test]
747 fn english_sidebar_relocalizes_mixed_persisted_whale_names() {
748 let mut app = create_test_app();
749 app.ui_locale = Locale::En;
750 for (agent_id, legacy_locale) in [
751 ("agent_locale_a", "zh-Hans"),
752 ("agent_locale_b", "ja"),
753 ("agent_locale_c", "vi"),
754 ] {
755 let legacy_name =
756 crate::tools::subagent::whale_name_for_id_in_locale(agent_id, legacy_locale);
757 app.subagent_cache
758 .push(cached_agent(agent_id, Some(&legacy_name)));
759 }
760
761 let rows = super::sidebar_agent_rows(&app);
762 assert_eq!(rows.len(), 3);
763 for row in rows {
764 assert!(
765 row.name.is_ascii(),
766 "English Fleet display leaked a prior-locale whale: {}",
767 row.name
768 );
769 assert_eq!(
770 row.name,
771 crate::tools::subagent::whale_name_for_id_in_locale(&row.id, "en")
772 );
773 }
774 }
775
776 // === #5906: parked husks vs. children that actually asked ============
777
778 /// A child parked at the parent's turn end is handed a `needs_input` note
779 /// phrased as a question ("Resume this parked child with ..."), which is
780 /// why every surface used to label it `waiting`. Build one exactly the way
781 /// the runtime does and assert the row names the state instead.
782 fn parked_agent(agent_id: &str) -> crate::tools::subagent::SubAgentResult {
783 let mut agent = cached_agent(agent_id, None);
784 agent.status = crate::tools::subagent::SubAgentStatus::Interrupted(
785 "Parent turn ended before this turn-owned child settled.".to_string(),
786 );
787 agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::WaitingForUser);
788 agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput {
789 question: format!(
790 "Resume this parked child with agent(action=\"start\", resume_from=\"{agent_id}\")."
791 ),
792 });
793 agent.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint {
794 checkpoint_id: format!("{agent_id}:step:2"),
795 agent_id: agent_id.to_string(),
796 continuation_handle: format!("agent:{agent_id}:checkpoint"),
797 reason: "Parent turn ended before this turn-owned child settled.".to_string(),
798 continuable: true,
799 steps_taken: 2,
800 message_count: 4,
801 created_at_ms: 1_000,
802 messages: Vec::new(),
803 omitted_messages: 0,
804 parked_at_turn_end: true,
805 });
806 agent
807 }
808
809 fn asking_agent(agent_id: &str) -> crate::tools::subagent::SubAgentResult {
810 let mut agent = cached_agent(agent_id, None);
811 agent.worker_status = Some(crate::tools::subagent::AgentWorkerStatus::WaitingForUser);
812 agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput {
813 question: "Which path should I use?".to_string(),
814 });
815 agent
816 }
817
818 #[test]
819 fn a_parked_row_says_parked_and_a_real_question_still_says_waiting() {
820 let mut app = create_test_app();
821 app.subagent_cache.push(parked_agent("agent_parked"));
822 app.subagent_cache.push(asking_agent("agent_asking"));
823 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
824
825 let rows = sidebar_agent_rows(&app);
826 let parked = rows
827 .iter()
828 .find(|row| row.id == "agent_parked")
829 .expect("parked row");
830 let asking = rows
831 .iter()
832 .find(|row| row.id == "agent_asking")
833 .expect("asking row");
834
835 assert_eq!(parked.status, "parked");
836 assert_ne!(
837 parked.status, "waiting",
838 "a parked husk must not wear the label a child a user can answer wears"
839 );
840 assert_eq!(asking.status, "waiting");
841 }
842
843 /// The status word is registry copy, not a hardcoded English literal.
844 #[test]
845 fn the_parked_status_word_follows_the_ui_locale() {
846 let mut app = create_test_app();
847 app.ui_locale = Locale::De;
848 app.subagent_cache.push(parked_agent("agent_parked_de"));
849 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
850
851 let rows = sidebar_agent_rows(&app);
852 assert_eq!(
853 rows[0].status,
854 codewhale_localization::tr(
855 Locale::De,
856 codewhale_localization::MessageId::AgentStatusParked
857 )
858 );
859 }
860
861 // --- Unicode / CJK / terminal-width QA (issue #3488) -------------------
862 // The sub-agent overlay renders CJK display names next to ASCII ids,
863 // numeric columns (step count, elapsed), status verbs, and branch lines.
864 // These guard that a CJK name never shifts the status columns, corrupts the
865 // panel border, or hides the running/completed state (#3488 dogfood case:
866 // a worker named 抹香鲸).
867 }
868
868 lines RUST