From 378cd125b21563e813ca9e80c23d56da9e25ab76 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:32:24 -0500 Subject: [PATCH 1/4] Agent: show tasks that wait on a question or failed in the sidebar A task paused on an ask_user question looked the same as one still running, and a run that failed off screen left no trace in the list. The row indicator now has four states: a coral dot while a question waits, the spinner while a run goes, an alert once a run failed unseen, and the green dot once one completed unseen. The screen pushes them as one TaskActivity. A question outranks the run it paused; a run outranks how the previous one ended. The failed marker follows the unread one: a failure off screen sets it, opening or archiving the task clears it, and a stop sets nothing. Questions arriving, answered, skipped, resolved, or dropped with their run now update the row. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/computers.rs | 4 +- apps/maple-agent/app/src/ui/chat/mod.rs | 96 +++++++++++++------ apps/maple-agent/app/src/ui/chat/sidebar.rs | 96 ++++++++++++------- apps/maple-agent/app/src/ui/chat/tests.rs | 86 ++++++++++++++++- 4 files changed, 215 insertions(+), 67 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index 5e8a9a582..ea3d60c22 100644 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -258,8 +258,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); } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index abcb5e3d7..41a67046b 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,52 @@ 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. + 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)); + } + + /// 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 +1351,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 +3352,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 +4087,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 +5240,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 +5275,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 +5697,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 +6205,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 +6287,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 +6348,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 +6462,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..988dcdb27 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -185,10 +185,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(super) struct TaskActivity { + pub(super) running: HashSet, + /// Tasks with a question waiting for an answer. + pub(super) waiting: HashSet, + pub(super) failed: HashSet, + pub(super) 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 +633,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 +966,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 +1075,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 +1565,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) { @@ -1662,7 +1679,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) { @@ -1794,7 +1811,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,18 +4423,27 @@ 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. +/// A coral dot while a question waits, a spinner while the task runs, an +/// alert once a run failed unseen, and a green dot once one completed +/// unseen. Each state has its own shape, so none relies on color alone. +/// The spinner keeps the sidebar repainting while any task runs; +/// `spinner_id` is prebuilt so render allocates nothing. 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() + }; + match activity { + SessionActivity::NeedsYou => dot(theme::accent()), + SessionActivity::Running => spinner_with_id(spinner_id.clone(), px(13.), theme::accent()), + SessionActivity::Failed => { + icon("circle-alert", px(13.), theme::status_error()).into_any_element() + } + SessionActivity::CompletedUnread => dot(theme::status_success()), } } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index d0abbecf7..12cb6ab9a 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -9628,7 +9628,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 +9652,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 +9683,84 @@ 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; answering 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; + + 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) + ); + + 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) + ); }); } From 6b641f04a297828aa41a892e96d9b300d7f578ce Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:54:14 -0500 Subject: [PATCH 2/4] Agent: breathe the sidebar's running indicator The turning arc steps visibly at the shared clock's low frame rate. A running task's row now shows a coral ring whose center dot fades in and out on that same clock, which reads as smooth at the same cost. Only the sidebar row changes; the loading spinner elsewhere stays. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 69 ++++++++++++++++----- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 988dcdb27..d62f15cda 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; @@ -4423,28 +4425,65 @@ fn row_fade_background(color: u32) -> gpui::Background { ) } -/// A coral dot while a question waits, a spinner while the task runs, an -/// alert once a run failed unseen, and a green dot once one completed -/// unseen. Each state has its own shape, so none relies on color alone. -/// 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 { let dot = |color: u32| { div() .size(px(8.)) - .flex_none() .rounded_full() .bg(gpui::rgb(color)) .into_any_element() }; - match activity { - SessionActivity::NeedsYou => dot(theme::accent()), - SessionActivity::Running => spinner_with_id(spinner_id.clone(), px(13.), theme::accent()), - SessionActivity::Failed => { - icon("circle-alert", px(13.), theme::status_error()).into_any_element() - } - SessionActivity::CompletedUnread => dot(theme::status_success()), - } + 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 From 73b683be3914f7abf96afca75438cea06d05da47 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:40:41 -0500 Subject: [PATCH 3/4] Agent: show the new row states on other computers' tasks Rows for another computer's tasks only knew running and unread, so a task waiting on a question or one that failed looked idle there. The computer snapshot now carries the same task activity as this computer's sidebar, and a question arriving or going away refreshes it. Deleting a task or archiving its project also left a failed marker behind, so both now clear it along with the unread one. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/computers.rs | 9 +-- apps/maple-agent/app/src/ui/chat/dialogs.rs | 3 +- apps/maple-agent/app/src/ui/chat/mod.rs | 2 + apps/maple-agent/app/src/ui/chat/sidebar.rs | 14 ++-- .../app/src/ui/chat/sidebar_computers.rs | 40 +++++----- .../app/src/ui/chat/sidebar_ordering.rs | 8 +- .../app/src/ui/chat/sidebar_paging.rs | 26 +++---- apps/maple-agent/app/src/ui/chat/tests.rs | 76 ++++++++++++++++--- 8 files changed, 119 insertions(+), 59 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index ea3d60c22..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), @@ -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 41a67046b..37f386a7e 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -976,10 +976,12 @@ impl ChatScreen { } /// 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. diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index d62f15cda..87715636c 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -198,12 +198,12 @@ pub(super) enum SessionActivity { /// 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(super) struct TaskActivity { - pub(super) running: HashSet, +pub(crate) struct TaskActivity { + pub(crate) running: HashSet, /// Tasks with a question waiting for an answer. - pub(super) waiting: HashSet, - pub(super) failed: HashSet, - pub(super) unread: HashSet, + pub(crate) waiting: HashSet, + pub(crate) failed: HashSet, + pub(crate) unread: HashSet, } impl TaskActivity { @@ -1667,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; @@ -1744,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( 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 12cb6ab9a..059273112 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); }); } @@ -6150,6 +6192,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 +6209,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 +8227,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, @@ -9733,11 +9776,12 @@ mod state_tests { } /// A question puts its task's row in the needs-you state ahead of the - /// run it paused; answering it, or the run ending, clears that. + /// 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; + use maple_agent::agent::{AgentRunEvent, AgentRunTerminal}; let screen = screen(cx); screen.update(cx, |this, cx| { @@ -9750,6 +9794,12 @@ mod state_tests { 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 { @@ -9761,6 +9811,15 @@ mod state_tests { 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); }); } @@ -11757,8 +11816,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, From 3615d6fc87d7b410bc0c9761280866ce2cee6a95 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:52:01 -0500 Subject: [PATCH 4/4] Agent: cover the failed marker on project archive and answers Archiving a project clears its tasks' unseen markers, but no test checked the failed one, or that another project's markers stay. The project removal test now seeds both markers in each project and checks both sides. The queued questions test also checks the sidebar row goes from needs-you to idle once the last question is answered, like the skip test does. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/tests.rs | 26 ++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 059273112..a43cfe5ed 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -2246,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); @@ -2260,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); }); } @@ -4694,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; @@ -4721,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()