//! Dashboard rows: classification, build, filter, sort. use super::state::{DashboardRowId, Filter, RowState}; use crate::acp::tracker::TurnActivity; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; use crate::app::roster::{RosterActivity, RosterEntry}; use crate::app::subagent::{SubagentInfo, format_activity_label, format_subagent_label}; use indexmap::IndexMap; use std::path::PathBuf; use std::time::{Instant, SystemTime}; /// Title prefix for a session that has no name / generated title / prompt /// yet. The renderer paints this part in the primary colour and the trailing /// ` #` suffix in dim gray (see `render::render_row`). pub(crate) const NEW_SESSION_LABEL: &str = "New session"; /// A single row in the dashboard. Built per-frame from `app.agents`. #[derive(Debug, Clone)] pub struct DashboardRow { pub id: DashboardRowId, /// Display label (e.g. `"implementer · fix login bug"`). pub label: String, /// Right-of-label subtitle painted after a ` · ` separator in /// dim text (e.g. `"xai my-branch-2 worktree"`). `None` when the /// row has no repo / branch context worth surfacing. pub subtitle: Option, /// Coarse state used for grouping. pub state: RowState, /// Activity summary (e.g. `"Running: cargo test"`). `None` when /// idle or finished. pub activity: Option, /// Secondary dim line painted directly below the title row — /// holds the last tool call, the last assistant message, or /// (for `NeedsInput`) a "Pending: …" preview of the front-most /// permission request. `None` collapses the row to a single /// line (e.g. very recently created with no activity yet). pub secondary_line: Option, /// Working directory display string (already compacted to `~/...`). pub cwd_display: String, /// Raw cwd for downstream filter / grouping consumers. pub cwd: PathBuf, /// Wall-clock moment of the row's last change — drives the age column and /// is the recency tiebreaker for sort (more recent floats higher in its /// group). /// /// Wall-clock [`SystemTime`], not a monotonic [`Instant`]: roster /// timestamps can predate this process — even the machine's boot — which /// an `Instant` cannot represent (its floor is system boot, so an older /// moment underflows and collapses back to "just now"). Local rows project /// their live `Instant` anchors onto the wall clock via /// [`crate::util::system_time_from_instant`]. pub last_change_at: SystemTime, /// True when this row is pinned (always floats above non-pinned). pub pinned: bool, /// True when this row is currently the active view target (so the /// renderer can highlight it). Reserved — wired through but /// always `false` in v1; populated when we eventually support /// "the agent you came from" highlighting from the dashboard. #[allow(dead_code)] pub is_active: bool, /// Optional row badges (e.g. `worktree`, `needs-input`). pub badges: Vec, /// Context window usage percent, when known. Drives the mini gauge. pub context_pct: Option, /// Indent level for nested subagent rendering. 0 = top-level, /// 1 = subagent under its parent. pub indent: u8, /// Display title for the parent agent — used to scope group /// headers in `Grouping::Directory` mode where subagents must /// sort with their parent. pub parent_label: Option, /// True when this row is a "… N more" collapse placeholder. The /// renderer paints it dimmed and the cursor cannot land on it /// (selectable=false). pub is_more_placeholder: bool, /// Number of rolled-up rows when `is_more_placeholder == true`. pub more_count: usize, } /// Compact badge rendered next to the label. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RowBadge { Worktree, NeedsInput, BgTask, Pinned, Failed, } impl RowBadge { pub fn label(self) -> &'static str { match self { Self::Worktree => "worktree", Self::NeedsInput => "needs-input", Self::BgTask => "bg", Self::Pinned => "pinned", Self::Failed => "failed", } } } /// Maximum number of subagents to show per parent before collapsing /// completed/failed ones into a "… N more" row (edge case 6). pub const MAX_VISIBLE_SUBAGENTS: usize = 8; /// Process-wide fallback anchor for the age column when an agent /// somehow lacks both `turn_started_at` AND `last_active_at` (rare — /// `AgentView::new` always stamps the latter, but a hypothetical /// future caller could omit it). Initialised lazily on first call so /// the fallback is genuinely frozen rather than re-anchoring every /// frame. fn fallback_epoch() -> Instant { static FALLBACK: std::sync::OnceLock = std::sync::OnceLock::new(); *FALLBACK.get_or_init(Instant::now) } /// Build the dashboard row list from the live `app.agents` map. /// /// Iterates `agents` so a stale `AgentId` can never panic (edge case 2). /// Subagents are appended directly after their parent — never reordered /// across parents — and a `… N more` collapse placeholder appears at /// the parent's scope once more than [`MAX_VISIBLE_SUBAGENTS`] rows /// exist (edge case 6). pub fn build_rows( agents: &IndexMap, pinned: &std::collections::BTreeSet, reorder: &[DashboardRowId], active: Option, grouping: super::state::Grouping, filter: &Filter, home: Option<&str>, ) -> Vec { let mut rows = build_local_rows(agents, pinned, active, home, true); apply_filter(&mut rows, filter, home); sort_rows(&mut rows, grouping, reorder); rows } /// Build the row list shown by the live dashboard (rendering in /// [`super::render::render_dashboard`] and keyboard navigation in /// `dispatch_dashboard_select` both go through here). /// /// Like [`build_rows`] but (a) appends "roster-only" rows for leader /// sessions this client is NOT locally attached to (FleetView dashboard) /// and (b) does NOT surface subagents as their own rows — only top-level /// agents and roster sessions are listed (see [`build_local_rows`]). /// /// Local `AgentView` rows are built first (richest data). Then, for each /// [`RosterEntry`] whose `session_id` is not already represented by a /// local agent, a synthetic [`DashboardRow`] is appended. In non-leader /// mode `roster` is empty. #[allow(clippy::too_many_arguments)] pub fn build_rows_with_roster( agents: &IndexMap, pinned: &std::collections::BTreeSet, reorder: &[DashboardRowId], active: Option, grouping: super::state::Grouping, filter: &Filter, home: Option<&str>, roster: &[RosterEntry], ) -> Vec { let mut rows = build_local_rows(agents, pinned, active, home, false); append_roster_rows(&mut rows, roster, agents, pinned, home); apply_filter(&mut rows, filter, home); sort_rows(&mut rows, grouping, reorder); rows } /// Build the local-agent rows WITHOUT applying filter or sort. Shared by /// [`build_rows`] and [`build_rows_with_roster`]. /// /// `include_subagents` controls whether each parent's subagent sessions are /// appended as nested rows. The live dashboard passes `false` (subagents are /// not surfaced as their own rows — see [`build_rows_with_roster`]); callers /// that want the full tree (and the row-building unit tests) pass `true` via /// [`build_rows`]. fn build_local_rows( agents: &IndexMap, pinned: &std::collections::BTreeSet, active: Option, home: Option<&str>, include_subagents: bool, ) -> Vec { let mut rows = Vec::new(); for (id, agent) in agents.iter() { let top_id = DashboardRowId::TopLevel(*id); if is_empty_top_level(agent) && !pinned.contains(&top_id) && !matches!( classify_top_level(agent), RowState::Working | RowState::NeedsInput ) { continue; } let row = top_level_row( *id, agent, pinned.contains(&top_id), active == Some(*id), home, ); rows.push(row); if !include_subagents { continue; } let mut subagents: Vec<&SubagentInfo> = agent.subagent_sessions.values().collect(); subagents.sort_by(|a, b| { let a_running = !a.finished; let b_running = !b.finished; match (a_running, b_running) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => b.started_at.cmp(&a.started_at), } }); let total = subagents.len(); let keep = MAX_VISIBLE_SUBAGENTS.min(total); for info in subagents.iter().take(keep) { rows.push(subagent_row( *id, agent, info, pinned.contains(&DashboardRowId::Subagent { parent: *id, child_session_id: info.child_session_id.to_string(), }), home, )); } if total > keep { let agent_label = top_level_label(agent); rows.push(DashboardRow { id: DashboardRowId::Subagent { parent: *id, child_session_id: format!("__more_{}", id.0), }, label: format!("\u{2026} {} more", total - keep), subtitle: None, state: RowState::Idle, activity: None, secondary_line: None, cwd_display: String::new(), cwd: agent.session.cwd.clone(), last_change_at: SystemTime::now(), pinned: false, is_active: false, badges: Vec::new(), context_pct: None, indent: 1, parent_label: Some(agent_label), is_more_placeholder: true, more_count: total - keep, }); } } rows } /// Map a leader [`RosterActivity`] to the dashboard's coarse [`RowState`]. fn roster_activity_to_state(activity: RosterActivity) -> RowState { match activity { RosterActivity::Working => RowState::Working, RosterActivity::NeedsInput => RowState::NeedsInput, RosterActivity::Idle | RosterActivity::Dormant => RowState::Inactive, RosterActivity::Completed => RowState::Completed, RosterActivity::Dead => RowState::Failed, } } /// Append "roster-only" rows for leader sessions not represented by a /// local `AgentView`. Skips any roster entry whose `session_id` already /// matches a locally-attached agent (those carry richer data via /// [`build_local_rows`]). fn append_roster_rows( rows: &mut Vec, roster: &[RosterEntry], agents: &IndexMap, pinned: &std::collections::BTreeSet, home: Option<&str>, ) { if roster.is_empty() { return; } let local_ids: std::collections::HashSet<&str> = agents .values() .filter_map(|a| a.session.session_id.as_ref().map(|s| s.0.as_ref())) .collect(); for entry in roster { if local_ids.contains(entry.session_id.as_str()) { continue; } let id = DashboardRowId::Roster { session_id: entry.session_id.clone(), }; let has_title = entry .title .as_deref() .map(str::trim) .is_some_and(|t| !t.is_empty()); let active = matches!( entry.activity, RosterActivity::Working | RosterActivity::NeedsInput ); if !has_title && !active && !pinned.contains(&id) { continue; } let cwd = PathBuf::from(&entry.cwd); let label = entry .title .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(sanitize) .or_else(|| { cwd.file_name() .and_then(|n| n.to_str()) .filter(|s| !s.is_empty()) .map(sanitize) }) .unwrap_or_else(|| sanitize(&entry.session_id)); let state = roster_activity_to_state(entry.activity); let activity = match state { RowState::NeedsInput => Some("Awaiting input".to_string()), RowState::Working => Some("Working".to_string()), _ => None, }; let mut badges = Vec::new(); if entry.is_worktree { badges.push(RowBadge::Worktree); } if entry.activity == RosterActivity::NeedsInput { badges.push(RowBadge::NeedsInput); } let cwd_display = super::state::compact_cwd(&cwd, home); let is_pinned = pinned.contains(&id); rows.push(DashboardRow { id, label, subtitle: None, state, activity, secondary_line: entry .model_id .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(sanitize), cwd_display, cwd, last_change_at: crate::util::system_time_from_unix_ms(entry.last_change_unix_ms), pinned: is_pinned, is_active: false, badges, context_pct: None, indent: 0, parent_label: None, is_more_placeholder: false, more_count: 0, }); } } /// Classify a top-level agent. /// /// Returns one of [`RowState::NeedsInput`], [`RowState::Working`], /// [`RowState::Idle`]. /// /// `NeedsInput` triggers on pending permission OR pending /// `QuestionView` (`ask_user_question` tool). `Working` covers any /// non-idle session state, including command runs (compaction, /// worktree create, etc.) — which is why edge case 14 (compaction) /// works: a row in `AgentState::CommandRunning { Compact, .. }` is /// "Working" and the activity label reads `Compacting`. A turn-idle /// agent still classifies as `Working` while it has live background work /// (`has_background_work`): a running background task / `monitor` or an /// active scheduled `/loop`. Each is ongoing, user-dispatched work — and /// monitors / loops can wake the agent for a fresh turn — so the agent /// isn't meaningfully idle while any are running. pub fn classify_top_level(agent: &AgentView) -> RowState { if !agent.permission_queue.is_empty() || agent.question_view.is_some() { return RowState::NeedsInput; } if !agent.session.state.is_idle() || agent.session.turn_activity().is_some() || !agent.session.pending_prompts.is_empty() { return RowState::Working; } if agent.session.loading_replay { return RowState::Working; } if has_background_work(agent) { return RowState::Working; } RowState::Idle } /// Whether `agent` has live background work that keeps it out of the /// `Idle` group even when its turn is idle: a running background task /// (`run_terminal_command` with `background=true`), a running `monitor` /// (a background task with `is_monitor`), or an active scheduled `/loop`. /// Mirrors the agent view's idle "watching" cue /// (`crate::views::turn_status::Watchers`) but also counts plain /// background tasks — any in-flight background work the user dispatched /// should read as "Working" on the dashboard. pub fn has_background_work(agent: &AgentView) -> bool { agent .session .bg_tasks .values() .any(|t| t.status == crate::app::agent::BgTaskStatus::Running) || !agent.session.scheduled_tasks.is_empty() } /// Compact `"watching · …"` label summarising a turn-idle agent's live /// background work, listing only the non-zero kinds with singular/plural /// nouns — e.g. `"watching · 1 monitor · 2 loops"` or /// `"watching · 1 task"`. `None` when there's no background work (the /// caller then falls back to a bare `"Working"`). Mirrors the agent /// view's idle "watching" cue (`turn_status::watching_label`) so the /// dashboard and the agent view speak the same language; the counting /// matches [`has_background_work`]. fn background_work_label(agent: &AgentView) -> Option { use std::fmt::Write as _; let mut monitors = 0usize; let mut tasks = 0usize; for t in agent.session.bg_tasks.values() { if t.status != crate::app::agent::BgTaskStatus::Running { continue; } if t.is_monitor { monitors += 1; } else { tasks += 1; } } let loops = agent.session.scheduled_tasks.len(); if monitors + tasks + loops == 0 { return None; } let mut label = String::with_capacity(24); label.push_str("watching"); if monitors > 0 { let noun = if monitors == 1 { "monitor" } else { "monitors" }; let _ = write!(label, " \u{00b7} {monitors} {noun}"); } if loops > 0 { let noun = if loops == 1 { "loop" } else { "loops" }; let _ = write!(label, " \u{00b7} {loops} {noun}"); } if tasks > 0 { let noun = if tasks == 1 { "task" } else { "tasks" }; let _ = write!(label, " \u{00b7} {tasks} {noun}"); } Some(label) } /// Classify a subagent. /// /// Subagents never enter `NeedsInput` in v1 — they have no user-prompt /// channel. Asserted in tests. pub fn classify_subagent(info: &SubagentInfo) -> RowState { if info.finished { match info.status.as_deref() { Some("failed") | Some("cancelled") | Some("error") => RowState::Failed, _ => RowState::Completed, } } else { RowState::Working } } /// Issues 30 + 31 fix — sanitise every string derived from /// backend / model-controlled content before returning. The /// dashboard's renderer paints raw via `set_string`, which preserves /// embedded escape sequences in the ratatui buffer. fn sanitize(s: &str) -> String { crate::views::session_title::sanitize_display_text(s).into_owned() } /// Whether a top-level agent has no real conversation yet: no display name, no /// generated title, no queued prompt, and no user-typed message in scrollback. /// /// These are the exact conditions under which [`top_level_label`] falls back to /// `New session #`. A session created on pager launch carries only the /// system prompt plus injected `` context (which are not real /// user turns and never render as `UserPrompt` blocks), so it reads as empty /// here until the user actually sends something. Used by [`build_local_rows`] /// to keep such sessions off the dashboard. fn is_empty_top_level(agent: &AgentView) -> bool { let has_text = |s: Option<&str>| s.map(str::trim).is_some_and(|t| !t.is_empty()); if has_text(agent.display_name.as_deref()) { return false; } if has_text(agent.generated_session_title.as_deref()) { return false; } if !agent.session.pending_prompts.is_empty() { return false; } if !agent.subagent_sessions.is_empty() || !agent.session.bg_tasks.is_empty() { return false; } if super::peek::extract_first_user_message(agent).is_some() { return false; } true } fn top_level_label(agent: &AgentView) -> String { if let Some(name) = agent.display_name.as_deref() { let trimmed = name.trim(); if !trimmed.is_empty() { return sanitize(trimmed); } } if let Some(title) = agent.generated_session_title.as_deref() { let trimmed = title.trim(); if !trimmed.is_empty() { return sanitize(trimmed); } } if let Some(first) = agent.session.pending_prompts.front() { let preview: String = first.text.trim().chars().take(30).collect(); if !preview.is_empty() { return sanitize(&preview); } } if let Some(first_msg) = super::peek::extract_first_user_message(agent) { let preview: String = first_msg.chars().take(30).collect(); if !preview.is_empty() { return preview; } } if let Some(sid) = agent.session.session_id.as_ref() { let short: String = sid.0.chars().take(8).collect(); return format!("{NEW_SESSION_LABEL} #{short}"); } NEW_SESSION_LABEL.to_string() } fn top_level_row( id: AgentId, agent: &AgentView, pinned: bool, is_active: bool, home: Option<&str>, ) -> DashboardRow { let state = classify_top_level(agent); let label = top_level_label(agent); let subtitle = top_level_subtitle(agent); let activity = top_level_activity(agent, state); let secondary_line = top_level_secondary_line(agent, state, activity.as_deref()); let anchor: Instant = match state { RowState::Working => agent .turn_started_at .or(agent.last_active_at) .unwrap_or_else(fallback_epoch), RowState::NeedsInput | RowState::Idle | RowState::Inactive | RowState::Completed | RowState::Failed | RowState::Blocked => agent.last_active_at.unwrap_or_else(fallback_epoch), }; let last_change_at = crate::util::system_time_from_instant(anchor); let mut badges = Vec::new(); if agent.is_worktree { badges.push(RowBadge::Worktree); } if state == RowState::NeedsInput { badges.push(RowBadge::NeedsInput); } if agent .session .bg_tasks .values() .any(|t| t.status == crate::app::agent::BgTaskStatus::Running) { badges.push(RowBadge::BgTask); } if pinned { badges.push(RowBadge::Pinned); } let cwd_display = super::state::compact_cwd(&agent.session.cwd, home); DashboardRow { id: DashboardRowId::TopLevel(id), label, subtitle, state, activity, secondary_line, cwd_display, cwd: agent.session.cwd.clone(), last_change_at, pinned, is_active, badges, context_pct: agent.context_state.as_ref().map(|c| c.usage_pct), indent: 0, parent_label: None, is_more_placeholder: false, more_count: 0, } } fn subagent_row( parent: AgentId, parent_view: &AgentView, info: &SubagentInfo, pinned: bool, home: Option<&str>, ) -> DashboardRow { let state = classify_subagent(info); let (label_raw, desc_raw) = format_subagent_label(info); let label = { let label = sanitize(&label_raw); let desc = sanitize(&desc_raw); if desc.trim().is_empty() { label } else { format!("{label} · {desc}") } }; let activity = subagent_activity(info, state); let cwd = info .child_cwd .as_deref() .map(PathBuf::from) .unwrap_or_else(|| parent_view.session.cwd.clone()); let cwd_display = super::state::compact_cwd(&cwd, home); let last_change_at = crate::util::system_time_from_instant(info.last_progress_at); let mut badges = Vec::new(); if info.worktree_path.is_some() { badges.push(RowBadge::Worktree); } if pinned { badges.push(RowBadge::Pinned); } if state == RowState::Failed { badges.push(RowBadge::Failed); } let parent_label = Some(top_level_label(parent_view)); let subtitle = subagent_subtitle(info, &cwd); let secondary_line = subagent_secondary_line(info, state, activity.as_deref()); DashboardRow { id: DashboardRowId::Subagent { parent, child_session_id: info.child_session_id.to_string(), }, label, subtitle, state, activity, secondary_line, cwd_display, cwd, last_change_at, pinned, is_active: false, badges, context_pct: info.context_usage_pct, indent: 1, parent_label, is_more_placeholder: false, more_count: 0, } } /// Basename of a directory (the leaf folder name), or `None` for the /// filesystem root / an empty path. fn cwd_basename(cwd: &std::path::Path) -> Option { cwd.file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) } /// Build the dim right-of-label subtitle for a top-level row. /// /// Format: `{branch} {location} [worktree]` where `location` is the /// worktree's human label when the agent's cwd is a managed worktree /// (falling back to the cwd's folder name), and the cwd's folder name /// otherwise. Branch first, then the worktree/dir name, then the /// `worktree` marker. Examples: /// /// - Worktree: `alice/feature location-picker worktree` /// - Plain repo / subdir: `main foo` /// - No branch (detached / non-git): just the folder name, or `None` /// when even that is unavailable. /// /// `current_branch` / `is_worktree` / `worktree_label` are refreshed when /// the dashboard opens (see `dispatch_open_dashboard`) so the branch is /// the latest, not a stale notification value. fn top_level_subtitle(agent: &AgentView) -> Option { let lazy = crate::git_info::cwd_git_info_lazy(&agent.session.cwd); let trimmed = |s: &str| { let t = s.trim(); (!t.is_empty()).then(|| t.to_string()) }; let branch = agent .current_branch .as_deref() .and_then(trimmed) .or_else(|| { lazy.as_ref() .and_then(|i| i.branch.as_deref()) .and_then(trimmed) }); let is_worktree = agent.is_worktree || lazy.as_ref().is_some_and(|i| i.is_worktree); let location = if is_worktree { agent .worktree_label .as_deref() .and_then(trimmed) .or_else(|| { lazy.as_ref() .and_then(|i| i.worktree_label.as_deref()) .and_then(trimmed) }) .or_else(|| cwd_basename(&agent.session.cwd)) } else { cwd_basename(&agent.session.cwd) }; let mut parts: Vec = Vec::new(); if let Some(b) = branch { parts.push(sanitize(&b)); } if let Some(loc) = location { parts.push(sanitize(&loc)); } if parts.is_empty() { return None; } if is_worktree { parts.push("worktree".to_string()); } Some(parts.join(" ")) } /// Compose the dim "last tool / message" line painted directly below /// the title row. Driven by row state so the most relevant detail /// surfaces: /// /// - **NeedsInput** — front-most permission's title (`"Pending: {title}"`) /// or the question view's prompt. Falls back to `activity` if both /// are absent (shouldn't happen in practice; defensive). /// - **Working** — the activity string (`"read foo.rs"`, etc.). /// - **Idle / Completed / Failed / Blocked** — the last /// `AgentMessage` block in scrollback, trimmed to a single line. /// When no message is available yet (e.g. a session whose /// scrollback hasn't been replayed because the user hasn't opened /// it) it falls back to the model name so the line isn't blank; /// `None` only when the model is also unknown. /// /// All strings are sanitised before returning — the renderer paints /// the result directly via `set_string` with no escape filtering. fn top_level_secondary_line( agent: &AgentView, state: RowState, activity: Option<&str>, ) -> Option { match state { RowState::NeedsInput => { if let Some(perm) = agent.permission_queue.front() { let title = perm.title.trim(); if !title.is_empty() { return Some(format!("Pending: {}", sanitize(title))); } } if agent.question_view.is_some() { return Some("Pending: question".to_string()); } activity.map(sanitize) } RowState::Working => activity.map(sanitize), RowState::Idle | RowState::Inactive | RowState::Completed | RowState::Failed | RowState::Blocked => last_agent_message_preview(agent), } } /// Walk the scrollback from the end, returning the first /// `AgentMessage` block's text trimmed to a single line. Returns /// `None` when no agent message has been produced yet. fn last_agent_message_preview(agent: &AgentView) -> Option { use crate::scrollback::block::RenderBlock; let len = agent.scrollback.len(); for idx in (0..len).rev() { let entry = agent.scrollback.get(idx)?; if let RenderBlock::AgentMessage(msg) = &entry.block { let text = msg.text(); let line = first_nonempty_line(&text)?; return Some(sanitize(line.trim())); } } None } /// Return the first non-empty trimmed line of `s`, or `None` when /// every line is blank. fn first_nonempty_line(s: &str) -> Option<&str> { for line in s.lines() { let trimmed = line.trim(); if !trimmed.is_empty() { return Some(line); } } None } /// Subagent equivalent of `top_level_subtitle`. Subagents don't carry /// their own branch / repo metadata, so we show the cwd's folder name /// (which is the worktree folder name when running in a worktree) plus a /// `worktree` suffix when one is set. fn subagent_subtitle(info: &SubagentInfo, cwd: &std::path::Path) -> Option { let name = cwd_basename(cwd)?; if info.worktree_path.is_some() { Some(format!("{name} worktree")) } else { Some(name) } } /// Secondary line for subagent rows: the running tool (working) or /// the duration summary (finished). fn subagent_secondary_line( _info: &SubagentInfo, _state: RowState, activity: Option<&str>, ) -> Option { activity.map(sanitize) } fn top_level_activity(agent: &AgentView, state: RowState) -> Option { match state { RowState::NeedsInput => Some("Awaiting your input".to_string()), RowState::Working => { if let Some(cmd) = agent.session.state.command_in_flight() { Some(format!("{}…", cmd.display_name())) } else if let Some(activity) = agent.resolve_turn_activity() { Some(sanitize(&format_activity_label(&activity))) } else if agent.session.loading_replay { Some("Loading…".to_string()) } else if let Some(bg) = background_work_label(agent) { Some(bg) } else { Some("Working".to_string()) } } _ => None, } } fn subagent_activity(info: &SubagentInfo, state: RowState) -> Option { if state == RowState::Working { if let Some(label) = info .activity_label .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { return Some(sanitize(label)); } let last_tool = info.tools_used.last().map(|s| s.as_ref()).unwrap_or(""); if last_tool.is_empty() { Some("Working".to_string()) } else { Some(sanitize(&format_activity_label( &TurnActivity::ToolRunning { title: last_tool.to_string(), description: None, }, ))) } } else if info.finished { let turns = info.turns.unwrap_or(0); let tools = info.tool_calls.unwrap_or(0); let toks = info.tokens_used.unwrap_or(0); Some(format!("{tools} tools · {toks} tok · {turns} turns")) } else { None } } /// Apply a filter to the list in place. /// /// Filter cases — see edge case 11. /// /// Needle is lowercased once outside `retain` instead /// of per-row, so a 100-row list doesn't allocate 100 fresh /// lowercase Strings per keystroke. pub fn apply_filter(rows: &mut Vec, filter: &Filter, _home: Option<&str>) { if matches!(filter, Filter::None) { return; } let needle_lower: Option = match filter { Filter::Agent(s) | Filter::Substring(s) => Some(s.to_lowercase()), _ => None, }; rows.retain(|r| { if r.is_more_placeholder { return true; } match filter { Filter::None => true, Filter::Agent(_) => { let n = needle_lower.as_deref().unwrap_or(""); r.label.to_lowercase().contains(n) || r.parent_label .as_deref() .is_some_and(|p| p.to_lowercase().contains(n)) } Filter::State(rs) => r.state == *rs, Filter::Substring(_) => { let n = needle_lower.as_deref().unwrap_or(""); if n.is_empty() { return true; } r.label.to_lowercase().contains(n) || r.cwd_display.to_lowercase().contains(n) } } }); } /// Sort rows in place. /// /// Order: /// 1. Pinned first (regardless of group). /// 2. Group primitive: descending state-priority in `State` grouping; /// `cwd` in `Directory` grouping. /// 3. `reorder` overrides (Shift+↑/↓) float a row to its declared index /// INSIDE its group only — never across groups. In `State` grouping /// this means within the same state (so the group can't be split /// into `Idle → Working → Idle`); in `Directory` grouping it means /// within the same cwd. See [`sort_cluster_key`]. /// 4. Then descending by `last_change_at`. /// 5. Final tiebreak by `DashboardRowId` so the order is /// deterministic across rebuilds even when every other field /// ties. Asserted by `sort_rows_tiebreaks_by_id_when_keys_equal`. /// 6. Subagents stay glued to their parent (we never sort the row /// vector across parents — that's already enforced by the build /// step which emits subagents immediately after their parent). pub fn sort_rows( rows: &mut [DashboardRow], grouping: super::state::Grouping, reorder: &[DashboardRowId], ) { use super::state::Grouping; match grouping { Grouping::State => sort_within_groups(rows, reorder), Grouping::Directory => sort_within_directory_groups(rows, reorder), } } fn sort_within_groups(rows: &mut [DashboardRow], reorder: &[DashboardRowId]) { let clusters = build_clusters(rows); let mut indexed: Vec<(usize, usize, ClusterKey)> = clusters .iter() .map(|(start, end)| { let parent = &rows[*start]; ( *start, *end, ClusterKey { pinned: parent.pinned, state: parent.state.group_priority(), last_change_at: parent.last_change_at, reorder_idx: reorder.iter().position(|r| *r == parent.id), id: parent.id.clone(), }, ) }) .collect(); indexed.sort_by(|a, b| sort_cluster_key(&a.2, &b.2, true)); let snapshot: Vec = rows.to_vec(); let mut write = 0usize; for (start, end, _) in &indexed { for src in snapshot.iter().take(*end).skip(*start) { rows[write] = src.clone(); write += 1; } } } fn sort_within_directory_groups(rows: &mut [DashboardRow], reorder: &[DashboardRowId]) { let clusters = build_clusters(rows); let mut indexed: Vec<(usize, usize, (String, ClusterKey))> = clusters .iter() .map(|(start, end)| { let parent = &rows[*start]; let key = ClusterKey { pinned: parent.pinned, state: parent.state.group_priority(), last_change_at: parent.last_change_at, reorder_idx: reorder.iter().position(|r| *r == parent.id), id: parent.id.clone(), }; (*start, *end, (parent.cwd_display.clone(), key)) }) .collect(); indexed.sort_by(|a, b| match a.2.0.cmp(&b.2.0) { std::cmp::Ordering::Equal => sort_cluster_key(&a.2.1, &b.2.1, false), other => other, }); let snapshot: Vec = rows.to_vec(); let mut write = 0usize; for (start, end, _) in &indexed { for src in snapshot.iter().take(*end).skip(*start) { rows[write] = src.clone(); write += 1; } } } #[derive(Debug, Clone)] struct ClusterKey { pinned: bool, state: u8, last_change_at: SystemTime, reorder_idx: Option, /// Final tiebreak: when every other field is equal, /// fall back to the parent row's id so the order is deterministic /// across rebuilds (no reliance on `sort_by`'s stable-on-equal /// pass-through). id: DashboardRowId, } /// Compare two cluster sort keys. /// /// `state_before_reorder` decides where the explicit-reorder override /// (Shift+↑/↓) sits relative to the state-priority key — and that choice /// is what keeps the rendered groups contiguous: /// /// - **State grouping (`true`)** — state is the grouping primitive AND /// the primary sort key, so a reorder must only float a row *within* /// its own state group. Ranking `reorder_idx` above `state` (the old /// behaviour) let a reordered Idle row jump above the Working group, /// which made the renderer emit a second "Idle" header below the /// Working one (Idle → Working → Idle). Keeping `state` first confines /// the reorder to one group, matching the documented contract /// ("float to position inside the row's group") in /// [`super::super::app::dispatch`]'s `dispatch_dashboard_reorder`. /// - **Directory grouping (`false`)** — the cwd is the grouping /// primitive (sorted ahead of this key in /// [`sort_within_directory_groups`]), so within a cwd the reorder is /// free to float across states; there are no state sub-headers to /// split. fn sort_cluster_key( a: &ClusterKey, b: &ClusterKey, state_before_reorder: bool, ) -> std::cmp::Ordering { use std::cmp::Ordering; if a.pinned != b.pinned { return b.pinned.cmp(&a.pinned); } let by_reorder = |a: &ClusterKey, b: &ClusterKey| match (a.reorder_idx, b.reorder_idx) { (Some(x), Some(y)) => x.cmp(&y), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => Ordering::Equal, }; let by_state = |a: &ClusterKey, b: &ClusterKey| b.state.cmp(&a.state); if state_before_reorder { if a.state != b.state { return by_state(a, b); } match by_reorder(a, b) { Ordering::Equal => {} ord => return ord, } } else { match by_reorder(a, b) { Ordering::Equal => {} ord => return ord, } if a.state != b.state { return by_state(a, b); } } b.last_change_at .cmp(&a.last_change_at) .then_with(|| a.id.cmp(&b.id)) } /// Identify `(start, end)` ranges of clustered rows — each cluster is /// one top-level row followed by zero or more subagents. fn build_clusters(rows: &[DashboardRow]) -> Vec<(usize, usize)> { let mut clusters = Vec::new(); let mut i = 0; while i < rows.len() { let start = i; i += 1; while i < rows.len() && rows[i].indent > 0 { i += 1; } clusters.push((start, i)); } clusters } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use std::time::{Duration, UNIX_EPOCH}; fn make_subagent(child_id: &str, finished: bool, status: Option<&str>) -> SubagentInfo { let now = Instant::now(); SubagentInfo { subagent_id: Arc::from(format!("sa-{child_id}")), child_session_id: Arc::from(child_id), description: Arc::from("test task"), subagent_type: Arc::from("explore"), persona: None, role: None, model: None, context_source: None, resumed_from: None, capability_mode: None, context_normalized: false, child_updates_replayed: false, parent_prompt_id: None, started_at: now, last_progress_at: now, finished, status: status.map(Arc::from), error: None, duration_ms: None, tool_calls: None, turns: None, turn_count: None, tool_call_count: None, tokens_used: None, context_window_tokens: None, context_usage_pct: None, tools_used: Vec::new(), error_count: None, activity_label: None, is_background: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, prompt: None, child_cwd: None, worktree_path: None, } } #[test] fn classify_subagent_running() { let info = make_subagent("a", false, None); assert_eq!(classify_subagent(&info), RowState::Working); } #[test] fn classify_subagent_completed() { let info = make_subagent("a", true, Some("completed")); assert_eq!(classify_subagent(&info), RowState::Completed); } #[test] fn classify_subagent_failed() { let info = make_subagent("a", true, Some("failed")); assert_eq!(classify_subagent(&info), RowState::Failed); } #[test] fn classify_subagent_cancelled() { let info = make_subagent("a", true, Some("cancelled")); assert_eq!(classify_subagent(&info), RowState::Failed); } #[test] fn subagent_activity_prefers_live_label_over_tool_reconstruction() { let mut info = make_subagent("a", false, None); info.tools_used = vec![Arc::from("bash")]; info.activity_label = Some("Running: cargo build".into()); assert_eq!( subagent_activity(&info, RowState::Working).as_deref(), Some("Running: cargo build") ); info.activity_label = None; assert_eq!( subagent_activity(&info, RowState::Working).as_deref(), Some("Running: bash") ); } /// Edge case 7: subagent rows never reach `NeedsInput` in v1. #[test] fn subagent_classifier_never_emits_needs_input() { for finished in [false, true] { for status in [None, Some("completed"), Some("failed"), Some("cancelled")] { let info = make_subagent("a", finished, status); let state = classify_subagent(&info); assert_ne!( state, RowState::NeedsInput, "finished={finished} status={status:?}" ); } } } #[test] fn cluster_keeps_subagents_with_parent() { let rows = vec![ make_row("a", 0, RowState::Working), make_row("a:1", 1, RowState::Working), make_row("a:2", 1, RowState::Idle), make_row("b", 0, RowState::Idle), make_row("b:1", 1, RowState::Working), ]; let clusters = build_clusters(&rows); assert_eq!(clusters, vec![(0, 3), (3, 5)]); } fn make_row(label: &str, indent: u8, state: RowState) -> DashboardRow { DashboardRow { id: if indent == 0 { DashboardRowId::TopLevel(AgentId(label.len())) } else { DashboardRowId::Subagent { parent: AgentId(0), child_session_id: label.to_string(), } }, label: label.to_string(), subtitle: None, state, activity: None, secondary_line: None, cwd_display: "~/x".to_string(), cwd: PathBuf::from("/tmp"), last_change_at: SystemTime::now(), pinned: false, is_active: false, badges: Vec::new(), context_pct: None, indent, parent_label: None, is_more_placeholder: false, more_count: 0, } } fn make_row_with_id(id: DashboardRowId, indent: u8, state: RowState) -> DashboardRow { DashboardRow { id, label: "row".to_string(), subtitle: None, state, activity: None, secondary_line: None, cwd_display: "~/x".to_string(), cwd: PathBuf::from("/tmp"), last_change_at: SystemTime::now(), pinned: false, is_active: false, badges: Vec::new(), context_pct: None, indent, parent_label: None, is_more_placeholder: false, more_count: 0, } } /// Sort: pinned floats above non-pinned regardless of state. #[test] fn sort_pinned_first() { let mut rows = vec![ DashboardRow { pinned: false, state: RowState::Working, ..make_row_with_id(DashboardRowId::TopLevel(AgentId(2)), 0, RowState::Working) }, DashboardRow { pinned: true, state: RowState::Idle, ..make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle) }, ]; sort_rows(&mut rows, super::super::state::Grouping::State, &[]); assert!(rows[0].pinned); assert!(!rows[1].pinned); } #[test] fn sort_state_priority_within_group() { let mut rows = vec![ make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle), make_row_with_id( DashboardRowId::TopLevel(AgentId(2)), 0, RowState::NeedsInput, ), make_row_with_id(DashboardRowId::TopLevel(AgentId(3)), 0, RowState::Working), ]; sort_rows(&mut rows, super::super::state::Grouping::State, &[]); assert_eq!(rows[0].state, RowState::NeedsInput); assert_eq!(rows[1].state, RowState::Working); assert_eq!(rows[2].state, RowState::Idle); } /// Renamed from `sort_deterministic_with_equal_keys`. /// The original name implied a tiebreak guarantee that /// `sort_cluster_key` does NOT provide; documents the actual /// behavioural contract: idempotent on identical inputs. #[test] fn sort_is_idempotent_for_identical_inputs() { let now = SystemTime::now(); let mut rows1 = vec![ DashboardRow { last_change_at: now, ..make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle) }, DashboardRow { last_change_at: now, ..make_row_with_id(DashboardRowId::TopLevel(AgentId(2)), 0, RowState::Idle) }, ]; let mut rows2 = rows1.clone(); sort_rows(&mut rows1, super::super::state::Grouping::State, &[]); sort_rows(&mut rows2, super::super::state::Grouping::State, &[]); assert_eq!( rows1.iter().map(|r| r.id.clone()).collect::>(), rows2.iter().map(|r| r.id.clone()).collect::>() ); } /// When every other field ties, the sort tiebreaks /// by id, producing a fully deterministic order independent of /// the input row order. AgentId(1) sorts before AgentId(2) /// regardless of which appears first in the input vector. #[test] fn sort_rows_tiebreaks_by_id_when_keys_equal() { let now = SystemTime::now(); let id1 = DashboardRowId::TopLevel(AgentId(1)); let id2 = DashboardRowId::TopLevel(AgentId(2)); let mut forward = vec![ DashboardRow { last_change_at: now, ..make_row_with_id(id1.clone(), 0, RowState::Idle) }, DashboardRow { last_change_at: now, ..make_row_with_id(id2.clone(), 0, RowState::Idle) }, ]; let mut reverse = vec![ DashboardRow { last_change_at: now, ..make_row_with_id(id2.clone(), 0, RowState::Idle) }, DashboardRow { last_change_at: now, ..make_row_with_id(id1.clone(), 0, RowState::Idle) }, ]; sort_rows(&mut forward, super::super::state::Grouping::State, &[]); sort_rows(&mut reverse, super::super::state::Grouping::State, &[]); assert_eq!(forward[0].id, id1, "forward: id1 must sort first"); assert_eq!(forward[1].id, id2, "forward: id2 must sort second"); assert_eq!(reverse[0].id, id1, "reverse: id1 must sort first"); assert_eq!(reverse[1].id, id2, "reverse: id2 must sort second"); } #[test] fn filter_substring_label_match() { let mut rows = vec![ DashboardRow { label: "fix login bug".to_string(), ..make_row("a", 0, RowState::Working) }, DashboardRow { label: "investigate cache".to_string(), ..make_row("b", 0, RowState::Working) }, ]; apply_filter(&mut rows, &Filter::Substring("login".into()), None); assert_eq!(rows.len(), 1); } #[test] fn filter_agent_matches_label_and_parent() { let mut rows = vec![ DashboardRow { label: "implementer".to_string(), parent_label: None, ..make_row("a", 0, RowState::Working) }, DashboardRow { label: "explore".to_string(), parent_label: Some("implementer".to_string()), ..make_row("a:1", 1, RowState::Working) }, DashboardRow { label: "other".to_string(), parent_label: None, ..make_row("b", 0, RowState::Working) }, ]; apply_filter(&mut rows, &Filter::Agent("implementer".into()), None); assert_eq!(rows.len(), 2); } #[test] fn filter_state_matches_exact() { let mut rows = vec![ DashboardRow { state: RowState::NeedsInput, ..make_row("a", 0, RowState::NeedsInput) }, DashboardRow { state: RowState::Idle, ..make_row("b", 0, RowState::Idle) }, ]; apply_filter(&mut rows, &Filter::State(RowState::Idle), None); assert_eq!(rows.len(), 1); assert_eq!(rows[0].state, RowState::Idle); } /// Sort: explicit reorderings (Shift+↑/↓) float a row to its /// declared position WITHIN its state group. Two Working rows: the /// older one (id2) is reordered above the more-recent one (id1). #[test] fn sort_reorder_floats_within_state_group() { let id1 = DashboardRowId::TopLevel(AgentId(1)); let id2 = DashboardRowId::TopLevel(AgentId(2)); let newer = SystemTime::now(); let older = newer - Duration::from_secs(60); let mut rows = vec![ DashboardRow { last_change_at: newer, ..make_row_with_id(id1.clone(), 0, RowState::Working) }, DashboardRow { last_change_at: older, ..make_row_with_id(id2.clone(), 0, RowState::Working) }, ]; sort_rows( &mut rows, super::super::state::Grouping::State, std::slice::from_ref(&id2), ); assert_eq!(rows[0].id, id2, "reorder must float id2 to the top"); assert_eq!(rows[1].id, id1); } /// Regression: reordering must NOT split a state group. With Working /// and Idle rows interleaved in the input, reordering an Idle row /// keeps every Working row above every Idle row — the renderer would /// otherwise emit `Idle → Working → Idle` headers (the reported bug). /// The reorder only floats the Idle row WITHIN the Idle group. #[test] fn reorder_does_not_split_state_groups() { let w1 = DashboardRowId::TopLevel(AgentId(1)); let i1 = DashboardRowId::TopLevel(AgentId(2)); let w2 = DashboardRowId::TopLevel(AgentId(3)); let i2 = DashboardRowId::TopLevel(AgentId(4)); let mut rows = vec![ make_row_with_id(w1, 0, RowState::Working), make_row_with_id(i1.clone(), 0, RowState::Idle), make_row_with_id(w2, 0, RowState::Working), make_row_with_id(i2.clone(), 0, RowState::Idle), ]; sort_rows( &mut rows, super::super::state::Grouping::State, &[i1.clone(), i2.clone()], ); let states: Vec = rows.iter().map(|r| r.state).collect(); assert_eq!( states, vec![ RowState::Working, RowState::Working, RowState::Idle, RowState::Idle, ], "reorder must not interleave states, got {states:?}", ); let idle_order: Vec = rows .iter() .filter(|r| r.state == RowState::Idle) .map(|r| r.id.clone()) .collect(); assert_eq!(idle_order, vec![i1, i2], "reorder must order within Idle"); } /// Sort: when both rows are pinned, ordering falls back to the /// secondary key (state). #[test] fn sort_two_pinned_rows_order_by_state() { let mut rows = vec![ DashboardRow { pinned: true, state: RowState::Idle, ..make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle) }, DashboardRow { pinned: true, state: RowState::Working, ..make_row_with_id(DashboardRowId::TopLevel(AgentId(2)), 0, RowState::Working) }, ]; sort_rows(&mut rows, super::super::state::Grouping::State, &[]); assert!(rows[0].pinned); assert_eq!(rows[0].state, RowState::Working); } /// Filter: agent prefix with empty needle keeps everything (treated /// as `Filter::None` upstream by `Filter::from_value`). #[test] fn filter_agent_with_empty_string_does_not_hide_rows() { let mut rows = vec![ make_row("a", 0, RowState::Working), make_row("b", 0, RowState::Idle), ]; apply_filter(&mut rows, &Filter::Agent(String::new()), None); assert_eq!(rows.len(), 2); } /// Edge case 6: more-placeholder rows survive filtering. #[test] fn filter_keeps_more_placeholders() { let mut rows = vec![ DashboardRow { label: "real row".to_string(), ..make_row("a", 1, RowState::Idle) }, DashboardRow { label: "… 3 more".to_string(), is_more_placeholder: true, ..make_row("b", 1, RowState::Idle) }, ]; apply_filter( &mut rows, &Filter::Substring("nothing matches".into()), None, ); assert!(rows.iter().any(|r| r.is_more_placeholder)); } fn roster_entry(session_id: &str, last_change_unix_ms: i64) -> RosterEntry { use crate::app::roster::RosterOrigin; RosterEntry { session_id: session_id.to_string(), title: Some(format!("title {session_id}")), cwd: "/repo".to_string(), is_worktree: false, model_id: None, yolo: false, activity: RosterActivity::Dormant, resident: false, last_change_unix_ms, origin: RosterOrigin::default(), } } fn now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as i64 } /// The age rendered for a roster row (`render` uses /// `last_change_at.elapsed()`). fn roster_row_age(entry: RosterEntry) -> Duration { let mut rows = Vec::new(); append_roster_rows( &mut rows, &[entry], &IndexMap::new(), &std::collections::BTreeSet::new(), None, ); assert_eq!(rows.len(), 1); rows[0].last_change_at.elapsed().unwrap_or_default() } /// A roster entry touched a while ago must NOT render as "just now": /// its `last_change_at` is derived from `last_change_unix_ms`, so the /// age reflects the real wall-clock age (regression guard for the bug /// where every roster row stamped `Instant::now()`). #[test] fn roster_row_age_reflects_last_change_unix_ms() { let two_hours_ago = now_unix_ms() - 2 * 3_600_000; let elapsed = roster_row_age(roster_entry("sess-old", two_hours_ago)); assert!( elapsed.as_secs() >= 3600, "roster row age should reflect the real timestamp, got {elapsed:?}" ); assert_eq!(crate::util::format_time_ago(elapsed), "2h"); } /// Roster rows sort by their real `last_change_unix_ms`: a session /// touched long ago floats below a recently-touched one. #[test] fn roster_rows_sort_by_real_timestamp() { let now_ms = now_unix_ms(); let mut rows = Vec::new(); let agents = IndexMap::new(); let pinned = std::collections::BTreeSet::new(); append_roster_rows( &mut rows, &[ roster_entry("sess-old", now_ms - 86_400_000), roster_entry("sess-new", now_ms - 5_000), ], &agents, &pinned, None, ); sort_rows(&mut rows, super::super::state::Grouping::State, &[]); assert_eq!( rows[0].id, DashboardRowId::Roster { session_id: "sess-new".to_string() } ); assert_eq!( rows[1].id, DashboardRowId::Roster { session_id: "sess-old".to_string() } ); } /// Build a roster entry with an explicit title + activity for the /// empty-session filtering tests. fn roster_entry_with( session_id: &str, title: Option<&str>, activity: RosterActivity, ) -> RosterEntry { RosterEntry { title: title.map(String::from), activity, ..roster_entry(session_id, now_unix_ms()) } } fn collect_roster( entries: &[RosterEntry], pinned: &std::collections::BTreeSet, ) -> Vec { let mut rows = Vec::new(); append_roster_rows(&mut rows, entries, &IndexMap::new(), pinned, None); rows } /// An untitled, inactive roster session is the "New session" noise the /// dashboard hides — every pager launch leaves one behind. #[test] fn append_roster_rows_hides_untitled_idle_entry() { let empty = std::collections::BTreeSet::new(); for activity in [ RosterActivity::Idle, RosterActivity::Dormant, RosterActivity::Completed, RosterActivity::Dead, ] { let rows = collect_roster(&[roster_entry_with("e", None, activity)], &empty); assert!( rows.is_empty(), "untitled {activity:?} entry must be hidden" ); } } /// An untitled session that is actively working / awaiting input stays /// visible: its title hasn't been generated yet but it is doing real work. #[test] fn append_roster_rows_keeps_untitled_active_entry() { let empty = std::collections::BTreeSet::new(); for activity in [RosterActivity::Working, RosterActivity::NeedsInput] { let rows = collect_roster(&[roster_entry_with("a", None, activity)], &empty); assert_eq!(rows.len(), 1, "untitled {activity:?} entry must be kept"); } } /// Titled (real) sessions always render, even when idle/dormant. #[test] fn append_roster_rows_keeps_titled_entry() { let empty = std::collections::BTreeSet::new(); let rows = collect_roster( &[roster_entry_with( "t", Some("Fix the bug"), RosterActivity::Dormant, )], &empty, ); assert_eq!(rows.len(), 1, "titled entries are kept"); } /// A blank/whitespace title is treated as no title. #[test] fn append_roster_rows_hides_blank_titled_idle_entry() { let empty = std::collections::BTreeSet::new(); let rows = collect_roster( &[roster_entry_with("b", Some(" "), RosterActivity::Idle)], &empty, ); assert!(rows.is_empty(), "blank-title idle entry must be hidden"); } /// A user-pinned row is always shown, even untitled + idle. #[test] fn append_roster_rows_keeps_pinned_untitled_entry() { let mut pinned = std::collections::BTreeSet::new(); pinned.insert(DashboardRowId::Roster { session_id: "p".to_string(), }); let rows = collect_roster( &[roster_entry_with("p", None, RosterActivity::Idle)], &pinned, ); assert_eq!(rows.len(), 1, "pinned untitled entries are kept"); } /// A roster session with a known model surfaces it on the otherwise /// blank second line (the wire carries no last-message preview, so /// the model is the only per-session detail left to show). #[test] fn append_roster_rows_uses_model_id_as_secondary_line() { let empty = std::collections::BTreeSet::new(); let entry = RosterEntry { model_id: Some("kigi-4.5".to_string()), ..roster_entry_with("m", Some("Fix the bug"), RosterActivity::Dormant) }; let rows = collect_roster(&[entry], &empty); assert_eq!(rows.len(), 1); assert_eq!(rows[0].secondary_line.as_deref(), Some("kigi-4.5")); } /// Without a model id there's genuinely nothing to show, so the /// second line stays empty rather than rendering a placeholder. #[test] fn append_roster_rows_secondary_line_none_without_model() { let empty = std::collections::BTreeSet::new(); let rows = collect_roster( &[roster_entry_with( "m", Some("Fix the bug"), RosterActivity::Dormant, )], &empty, ); assert_eq!(rows.len(), 1); assert_eq!(rows[0].secondary_line, None); } /// Build a minimal idle local agent with an empty scrollback (no /// replayed messages) and an optional current model — mirrors a /// session listed on the dashboard that the user hasn't opened yet. fn make_idle_agent_with_model(model: Option<&str>) -> AgentView { use crate::acp::model_state::ModelState; use crate::app::agent::{AgentSession, AgentState}; use crate::scrollback::state::ScrollbackState; use agent_client_protocol as acp; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut models = ModelState::default(); if let Some(m) = model { models.set_current(acp::ModelId::new(std::sync::Arc::from(m)), None); } let session = AgentSession { id: AgentId(0), acp_tx: tx, session_id: Some(acp::SessionId::new("test-session")), models, state: AgentState::Idle, tracker: crate::acp::tracker::AcpUpdateTracker::new(), cwd: PathBuf::from("/tmp"), is_worktree: false, forked_from: None, pending_prompts: std::collections::VecDeque::new(), next_queue_id: 0, yolo_mode: false, auto_mode: false, prompt_history: Vec::new(), prompt_history_loading: false, loading_replay: false, restore_degree: None, rate_limited: false, model_incompatible: false, available_commands: Vec::new(), available_commands_generation: 0, available_tools: None, model_switch_pending: false, user_model_preference: None, deferred_model_switch: None, bg_tasks: std::collections::BTreeMap::new(), bg_tool_call_to_task: std::collections::HashMap::new(), scheduled_tasks: std::collections::HashMap::new(), in_flight_prompt: None, current_prompt_id: None, created_via_new: false, }; AgentView::new(session, ScrollbackState::new()) } /// An idle local agent with no last message has a BLANK second line — /// the model is no longer used as a fallback there (it now shows in /// the peek panel's bottom-border badge for the selected row, keeping /// the list uncluttered). #[test] fn idle_local_agent_without_message_has_blank_secondary() { let agent = make_idle_agent_with_model(Some("kigi-4.5")); assert_eq!(classify_top_level(&agent), RowState::Idle); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!( row.secondary_line, None, "the model must not appear on the list row anymore", ); } /// When neither a message nor a model is known the second line is /// left empty (no placeholder). #[test] fn idle_local_agent_without_message_or_model_has_no_secondary() { let agent = make_idle_agent_with_model(None); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!(row.secondary_line, None); } /// A worktree agent's subtitle shows the branch + the worktree's /// human label + a `worktree` marker. #[test] fn subtitle_worktree_shows_label_branch_and_marker() { let mut agent = make_idle_agent_with_model(None); agent.session.cwd = PathBuf::from("/home/me/.kigi/worktrees/x/location-picker"); agent.is_worktree = true; agent.worktree_label = Some("location-picker".to_string()); agent.current_branch = Some("kevin/feature".to_string()); assert_eq!( top_level_subtitle(&agent).as_deref(), Some("kevin/feature location-picker worktree"), ); } /// A worktree without a DB label falls back to the cwd folder name. #[test] fn subtitle_worktree_without_label_uses_folder_name() { let mut agent = make_idle_agent_with_model(None); agent.session.cwd = PathBuf::from("/home/me/wt/my-wt-dir"); agent.is_worktree = true; agent.worktree_label = None; agent.current_branch = Some("main".to_string()); assert_eq!( top_level_subtitle(&agent).as_deref(), Some("main my-wt-dir worktree"), ); } /// A non-worktree agent shows the branch + the cwd folder name (the /// actual working subdir, not the repo root), no worktree marker. #[test] fn subtitle_non_worktree_shows_branch_and_folder() { let mut agent = make_idle_agent_with_model(None); agent.session.cwd = PathBuf::from("/home/me/xai/crates/foo"); agent.is_worktree = false; agent.current_branch = Some("main".to_string()); assert_eq!(top_level_subtitle(&agent).as_deref(), Some("main foo")); } /// With no branch known, the subtitle is just the folder name. #[test] fn subtitle_no_branch_shows_only_folder() { let mut agent = make_idle_agent_with_model(None); agent.session.cwd = PathBuf::from("/home/me/projects/bar"); agent.is_worktree = false; agent.current_branch = None; assert_eq!(top_level_subtitle(&agent).as_deref(), Some("bar")); } /// The `[bg]` badge reflects RUNNING background tasks only — /// `bg_tasks` retains finished (`Done` / `Failed`) tasks for the /// tasks-pane history, and those must not pin a stale badge on the /// row forever. #[test] fn bg_badge_only_for_running_tasks() { use crate::app::agent::{BgTaskState, BgTaskStatus}; let make_task = |status: BgTaskStatus| BgTaskState { task_id: "t1".into(), tool_call_id: String::new(), command: "sleep 99".into(), description: None, cwd: String::new(), output_file: String::new(), status, start_time: std::time::SystemTime::now(), end_time: None, exit_code: None, signal: None, stdout: String::new(), stdout_line_count: 0, truncated: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, is_monitor: false, restored_from_replay: false, }; for (status, expect_badge) in [ (BgTaskStatus::Running, true), (BgTaskStatus::Done, false), (BgTaskStatus::Failed, false), ] { let mut agent = make_idle_agent_with_model(None); agent .session .bg_tasks .insert("t1".into(), make_task(status)); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!( row.badges.contains(&RowBadge::BgTask), expect_badge, "status={status:?} badge expectation", ); } } /// A running background task (`run_terminal_command background=true`). fn running_bg_task(task_id: &str, is_monitor: bool) -> crate::app::agent::BgTaskState { crate::app::agent::BgTaskState { task_id: task_id.into(), tool_call_id: String::new(), command: "sleep 99".into(), description: None, cwd: String::new(), output_file: String::new(), status: crate::app::agent::BgTaskStatus::Running, start_time: std::time::SystemTime::now(), end_time: None, exit_code: None, signal: None, stdout: String::new(), stdout_line_count: 0, truncated: false, pending_kill: false, kill_requested_at: None, scrollback_entry_id: None, is_monitor, restored_from_replay: false, } } /// An active scheduled `/loop` task. fn scheduled_loop(task_id: &str) -> crate::app::agent::ScheduledTaskInfo { crate::app::agent::ScheduledTaskInfo { task_id: task_id.into(), prompt: "check things".into(), human_schedule: "every 5m".into(), created_at: std::time::Instant::now(), next_fire_at: None, tag: "loop".into(), } } /// A turn-idle agent with a RUNNING background task is `Working`, not /// `Idle`. Finished tasks (Done / Failed) don't keep it Working — they /// linger in `bg_tasks` only for the tasks-pane history. #[test] fn running_bg_task_classifies_as_working() { use crate::app::agent::BgTaskStatus; for (status, expected) in [ (BgTaskStatus::Running, RowState::Working), (BgTaskStatus::Done, RowState::Idle), (BgTaskStatus::Failed, RowState::Idle), ] { let mut agent = make_idle_agent_with_model(None); let mut task = running_bg_task("t1", false); task.status = status; agent.session.bg_tasks.insert("t1".into(), task); assert_eq!( classify_top_level(&agent), expected, "status={status:?} must classify as {expected:?}", ); } } /// A running `monitor` (a bg task with `is_monitor`) keeps the agent /// `Working`, and the activity line names it. #[test] fn running_monitor_classifies_as_working_with_label() { let mut agent = make_idle_agent_with_model(None); agent .session .bg_tasks .insert("m1".into(), running_bg_task("m1", true)); assert_eq!(classify_top_level(&agent), RowState::Working); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!(row.activity.as_deref(), Some("watching · 1 monitor")); } /// An active scheduled `/loop` keeps the agent `Working` even with a /// fully idle turn, labelled as a loop. #[test] fn scheduled_loop_classifies_as_working_with_label() { let mut agent = make_idle_agent_with_model(None); agent .session .scheduled_tasks .insert("l1".into(), scheduled_loop("l1")); assert_eq!(classify_top_level(&agent), RowState::Working); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!(row.activity.as_deref(), Some("watching · 1 loop")); } /// The background-work label lists every non-zero kind (monitors, /// then loops, then plain tasks) with correct singular/plural nouns. #[test] fn background_work_label_lists_all_kinds() { let mut agent = make_idle_agent_with_model(None); agent .session .bg_tasks .insert("m1".into(), running_bg_task("m1", true)); agent .session .bg_tasks .insert("b1".into(), running_bg_task("b1", false)); agent .session .bg_tasks .insert("b2".into(), running_bg_task("b2", false)); agent .session .scheduled_tasks .insert("l1".into(), scheduled_loop("l1")); assert_eq!(classify_top_level(&agent), RowState::Working); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!( row.activity.as_deref(), Some("watching · 1 monitor · 1 loop · 2 tasks"), ); } /// The background-work label is the LAST activity fallback: a more /// specific Working signal (here, replay loading) still wins over /// "watching · …", so a real turn is never masked by it. #[test] fn specific_working_activity_wins_over_background_label() { let mut agent = make_idle_agent_with_model(None); agent.session.loading_replay = true; agent .session .bg_tasks .insert("m1".into(), running_bg_task("m1", true)); assert_eq!(classify_top_level(&agent), RowState::Working); let row = top_level_row(AgentId(0), &agent, false, false, None); assert_eq!( row.activity.as_deref(), Some("Loading…"), "loading-replay activity must win over the background label", ); } /// Roster-only idle / dormant sessions classify as `Inactive` — the /// dedicated section for sessions not loaded in this pager — while /// the active roster states keep their existing mapping. (Local /// idle agents stay `Idle`; `classify_top_level` never returns /// `Inactive` — pinned by `idle_local_agent_without_message_has_blank_secondary`.) #[test] fn roster_idle_and_dormant_classify_as_inactive() { let empty = std::collections::BTreeSet::new(); for (activity, expected) in [ (RosterActivity::Idle, RowState::Inactive), (RosterActivity::Dormant, RowState::Inactive), (RosterActivity::Working, RowState::Working), (RosterActivity::NeedsInput, RowState::NeedsInput), (RosterActivity::Completed, RowState::Completed), (RosterActivity::Dead, RowState::Failed), ] { let rows = collect_roster(&[roster_entry_with("m", Some("Task"), activity)], &empty); assert_eq!(rows.len(), 1, "activity={activity:?}"); assert_eq!(rows[0].state, expected, "activity={activity:?}"); } } /// In state grouping, `Inactive` sorts below `Idle` (not loaded /// here → less immediately actionable) but above `Done` / `Failed` /// (still live, resumable sessions). #[test] fn sort_rows_places_inactive_between_idle_and_done() { let mut rows = vec![ make_row("done", 0, RowState::Completed), make_row("inactive", 0, RowState::Inactive), make_row("idle", 0, RowState::Idle), make_row("failed", 0, RowState::Failed), ]; sort_rows(&mut rows, super::super::state::Grouping::State, &[]); let order: Vec = rows.iter().map(|r| r.state).collect(); assert_eq!( order, vec![ RowState::Idle, RowState::Inactive, RowState::Completed, RowState::Failed, ], ); } }