diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index 5e8a9a582..f7529d237 100644 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -65,8 +65,7 @@ pub(crate) struct Computer { pub project_names: HashMap, pub projects: Vec, pub sessions: Vec, - pub running: HashSet, - pub unread: HashSet, + pub activity: TaskActivity, pub pinned: Vec, pub loading: bool, pub error: Option, @@ -81,8 +80,7 @@ impl Computer { project_names: HashMap::new(), projects: Vec::new(), sessions: Vec::new(), - running: HashSet::new(), - unread: HashSet::new(), + activity: TaskActivity::default(), pinned: Vec::new(), loading: error.is_none(), error: error.map(Into::into), @@ -258,8 +256,8 @@ impl ChatScreen { .update(cx, |sidebar, cx| sidebar.set_computers(computers, cx)); } if visible - && let Some(id) = &self.selected_session - && self.completed_unread_sessions.remove(id) + && let Some(id) = self.selected_session.clone() + && self.mark_seen(&id) { self.sync_sidebar(cx); } @@ -302,8 +300,7 @@ impl ChatScreen { .project_is_removed(&session.project_root) }) .collect(), - running: self.active_runs.keys().cloned().collect(), - unread: self.completed_unread_sessions.clone(), + activity: self.task_activity(), pinned: self.sidebar.read(cx).pinned_tasks().to_vec(), loading: !self.navigation_ready && self.host_recovery.events.is_some(), error: self.host_recovery.unavailable.then(|| { diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index 1f293370c..d82d11f57 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -515,7 +515,7 @@ impl ChatScreen { return; }; let removed = self.sessions.remove(index); - self.completed_unread_sessions.remove(session_id); + self.mark_seen(session_id); self.timeline_revisions.remove(session_id); self.pending_questions .retain(|question| question.session_id != session_id); @@ -581,6 +581,7 @@ impl ChatScreen { if session.project_root == removed { session.state = AgentTaskState::Archived; this.completed_unread_sessions.remove(&session.id); + this.failed_unread_sessions.remove(&session.id); } } this.sidebar.update(cx, |sidebar, cx| { diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index abcb5e3d7..37f386a7e 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -61,7 +61,7 @@ use self::composer::{SideQuestionPanel, SlashEntry, slash_entries_for}; use self::navigation::ApplicationVimState; #[cfg(test)] use self::sidebar::SessionActivity; -use self::sidebar::{Sidebar, SidebarEvent, root_display_name, session_summary_eq}; +use self::sidebar::{Sidebar, SidebarEvent, TaskActivity, root_display_name, session_summary_eq}; use self::transcript::{ ActiveSubagent, PlanEntry, PlanStatus, plan_entries, render_question_card, render_waiting_indicator, @@ -453,6 +453,9 @@ pub struct ChatScreen { /// terminal event. This is intentionally independent of selection and /// project grouping; the sidebar derives presentation from it. completed_unread_sessions: HashSet, + /// Tasks whose last run failed and that have not been opened since; + /// kept the same way as `completed_unread_sessions`. + failed_unread_sessions: HashSet, /// Ids of runs whose Finished event already arrived, newest last and /// capped at `FINISHED_RUNS_KEPT`. A send that returns after its run /// finished must not mark that run active again. @@ -955,24 +958,54 @@ impl ChatScreen { } /// Push everything the sidebar shows: sessions, the selection, which - /// tasks run or finished unread, and the recent roots. Called when + /// tasks wait, run, or ended unseen, and the recent roots. Called when /// any of them changed; the sidebar rebuilds only what moved. fn sync_sidebar(&mut self, cx: &mut Context) { let sessions = self.sessions.clone(); let selected = self.selected_session.clone(); - let running: HashSet = self.active_runs.keys().cloned().collect(); - let unread = self.completed_unread_sessions.clone(); + let activity = self.task_activity(); let roots = self.recent_roots.clone(); self.sidebar.update(cx, |sidebar, cx| { sidebar.set_sessions(sessions, cx); sidebar.set_selected(selected, cx); - sidebar.set_activity(running, unread, cx); + sidebar.set_activity(activity, cx); sidebar.set_recent_roots(roots, cx); }); self.refresh_selected_title(); cx.emit(NavigationChanged); } + /// Push only the row indicators: a question arrived or went away. + /// Other computers' sidebars show them too. + fn sync_sidebar_activity(&mut self, cx: &mut Context) { + let activity = self.task_activity(); + self.sidebar + .update(cx, |sidebar, cx| sidebar.set_activity(activity, cx)); + cx.emit(NavigationChanged); + } + + /// Which tasks wait on the user, run, or ended unseen. + fn task_activity(&self) -> TaskActivity { + TaskActivity { + running: self.active_runs.keys().cloned().collect(), + waiting: self + .pending_questions + .iter() + .map(|question| question.session_id.clone()) + .collect(), + failed: self.failed_unread_sessions.clone(), + unread: self.completed_unread_sessions.clone(), + } + } + + /// Seeing a task reads how its last run ended. Reports whether that + /// cleared a marker. + fn mark_seen(&mut self, session_id: &str) -> bool { + let completed = self.completed_unread_sessions.remove(session_id); + let failed = self.failed_unread_sessions.remove(session_id); + completed || failed + } + fn sync_sidebar_selection(&mut self, cx: &mut Context) { let selected = self.selected_session.clone(); self.sidebar @@ -1320,6 +1353,7 @@ impl ChatScreen { pages: paging::TranscriptPages::default(), active_runs: HashMap::new(), completed_unread_sessions: HashSet::new(), + failed_unread_sessions: HashSet::new(), finished_runs: std::collections::VecDeque::new(), session_setup_pending: false, first_send_waiting_for_mcp: false, @@ -3320,11 +3354,11 @@ impl ChatScreen { } self.tool_summaries.extend(stored_summaries); let project_root = session.project_root.clone(); - // Opening the task is what reads its completion, so the unread - // marker goes. Only an explicit settle ever moves a task out of - // the active inbox. + // Opening the task is what reads how its last run ended, so the + // unread or failed marker goes. Only an explicit settle ever moves + // a task out of the active inbox. if self.visible { - self.completed_unread_sessions.remove(&session.id); + self.mark_seen(&session.id); } self.selected_session = Some(session.id.clone()); self.draft = false; @@ -4055,13 +4089,7 @@ impl ChatScreen { #[cfg(test)] fn session_activity(&self, session_id: &str) -> Option { - if self.active_runs.contains_key(session_id) { - Some(SessionActivity::Running) - } else if self.completed_unread_sessions.contains(session_id) { - Some(SessionActivity::CompletedUnread) - } else { - None - } + self.task_activity().of(session_id) } /// Open the task before or after the selected one, in sidebar order. @@ -5214,6 +5242,7 @@ impl ChatScreen { self.pending_questions .retain(|queued| queued.request_id != question.request_id); self.reset_question_card(cx); + self.sync_sidebar_activity(cx); cx.notify(); self.call( async move { backend.answer_question(&user_id, &request_id, answer).await }, @@ -5248,6 +5277,7 @@ impl ChatScreen { self.pending_questions .retain(|queued| queued.request_id != question.request_id); self.reset_question_card(cx); + self.sync_sidebar_activity(cx); cx.notify(); let backend = self.backend.clone(); let user_id = self.user_id.clone(); @@ -5669,7 +5699,7 @@ impl ChatScreen { }); // An archived row shows no indicator; drop a marker it may carry. if session.state == AgentTaskState::Archived { - self.completed_unread_sessions.remove(&session.id); + self.mark_seen(&session.id); } if let Some(existing) = self .sessions @@ -6177,6 +6207,7 @@ impl ChatScreen { if showing { self.question_selected.clear(); } + self.sync_sidebar_activity(cx); } AgentServiceEvent::ThinkingChanged { session_id, state } => { self.apply_thinking_changed(&session_id, state, cx); @@ -6258,6 +6289,7 @@ impl ChatScreen { request_id, questions, }); + self.sync_sidebar_activity(cx); if shows_now { // A fresh question takes the card; follow-ups queue // behind it and keep the user's picks on this one. @@ -6318,8 +6350,11 @@ impl ChatScreen { let before = self.pending_questions.len(); self.pending_questions .retain(|question| question.session_id != session_id); - if self.pending_questions.len() != before && self.is_selected(session_id) { - self.reset_question_card(cx); + if self.pending_questions.len() != before { + if self.is_selected(session_id) { + self.reset_question_card(cx); + } + self.sync_sidebar_activity(cx); } } @@ -6429,15 +6464,22 @@ impl ChatScreen { .is_none_or(|active| active == run_id); if owns_session { self.active_runs.remove(session_id); - // The marker follows the latest terminal: only a - // successful completion off screen sets it. Opening the - // task (`set_active_session`) is the only other writer. - self.completed_unread_sessions.remove(session_id); - if matches!(terminal, maple_agent::agent::AgentRunTerminal::Completed) - && !(self.visible && self.is_selected(session_id)) - { - self.completed_unread_sessions - .insert(session_id.to_string()); + // The markers follow the latest terminal: a completion + // or a failure off screen sets one; a stop sets none. + // Opening the task (`set_active_session`) is the only + // other writer. + self.mark_seen(session_id); + if !(self.visible && self.is_selected(session_id)) { + match terminal { + maple_agent::agent::AgentRunTerminal::Completed => { + self.completed_unread_sessions + .insert(session_id.to_string()); + } + maple_agent::agent::AgentRunTerminal::Failed => { + self.failed_unread_sessions.insert(session_id.to_string()); + } + maple_agent::agent::AgentRunTerminal::Cancelled => {} + } } // Waking or retiring a run moves a task between // sections; the entries must follow the same frame. diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 432911fc0..87715636c 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -76,6 +76,7 @@ pub(crate) struct BrowseState { use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; use gpui::{ AnyElement, AppContext, Context, Div, Entity, EventEmitter, Focusable, SharedString, @@ -90,7 +91,8 @@ use super::navigation::SidebarTarget; use super::{ChatScreen, section_label}; use crate::backend::AgentBackend; use crate::settings::{SidebarGroup, SidebarItemKey, SidebarOrder, SidebarSort, SidebarView}; -use crate::ui::icons::{icon, spinner_with_id, wordmark}; +use crate::ui::icons::{icon, wordmark}; +use crate::ui::motion; use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; use crate::ui::theme; @@ -185,10 +187,43 @@ pub(super) enum SidebarEvent { /// Activity a task row indicates beside its title. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SessionActivity { + /// A question waits for the user's answer. + NeedsYou, Running, + /// The last run failed and the task was not opened since. + Failed, CompletedUnread, } +/// Which tasks wait on the user, run, or ended while nobody watched. The +/// screen owns these sets and pushes them together. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct TaskActivity { + pub(crate) running: HashSet, + /// Tasks with a question waiting for an answer. + pub(crate) waiting: HashSet, + pub(crate) failed: HashSet, + pub(crate) unread: HashSet, +} + +impl TaskActivity { + /// What a task's row shows. A question outranks the run it paused, + /// and a run outranks how the previous one ended. + pub(super) fn of(&self, session_id: &str) -> Option { + if self.waiting.contains(session_id) { + Some(SessionActivity::NeedsYou) + } else if self.running.contains(session_id) { + Some(SessionActivity::Running) + } else if self.failed.contains(session_id) { + Some(SessionActivity::Failed) + } else if self.unread.contains(session_id) { + Some(SessionActivity::CompletedUnread) + } else { + None + } + } +} + /// What an inline rename edits. #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum RenameTarget { @@ -600,10 +635,7 @@ pub(super) struct Sidebar { // What the screen pushes in. sessions: Vec, selected: Option, - /// Tasks with a run in flight. - running: HashSet, - /// Tasks that finished while not on screen. - unread: HashSet, + activity: TaskActivity, recent_roots: Vec, application_vim_enabled: bool, /// Application Vim's region is the sidebar, so rows show its @@ -936,8 +968,7 @@ impl Sidebar { chat, sessions: Vec::new(), selected: None, - running: HashSet::new(), - unread: HashSet::new(), + activity: TaskActivity::default(), recent_roots: Vec::new(), application_vim_enabled, vim_active: false, @@ -1046,20 +1077,14 @@ impl Sidebar { cx.notify(); } - /// Which tasks run and which finished unread; both drive the row - /// indicator and the active section. - pub(super) fn set_activity( - &mut self, - running: HashSet, - unread: HashSet, - cx: &mut Context, - ) { - if self.running == running && self.unread == unread { + /// Which tasks wait on the user, run, or ended unseen. All of it + /// drives the row indicator; running also decides the active section. + pub(super) fn set_activity(&mut self, activity: TaskActivity, cx: &mut Context) { + if self.activity == activity { return; } - let sections_changed = self.running != running; - self.running = running; - self.unread = unread; + let sections_changed = self.activity.running != activity.running; + self.activity = activity; if sections_changed { self.rebuild_sections(); } @@ -1542,13 +1567,7 @@ impl Sidebar { // ---- Sections -------------------------------------------------------- pub(super) fn session_activity(&self, session_id: &str) -> Option { - if self.running.contains(session_id) { - Some(SessionActivity::Running) - } else if self.unread.contains(session_id) { - Some(SessionActivity::CompletedUnread) - } else { - None - } + self.activity.of(session_id) } pub(super) fn set_archived_expanded(&mut self, expanded: bool, cx: &mut Context) { @@ -1648,7 +1667,7 @@ impl Sidebar { for (index, session) in self.sessions.iter().enumerate() { if self.pages.enabled && !self.pages.contains(&session.id, filtering) - && !self.running.contains(&session.id) + && !self.activity.running.contains(&session.id) && self.selected.as_ref() != Some(&session.id) { continue; @@ -1662,7 +1681,7 @@ impl Sidebar { } // The cached count updates when a run ends. Its first run // already makes the task visible while that count is still zero. - if session.message_count == 0 && !self.running.contains(&session.id) { + if session.message_count == 0 && !self.activity.running.contains(&session.id) { continue; } if scoped.is_some_and(|scoped| scoped != root) { @@ -1725,7 +1744,7 @@ impl Sidebar { if let Some(prefix) = self.local_task_prefix() { listed.retain(|&ix| { let task = &self.sessions[ix]; - self.running.contains(&task.id) + self.activity.running.contains(&task.id) || self.selected.as_ref() == Some(&task.id) || self.pages.is_live(&task.id) || prefix.allows(&self.task_prefix_position( @@ -1794,7 +1813,7 @@ impl Sidebar { /// task active the moment a run starts on it; the running set covers /// the frame before its updated record lands. fn session_is_active(&self, session: &AgentSessionSummary) -> bool { - self.running.contains(&session.id) || session.state != AgentTaskState::Settled + self.activity.running.contains(&session.id) || session.state != AgentTaskState::Settled } /// Project bounded local-cache rows through the selected grouping. @@ -4406,19 +4425,65 @@ fn row_fade_background(color: u32) -> gpui::Background { ) } -/// A spinner while the task runs, a dot once it completed unseen. The -/// spinner keeps the sidebar repainting while any task runs; `spinner_id` -/// is prebuilt so render allocates nothing. +/// One breath of the running ring's dot. +const RUNNING_BREATH: Duration = Duration::from_millis(1600); + +/// A coral dot while a question waits, a ring with a breathing dot while +/// the task runs, an alert once a run failed unseen, and a green dot once +/// one completed unseen. The two dots differ by color; the ring and the +/// alert differ by shape. The ring runs on the shared low-rate clock, so +/// any number of running rows cost one repaint per interval; `spinner_id` +/// is prebuilt so render allocates nothing. Every state sits centered in +/// one box the size of the largest, so the center stays put as states +/// change. fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> AnyElement { - match activity { - SessionActivity::Running => spinner_with_id(spinner_id.clone(), px(13.), theme::accent()), - SessionActivity::CompletedUnread => div() + let dot = |color: u32| { + div() .size(px(8.)) - .flex_none() .rounded_full() - .bg(gpui::rgb(theme::status_success())) - .into_any_element(), - } + .bg(gpui::rgb(color)) + .into_any_element() + }; + div() + .size(px(13.)) + .flex_none() + .flex() + .items_center() + .justify_center() + .child(match activity { + SessionActivity::NeedsYou => dot(theme::accent()), + SessionActivity::Running => running_ring(spinner_id.clone()), + SessionActivity::Failed => { + icon("circle-alert", px(13.), theme::status_error()).into_any_element() + } + SessionActivity::CompletedUnread => dot(theme::status_success()), + }) + .into_any_element() +} + +/// The running state: a coral ring whose center dot fades in and out. A +/// fade reads as smooth at the shared clock's low frame rate, where a +/// turning arc steps visibly. +fn running_ring(id: SharedString) -> AnyElement { + motion::ticker(id, RUNNING_BREATH, |phase| { + div() + .size(px(12.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(gpui::rgb(theme::accent())) + .child( + div() + .size(px(6.)) + .rounded_full() + .bg(gpui::rgb(theme::accent())) + .opacity(motion::pulse(phase, 0., 0.3, 1.)), + ) + .into_any_element() + }) + .into_any_element() } /// The strip over a row's end that holds its hover buttons, with the diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs index cf9a2a2f4..ffbb00ee3 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -387,7 +387,7 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C }) .collect(); for session in &computer.sessions { - if session.message_count == 0 && !computer.running.contains(&session.id) { + if session.message_count == 0 && !computer.activity.running.contains(&session.id) { continue; } let project = computer @@ -597,8 +597,8 @@ impl Sidebar { let task = self.current_computer_task(computer, &task); if task.state != "archived" && (task.message_count > 0 - || computer.running.contains(&task.uuid) - || (own && self.running.contains(&task.uuid))) + || computer.activity.running.contains(&task.uuid) + || (own && self.activity.running.contains(&task.uuid))) { tasks.insert(task.uuid.clone(), task); } @@ -1329,7 +1329,9 @@ impl Sidebar { } .into(); } - if computer.running.contains(&task.uuid) || (own && self.running.contains(&task.uuid)) { + if computer.activity.running.contains(&task.uuid) + || (own && self.activity.running.contains(&task.uuid)) + { task.state = "active".into(); } task.pinned_ms = self.computer_task_pin(computer, &task); @@ -1577,10 +1579,10 @@ impl Sidebar { fn prefix_exempt_task(&self, computer: &Computer, task: &TaskSummary) -> bool { task.pinned_ms.is_some() - || computer.running.contains(&task.uuid) + || computer.activity.running.contains(&task.uuid) || (computer.key.as_deref() == self.backend.remote_host() && (self.selected.as_ref() == Some(&task.uuid) - || self.running.contains(&task.uuid) + || self.activity.running.contains(&task.uuid) || self.pages.is_live(&task.uuid))) } @@ -2074,7 +2076,8 @@ impl Sidebar { entries.push(SidebarEntry::Task(SidebarTaskEntry { session: index, pinned: self.computer_task_pin(computer, task).is_some(), - settled: state == AgentTaskState::Settled && !self.running.contains(&task.uuid), + settled: state == AgentTaskState::Settled + && !self.activity.running.contains(&task.uuid), archived: state == AgentTaskState::Archived, })); } @@ -2123,7 +2126,7 @@ impl Sidebar { let rung = if archived { TaskRung::Archived } else if session.state == AgentTaskState::Settled - && !computer.running.contains(&task.uuid) + && !computer.activity.running.contains(&task.uuid) { TaskRung::Settled } else { @@ -2148,13 +2151,7 @@ impl Sidebar { latest.map_or(task.title.as_str(), |session| session.title.as_str()), ), pinned, - activity: if computer.running.contains(&task.uuid) { - Some(SessionActivity::Running) - } else if computer.unread.contains(&task.uuid) { - Some(SessionActivity::CompletedUnread) - } else { - None - }, + activity: computer.activity.of(&task.uuid), }); entries.push(SidebarEntry::OtherTask(index)); } @@ -2166,6 +2163,12 @@ impl Sidebar { Some(self.other_tasks.get(index)?.title.as_ref()) } + /// What another computer's task row indicates. + #[cfg(test)] + pub(in crate::ui::chat) fn other_task_activity(&self, index: usize) -> Option { + self.other_tasks.get(index)?.activity + } + pub(in crate::ui::chat) fn computer_target( &self, entry: SidebarEntry, @@ -2559,8 +2562,7 @@ mod paging_tests { project_names: HashMap::new(), projects: Vec::new(), sessions: vec![session_from_summary(&task("head"))], - running: HashSet::new(), - unread: HashSet::new(), + activity: TaskActivity::default(), pinned: Vec::new(), loading: false, error: None, @@ -2950,7 +2952,7 @@ mod paging_tests { let mut computer = computer(); computer.sessions[0].message_count = 0; assert!(snapshot_page(&computer, "", false).groups.is_empty()); - computer.running.insert("head".into()); + computer.activity.running.insert("head".into()); let page = snapshot_page(&computer, "", false); assert_eq!(page.groups.len(), 1); assert_eq!(page.groups[0].tasks[0].uuid, "head"); @@ -3114,7 +3116,7 @@ mod merged_prefix_tests { sort: SidebarSort::Updated, }; sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); let mut local = Computer::pending("local".into(), "This Computer".into(), None); local.key = None; local.backend = Some(sidebar.backend.clone()); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index bb31f49e1..f065f8444 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -361,12 +361,12 @@ impl Sidebar { SidebarGroup::Project | SidebarGroup::Connection => TaskGroup::Project(target), SidebarGroup::Status => { let running = if target.computer.as_deref() == self.backend.remote_host() { - self.running.contains(id) + self.activity.running.contains(id) } else { self.computers .iter() .find(|computer| computer.key == target.computer) - .is_some_and(|computer| computer.running.contains(id)) + .is_some_and(|computer| computer.activity.running.contains(id)) }; TaskGroup::Status(state == AgentTaskState::Settled && !running) } @@ -640,7 +640,7 @@ impl Sidebar { } for host in self.computers.iter() { for task in &host.sessions { - if task.message_count > 0 || host.running.contains(&task.id) { + if task.message_count > 0 || host.activity.running.contains(&task.id) { loaded .entry(SidebarItemKey { computer: host.key.clone(), @@ -656,7 +656,7 @@ impl Sidebar { } } for task in self.sessions.iter() { - if task.message_count > 0 || self.running.contains(&task.id) { + if task.message_count > 0 || self.activity.running.contains(&task.id) { loaded .entry(SidebarItemKey { computer: computer.clone(), diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs index a07f4d0b2..20c5adc82 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -103,7 +103,7 @@ mod tests { sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); sidebar.archived_expanded = false; sidebar.refresh_pages(cx); assert!(sidebar.pages.loading(PageKind::Archived)); @@ -211,7 +211,7 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); // The prior grouped projection stays visible during refresh; the // new authoritative ordinary pages have all completed empty. sidebar.sessions = vec![summary("display-only", AgentTaskState::Active)]; @@ -248,7 +248,7 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); sidebar.sessions = (0..60) .map(|index| AgentSessionSummary { updated_ms: 100 + index, @@ -278,7 +278,7 @@ mod tests { // Explicit selection and running state keep their existing retention. sidebar.selected = Some("archived-0".into()); - sidebar.running.insert("archived-1".into()); + sidebar.activity.running.insert("archived-1".into()); sidebar.pages.autoselect_generation = Some(u64::MAX); sidebar.pages_ready(cx); }); @@ -409,7 +409,7 @@ mod tests { ..summary("selected-a", state) }, ]; - sidebar.running = HashSet::from(["running-a".into()]); + sidebar.activity.running = HashSet::from(["running-a".into()]); sidebar.selected = Some("selected-a".into()); sidebar.project_filter = Some("/b".into()); sidebar.refresh_pages(cx); @@ -733,7 +733,7 @@ mod tests { ..SidebarPages::default() }; sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); let original: Vec<_> = (0..count).map(|i| format!("loaded-{i}")).collect(); sidebar.sessions = original .iter() @@ -981,7 +981,7 @@ impl Sidebar { .filter(|session| { (session.state != AgentTaskState::Archived && self.pages.contains(&session.id, false)) - || self.running.contains(&session.id) + || self.activity.running.contains(&session.id) || self.selected.as_ref() == Some(&session.id) }) .cloned() @@ -1043,7 +1043,7 @@ impl Sidebar { }; self.sessions.retain(|s| { retained.contains(&s.id) - || self.running.contains(&s.id) + || self.activity.running.contains(&s.id) || self.selected.as_ref() == Some(&s.id) }); if self.filter.is_empty() { @@ -1084,7 +1084,7 @@ impl Sidebar { *current = summary; changed = true; } - } else if self.running.contains(&summary.id) + } else if self.activity.running.contains(&summary.id) || self.selected.as_ref() == Some(&summary.id) { self.sessions.push(summary); @@ -1410,7 +1410,7 @@ impl Sidebar { self.sessions.retain(|session| { self.pages.ids.values().any(|ids| ids.contains(&session.id)) || own.contains(&session.id) - || self.running.contains(&session.id) + || self.activity.running.contains(&session.id) || self.selected.as_ref() == Some(&session.id) }); self.task_project_labels.retain(|root, _| { @@ -1697,7 +1697,7 @@ mod merged_prefix_tests { sort: SidebarSort::Updated, }; sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); sidebar.computers = Arc::new(Vec::new()); sidebar.pages.enabled = true; for (kind, state, newest) in [ @@ -1783,7 +1783,7 @@ mod merged_prefix_tests { sort: SidebarSort::Updated, }; sidebar.selected = None; - sidebar.running.clear(); + sidebar.activity.running.clear(); sidebar.computers = Arc::new(Vec::new()); sidebar.pages.enabled = true; sidebar.apply_task_page(PageKind::Active, vec![summary("z", "active", 100)], false); @@ -1804,7 +1804,7 @@ mod merged_prefix_tests { // A selected/running task remains reachable outside the prefix. sidebar.pages.next.insert(PageKind::Settled, "more".into()); sidebar.selected = Some("a".into()); - sidebar.running.insert("z".into()); + sidebar.activity.running.insert("z".into()); sidebar.rebuild_sections(); assert_eq!(shown(sidebar), ["a", "z"]); }); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index d0abbecf7..a43cfe5ed 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -188,6 +188,7 @@ mod state_tests { this.sync_sidebar(cx); assert!( this.computer_snapshot("Office".into(), cx) + .activity .unread .contains("s1") ); @@ -220,13 +221,15 @@ mod state_tests { label: "proj".into(), }], sessions: vec![summary("same-id", "Remote\r\ntask")], - running: HashSet::from(["same-id".into()]), - unread: HashSet::new(), + activity: TaskActivity { + running: HashSet::from(["same-id".into()]), + ..TaskActivity::default() + }, pinned: Vec::new(), loading: false, error: None, }; - set_computer_snapshots(this, Arc::new(vec![local, remote]), cx); + set_computer_snapshots(this, Arc::new(vec![local.clone(), remote.clone()]), cx); this.sidebar.update(cx, |sidebar, _| { assert_eq!( sidebar @@ -270,6 +273,40 @@ mod state_tests { destination: Destination::Task("same-id".into()), }) ); + // Another computer's rows show its tasks' full status. + for (activity, shown) in [ + ( + TaskActivity { + running: HashSet::from(["same-id".into()]), + waiting: HashSet::from(["same-id".into()]), + ..TaskActivity::default() + }, + SessionActivity::NeedsYou, + ), + ( + TaskActivity { + failed: HashSet::from(["same-id".into()]), + ..TaskActivity::default() + }, + SessionActivity::Failed, + ), + ] { + let remote = Computer { + activity, + ..remote.clone() + }; + set_computer_snapshots(this, Arc::new(vec![local.clone(), remote]), cx); + let sidebar = this.sidebar.read(cx); + let Some(SidebarEntry::OtherTask(index)) = sidebar + .entries() + .iter() + .find(|e| matches!(e, SidebarEntry::OtherTask(_))) + .copied() + else { + panic!("the remote task has a row"); + }; + assert_eq!(sidebar.other_task_activity(index), Some(shown)); + } // The projection never merges foreign records into the local runtime list. assert_eq!(this.sessions.len(), 1); assert_eq!(this.sessions[0].title, "Local task"); @@ -2091,9 +2128,14 @@ mod state_tests { screen.update(cx, |this, cx| { this.handle_service_event(event, cx); assert!(!this.pending_questions.is_empty()); + assert_eq!( + this.sidebar.read(cx).session_activity("s1"), + Some(SessionActivity::NeedsYou) + ); this.skip_question(cx); assert!(this.pending_questions.is_empty()); assert!(this.question_selected.is_empty()); + assert_eq!(this.sidebar.read(cx).session_activity("s1"), None); }); } @@ -2204,6 +2246,10 @@ mod state_tests { this.handle_service_event(question("q2"), cx); assert_eq!(this.pending_questions.len(), 3); assert_eq!(this.pending_questions[0].request_id, "q1"); + assert_eq!( + this.sidebar.read(cx).session_activity("s1"), + Some(SessionActivity::NeedsYou) + ); // Answering pops the head and leaves the rest queued. this.answer_question("first".to_string(), cx); assert_eq!(this.pending_questions.len(), 2); @@ -2218,6 +2264,7 @@ mod state_tests { this.answer_question("third".to_string(), cx); assert!(this.pending_questions.is_empty()); assert!(this.pending_question_input.is_none()); + assert_eq!(this.sidebar.read(cx).session_activity("s1"), None); }); } @@ -4652,8 +4699,20 @@ mod state_tests { let backend = backend_with_mcp_servers(cx, "remove-waiting-project", Vec::new()); let alpha = mcp_project("remove-waiting-alpha"); let beta = mcp_project("remove-waiting-beta"); + // A task in each project, both carrying unseen markers. The test + // runtime is not running, so the archived project's task is + // already archived and the archive asks the backend for nothing. + let mut alpha_task = summary_at("alpha-task", "Alpha", &alpha); + alpha_task.state = AgentTaskState::Archived; + let beta_task = summary_at("beta-task", "Beta", &beta); + let (alpha_id, beta_id) = (alpha_task.id.clone(), beta_task.id.clone()); let (chat, cx) = chat_window_in(cx, backend, |this, cx| { this.selected_session = None; + this.sessions = vec![alpha_task, beta_task]; + for id in [&alpha_id, &beta_id] { + this.completed_unread_sessions.insert(id.clone()); + this.failed_unread_sessions.insert(id.clone()); + } this.project_root = Some(alpha.clone()); this.recent_roots = vec![recent_root(&alpha), recent_root(&beta)]; this.draft = true; @@ -4679,9 +4738,16 @@ mod state_tests { assert!(this.pending_first_send.is_none()); assert_eq!(this.selected_session, None); assert!( - this.sessions.is_empty(), + this.sessions + .iter() + .all(|session| session.id == alpha_id || session.id == beta_id), "no task was created for the withdrawn send" ); + // The archived project's markers go with it; the other's stay. + assert!(!this.completed_unread_sessions.contains(&alpha_id)); + assert!(!this.failed_unread_sessions.contains(&alpha_id)); + assert!(this.completed_unread_sessions.contains(&beta_id)); + assert!(this.failed_unread_sessions.contains(&beta_id)); assert!( this.prompt_history .iter() @@ -6150,6 +6216,7 @@ mod state_tests { screen.update(cx, |this, cx| { this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; this.completed_unread_sessions.insert("s1".to_string()); + this.failed_unread_sessions.insert("s1".to_string()); this.sync_sidebar(cx); this.settle_task("s1", cx); assert_eq!(this.sidebar_settled(cx), vec![0]); @@ -6166,6 +6233,7 @@ mod state_tests { assert!(this.selected_session.is_none()); assert_eq!(this.loading_session.as_deref(), Some("s2")); assert!(!this.completed_unread_sessions.contains("s1")); + assert!(!this.failed_unread_sessions.contains("s1")); assert!(this.sidebar_settled(cx).is_empty()); assert_eq!(this.sidebar_active(cx), vec![0]); @@ -8183,8 +8251,7 @@ mod state_tests { label: name.into(), }], sessions: vec![summary_at("same-id", "Existing task", "/shared")], - running: HashSet::new(), - unread: HashSet::new(), + activity: TaskActivity::default(), pinned: Vec::new(), loading: false, error: None, @@ -9628,7 +9695,8 @@ mod state_tests { } /// A queued turn that starts after a completion and then fails must - /// not leave the earlier completion's marker behind. + /// not leave the earlier completion's marker behind: the failure's + /// marker replaces it. #[gpui::test] fn test_later_failed_run_clears_an_earlier_unread_completion(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -9651,10 +9719,13 @@ mod state_tests { AgentRunEvent::Finished(AgentRunTerminal::Failed), cx, ); - assert_eq!(this.session_activity("s2"), None); + assert_eq!(this.session_activity("s2"), Some(SessionActivity::Failed)); + assert!(this.completed_unread_sessions.is_empty()); }); } + /// A failure off screen marks the task until it is opened; a stop + /// marks nothing, and neither counts as an unread completion. #[gpui::test] fn test_only_successful_background_completion_becomes_unread(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -9679,6 +9750,100 @@ mod state_tests { ); assert!(this.completed_unread_sessions.is_empty()); + assert_eq!( + this.session_activity("failed"), + Some(SessionActivity::Failed) + ); + assert_eq!(this.session_activity("cancelled"), None); + }); + } + + /// A failure on the open task is in front of the user: no marker. + /// Opening a task that failed off screen clears its marker. + #[gpui::test] + fn test_failed_marker_clears_when_the_task_is_opened(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + use maple_agent::agent::{AgentRunEvent, AgentRunTerminal}; + + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![ + summary_at("s1", "A", "/work/alpha"), + summary_at("s2", "B", "/work/beta"), + ]; + this.selected_session = Some("s1".to_string()); + for session in ["s1", "s2"] { + this.handle_run_event(session, "run", AgentRunEvent::Started, cx); + this.handle_run_event( + session, + "run", + AgentRunEvent::Finished(AgentRunTerminal::Failed), + cx, + ); + } + assert_eq!(this.session_activity("s1"), None); + assert_eq!(this.session_activity("s2"), Some(SessionActivity::Failed)); + assert_eq!( + this.sidebar.read(cx).session_activity("s2"), + Some(SessionActivity::Failed) + ); + + this.set_active_session( + summary_at("s2", "B", "/work/beta"), + Vec::new(), + HashMap::new(), + cx, + ); + assert_eq!(this.session_activity("s2"), None); + assert_eq!(this.sidebar.read(cx).session_activity("s2"), None); + }); + } + + /// A question puts its task's row in the needs-you state ahead of the + /// run it paused, here and in the snapshot other computers show; + /// resolving it, or the run ending, clears that. + #[gpui::test] + fn test_waiting_question_marks_its_task_until_resolved(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + use maple_agent::agent::{AgentRunEvent, AgentRunTerminal}; + + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.selected_session = Some("other".to_string()); + this.handle_run_event("s1", "run", AgentRunEvent::Started, cx); + this.apply_service_event(one_question("q1", "Which file?"), cx); + assert_eq!(this.session_activity("s1"), Some(SessionActivity::NeedsYou)); + assert_eq!( + this.sidebar.read(cx).session_activity("s1"), + Some(SessionActivity::NeedsYou) + ); + assert_eq!( + this.computer_snapshot("This computer".into(), cx) + .activity + .of("s1"), + Some(SessionActivity::NeedsYou) + ); + + this.apply_service_event( + AgentServiceEvent::QuestionResolved { + request_id: "req-q1".to_string(), + }, + cx, + ); + assert_eq!( + this.sidebar.read(cx).session_activity("s1"), + Some(SessionActivity::Running) + ); + + this.apply_service_event(one_question("q2", "Which branch?"), cx); + this.handle_run_event( + "s1", + "run", + AgentRunEvent::Finished(AgentRunTerminal::Cancelled), + cx, + ); + assert_eq!(this.sidebar.read(cx).session_activity("s1"), None); }); } @@ -11675,8 +11840,7 @@ mod state_tests { label: "Project".into(), }], sessions: Vec::new(), - running: HashSet::new(), - unread: HashSet::new(), + activity: TaskActivity::default(), pinned: Vec::new(), loading: false, error: None,