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