From 9ca1c6d36bbb1b1bad86a70e2f80e68d776edd8a Mon Sep 17 00:00:00 2001 From: marks Date: Wed, 28 Oct 2026 22:18:52 -0500 Subject: [PATCH 1/6] Agent: align computer disclosure and collapsed project spacing --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 35 +++++++---- .../app/src/ui/chat/sidebar_computers.rs | 63 ++++--------------- 2 files changed, 36 insertions(+), 62 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 71a457be0..e83e4099e 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -409,6 +409,7 @@ pub(super) enum SidebarSection { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SidebarHoverSection { Projects, + Computer(usize), Section(SidebarSection), Archived, } @@ -1867,8 +1868,14 @@ impl Sidebar { SidebarEntry::SectionLabel(label) => { section = Some(SidebarHoverSection::Section(*label)) } + SidebarEntry::Computer(index) => { + section = Some(SidebarHoverSection::Computer(*index)) + } SidebarEntry::ArchivedHeader => section = Some(SidebarHoverSection::Archived), - SidebarEntry::Task(_) | SidebarEntry::OtherTask(_) => {} + SidebarEntry::Task(_) + | SidebarEntry::OtherTask(_) + | SidebarEntry::ComputerProject(_) + | SidebarEntry::ComputerMore(_) => {} _ => section = None, } section @@ -1890,7 +1897,17 @@ impl Sidebar { ix.checked_sub(1).and_then(|ix| entries.get(ix)), Some(SidebarEntry::SectionLabel(_) | SidebarEntry::ArchivedHeader) ), - after: unarchived_task(entry) && !entries.get(ix + 1).is_some_and(unarchived_task), + after: match entry { + SidebarEntry::SectionLabel(SidebarSection::Project(index)) => self + .collapsed_projects + .contains(&self.project_roots[*index].target), + SidebarEntry::ComputerProject(index) => self + .collapsed_projects + .contains(&self.computer_projects[*index].target), + _ => { + unarchived_task(entry) && !entries.get(ix + 1).is_some_and(unarchived_task) + } + }, }) .collect(); let old_spacing = std::mem::replace(&mut self.row_spacing, row_spacing); @@ -2930,7 +2947,6 @@ impl Sidebar { ) -> AnyElement { let selected = self.selected.as_deref(); let chat = self.chat.clone(); - // Adjacent folded headings keep only their pill margins. let spacing = self.row_spacing.get(ix).copied().unwrap_or_default(); let heading_gap = spacing.before; let entry = match self.entries.get(ix).copied() { @@ -3167,14 +3183,8 @@ impl Sidebar { } None => div().into_any_element(), }; - let entry = if matches!( - self.entries.get(ix), - Some(SidebarEntry::Task(_) | SidebarEntry::OtherTask(_)) - ) { - div() - .when(spacing.after, |row| row.mb_2()) - .child(entry) - .into_any_element() + let entry = if spacing.after { + div().mb_2().child(entry).into_any_element() } else { entry }; @@ -3333,7 +3343,8 @@ impl Sidebar { fn section_hovered(&self, section: SidebarHoverSection) -> bool { self.hovered_section == Some(section) || (section == SidebarHoverSection::Projects - && matches!(self.hovered_section, Some(SidebarHoverSection::Section(label)) if label != SidebarSection::Pinned)) + && (matches!(self.hovered_section, Some(SidebarHoverSection::Computer(_))) + || matches!(self.hovered_section, Some(SidebarHoverSection::Section(label)) if label != SidebarSection::Pinned))) } /// Reserve the chevron's space; a folded section always shows it. 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 716a99701..db5be5129 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2359,7 +2359,6 @@ impl Sidebar { computer: computer.key.clone(), destination: Destination::Overview, }; - let disclosure_request = request.clone(); let action_group: SharedString = format!("computer-header-actions-{:?}", computer.key).into(); let new_project = Navigate { @@ -2395,6 +2394,8 @@ impl Sidebar { .on_click(cx.listener(move |this, _, _, cx| { if !this.toggle_computer_group(&request, cx) { cx.emit(SidebarEvent::Navigate(request.clone())); + } else { + this.hover_heading(SidebarEntry::Computer(index), cx); } })) .child( @@ -2403,61 +2404,23 @@ impl Sidebar { .flex() .items_center() .gap(FOLDER_HEADER_GAP) + .child(icon("monitor", FOLDER_ICON_SIZE, theme::text_muted())) .child( div() - .flex_none() - .relative() - .w(FOLDER_ICON_SIZE) - .h(FOLDER_ICON_SIZE) + .flex_1() + .min_w_0() .flex() .items_center() - .justify_center() - .child(icon("monitor", FOLDER_ICON_SIZE, theme::text_muted())) - .when(collapsible, |slot| { - slot.child( - div() - .id("computer-disclosure") - .role(gpui::Role::Button) - .aria_label(if expanded { - "Collapse connection" - } else { - "Expand connection" - }) - .aria_expanded(expanded) - .absolute() - .top_0() - .right(FOLDER_ICON_SIZE + px(3.)) - .w(FOLDER_HEADER_PADDING - gpui::rems(0.5)) - .h(FOLDER_ICON_SIZE) - .flex() - .items_center() - .justify_center() - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - this.toggle_computer_group( - &disclosure_request, - cx, - ); - })) - .child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, - px(12.), - theme::text_muted(), - )), - ) + .gap_1() + .child(div().min_w_0().truncate().child(computer.label.clone())) + .when(collapsible, |row| { + row.child(self.section_chevron( + expanded, + "computer-disclosure", + SidebarHoverSection::Computer(index), + )) }), ) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .child(computer.label.clone()), - ) .when( collapsible && !dragging && !self.sidebar_menu_open(), |row| { From a25b13905d1913c94efacc5795f7ebab45e1b9b5 Mon Sep 17 00:00:00 2001 From: marks Date: Wed, 28 Oct 2026 23:09:17 -0500 Subject: [PATCH 2/6] Agent: fade sidebar task titles and hover actions --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 101 +++++++++++++++--- .../app/src/ui/chat/sidebar_computers.rs | 38 ++++--- apps/maple-agent/app/src/ui/chat/tests.rs | 29 ++++- 3 files changed, 135 insertions(+), 33 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index e83e4099e..7eba33cf1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -99,6 +99,9 @@ const SEARCH_ROW_RADIUS: gpui::Pixels = px(6.); /// Corner radius of a sidebar row's hover and selected highlight. const ROW_RADIUS: gpui::Pixels = px(6.); +/// How far a cut-off task title fades into its row. +const ROW_FADE: gpui::Pixels = px(20.); + /// Geometry shared by the expanded wordmark row and the collapsed mark. pub(super) const WORDMARK_LEFT: gpui::Pixels = px(16.); pub(super) const WORDMARK_HEIGHT: gpui::Pixels = px(16.); @@ -208,8 +211,9 @@ pub(super) struct SidebarRow { pub(super) menu_archive_id: SharedString, pub(super) menu_delete_id: SharedString, pub(super) menu_reopen_id: SharedString, + /// The title on one line (see [`one_line`]). pub(super) title: SharedString, - /// Display name of the task's project, for metadata, search and accessibility. + /// Display name of the task's project on one line. pub(super) project_name: SharedString, connection_name: SharedString, metadata: SharedString, @@ -236,18 +240,26 @@ impl SidebarRow { menu_archive_id: SharedString::from(format!("archive-task-{id}")), menu_delete_id: SharedString::from(format!("delete-task-{id}")), menu_reopen_id: SharedString::from(format!("reopen-task-{id}")), - title: SharedString::from(session.title.clone()), - project_name: SharedString::from(project_name.to_string()), + title: one_line(&session.title), + project_name: one_line(project_name), connection_name: connection.to_owned().into(), - metadata: if connection.is_empty() { + metadata: one_line(&if connection.is_empty() { project_name.to_owned() } else { format!("{project_name} · {connection}") - } - .into(), + }), search: session.title.to_lowercase(), } } + + /// Preserve raw-title identity when line breaks are flattened. + fn built_from(&self, title: &str) -> bool { + if title.contains(['\r', '\n']) { + self.search == title.to_lowercase() && *self.title == *one_line(title) + } else { + !self.search.contains(['\r', '\n']) && *self.title == *title + } + } } // Folder tasks start at the header's name, after its icon and gap. @@ -1992,7 +2004,7 @@ impl Sidebar { let fresh = self.rows.len() == self.sessions.len() && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id - && row.title.as_ref() == session.title + && row.built_from(&session.title) && row.connection_name.as_ref() == connection && self.root_name_matches(&session.project_root, &row.project_name) }); @@ -2222,7 +2234,7 @@ impl Sidebar { /// Whether a cached row still has the current owner name or pending preview. fn root_name_matches(&self, root: &str, name: &str) -> bool { - self.root_name(root) == name + *one_line(&self.root_name(root)) == *name } // ---- Rename ------------------------------------------------------------ @@ -3943,6 +3955,12 @@ impl Sidebar { } else { theme::bg_sidebar_row_hover() }; + let row_bg = if is_selected { + theme::bg_sidebar_row_selected() + } else { + theme::bg_sidebar() + }; + let hover_group = (!renaming && !self.dragging).then_some(&row.group); div() .relative() .w_auto() @@ -3986,21 +4004,19 @@ impl Sidebar { .flex_col() .map(|column| match rename_field { Some(field) => column.child(field), - None => column - .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())), + None => { + column.child(row_line(row.title.clone(), hover_group, row_bg, hover_bg)) + } }) .when(self.show_task_metadata(is_pinned, archived), |column| { column.child( - div() + row_line(row.metadata.clone(), hover_group, row_bg, hover_bg) .text_xs() .text_color(gpui::rgb(if is_selected { theme::text_secondary() } else { theme::text_muted() - })) - .line_clamp(1) - .text_ellipsis() - .child(row.metadata.clone()), + })), ) }), ) @@ -4034,11 +4050,23 @@ impl Sidebar { .when(!renaming && !self.dragging, |el| { el.child( hover_actions(&row.group, hover_bg) + .pl_0() .when(is_pinned, |overlay| overlay.right(px(26.))) .when(!is_pinned, |overlay| { overlay.right_0().rounded_r(ROW_RADIUS) }) .pr_4() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_full() + .w(ROW_FADE) + .group_hover(row.group.clone(), |style| { + style.bg(row_fade_background(hover_bg)) + }), + ) .child(self.task_menu_trigger(row, task, window, cx)) .child(row_action( row.rename_id.clone(), @@ -4171,6 +4199,49 @@ impl Render for Sidebar { } } +/// Replace each line break, including CRLF, with one space. +fn one_line(text: &str) -> SharedString { + text.replace("\r\n", " ").replace(['\r', '\n'], " ").into() +} + +/// Fade a line into its row background; renames and drags omit the hover group. +fn row_line( + text: SharedString, + hover_group: Option<&SharedString>, + row_bg: u32, + hover_bg: u32, +) -> Div { + div() + .relative() + .overflow_hidden() + .whitespace_nowrap() + .child(text) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(ROW_FADE) + .bg(row_fade_background(row_bg)) + .when_some(hover_group, |fade, group| { + fade.group_hover(group.clone(), |style| { + style.bg(row_fade_background(hover_bg)) + }) + }), + ) +} + +/// Left to right from clear to `color`. +fn row_fade_background(color: u32) -> gpui::Background { + let color = gpui::rgb(color); + gpui::linear_gradient( + 90., + gpui::linear_color_stop(color.alpha(0.), 0.), + gpui::linear_color_stop(color, 1.), + ) +} + /// 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. 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 db5be5129..2b7c98331 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2093,7 +2093,7 @@ impl Sidebar { let session = session_from_summary(task); let pinned = self.computer_task_pin(computer, task).is_some(); let metadata = if self.show_task_metadata(pinned, archived) { - format!( + one_line(&format!( "{} · {}", self.computer_project_name( computer, @@ -2108,8 +2108,7 @@ impl Sidebar { } ), computer.label, - ) - .into() + )) } else { SharedString::default() }; @@ -2122,9 +2121,9 @@ impl Sidebar { destination: Destination::Task(task.uuid.clone()), }, id: format!("computer-task-{:?}-{}", computer.key, task.uuid).into(), - title: latest - .map_or_else(|| task.title.clone(), |session| session.title.clone()) - .into(), + title: one_line( + latest.map_or(task.title.as_str(), |session| session.title.as_str()), + ), pinned, activity: if computer.running.contains(&task.uuid) { Some(SessionActivity::Running) @@ -2138,6 +2137,12 @@ impl Sidebar { } } + /// The title on another computer's task row. + #[cfg(test)] + pub(in crate::ui::chat) fn other_task_title(&self, index: usize) -> Option<&str> { + Some(self.other_tasks.get(index)?.title.as_ref()) + } + pub(in crate::ui::chat) fn computer_target( &self, entry: SidebarEntry, @@ -2279,8 +2284,15 @@ impl Sidebar { SidebarEntry::OtherTask(index) => { let task = &self.other_tasks[index]; let request = task.request.clone(); + let row_bg = if selected { + theme::bg_sidebar_row_hover() + } else { + theme::bg_sidebar() + }; + let hover_group = (!dragging).then_some(&task.id); div() .id(task.id.clone()) + .group(task.id.clone()) .role(gpui::Role::ListBoxOption) .aria_label(task.title.clone()) .aria_selected(false) @@ -2315,20 +2327,12 @@ impl Sidebar { .min_w_0() .flex() .flex_col() - .child( - div() - .line_clamp(1) - .text_ellipsis() - .child(task.title.clone()), - ) + .child(row_line(task.title.clone(), hover_group, row_bg, hover_bg)) .when(!task.metadata.is_empty(), |column| { column.child( - div() + row_line(task.metadata.clone(), hover_group, row_bg, hover_bg) .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .line_clamp(1) - .text_ellipsis() - .child(task.metadata.clone()), + .text_color(gpui::rgb(theme::text_secondary())), ) }), ) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index d9fc3f56e..5f3e4f2b1 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -219,7 +219,7 @@ mod state_tests { path: "/tmp/proj".into(), label: "proj".into(), }], - sessions: vec![summary("same-id", "Remote task")], + sessions: vec![summary("same-id", "Remote\r\ntask")], running: HashSet::from(["same-id".into()]), unread: HashSet::new(), pinned: Vec::new(), @@ -249,6 +249,10 @@ mod state_tests { .iter() .find(|e| matches!(e, SidebarEntry::OtherTask(_))) .unwrap(); + let SidebarEntry::OtherTask(index) = entry else { + unreachable!() + }; + assert_eq!(sidebar.other_task_title(index), Some("Remote task")); assert_eq!( sidebar.computer_target(entry), Some(navigation::SidebarTarget::Computer(Navigate { @@ -9256,6 +9260,22 @@ mod state_tests { // A rename moves the row strings. this.upsert_session(summary("s1", "Renamed"), cx); assert_eq!(this.sidebar.read(cx).rows()[0].title.as_ref(), "Renamed"); + // Search retains raw line breaks. + for title in ["Two\nlines", "Two\r\nlines", "Two\rlines", "Two lines"] { + this.upsert_session(summary("s1", title), cx); + let sidebar = this.sidebar.read(cx); + assert_eq!(sidebar.rows()[0].title.as_ref(), "Two lines"); + assert_eq!(sidebar.rows()[0].search, title.to_lowercase()); + } + // Case-only renames still refresh the displayed title. + this.upsert_session(summary("s1", "two lines"), cx); + assert_eq!(this.sidebar.read(cx).rows()[0].title.as_ref(), "two lines"); + // Filtering reuses the normalized row cache. + this.upsert_session(summary("s1", "Two\nlines"), cx); + let built = this.sidebar.read(cx).rows()[0].title.as_ptr(); + this.set_sidebar_filter("two", cx); + assert_eq!(this.sidebar.read(cx).rows()[0].title.as_ptr(), built); + this.set_sidebar_filter("", cx); this.sidebar.update(cx, |sidebar, _| { sidebar.set_project_name_for_test("/tmp/proj", "Nice"); }); @@ -9263,6 +9283,13 @@ mod state_tests { this.sidebar.read(cx).rows()[0].project_name.as_ref(), "Nice" ); + this.sidebar.update(cx, |sidebar, _| { + sidebar.set_project_name_for_test("/tmp/proj", "Nice\nname"); + }); + assert_eq!( + this.sidebar.read(cx).rows()[0].project_name.as_ref(), + "Nice name" + ); }); } From 446602415a9c95ac7c4bde0764152d8bc25adb7d Mon Sep 17 00:00:00 2001 From: marks Date: Wed, 28 Oct 2026 23:37:58 -0500 Subject: [PATCH 3/6] Agent: hide empty archived task sections --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 9 +- .../app/src/ui/chat/sidebar_computers.rs | 133 ++++++++---- .../app/src/ui/chat/sidebar_paging.rs | 189 ++++++++++++++++-- 3 files changed, 272 insertions(+), 59 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 7eba33cf1..da002ed94 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -1516,7 +1516,7 @@ impl Sidebar { return; } self.archived_expanded = expanded; - if expanded && self.pages.enabled { + if expanded && self.pages.enabled && !self.pages.has_started(PageKind::Archived) { self.load_page(PageKind::Archived, cx); } self.refresh_computer_pages(cx); @@ -1840,7 +1840,7 @@ impl Sidebar { if empty && !self.pages.any_loading() && !self.filter.is_empty() { entries.push(SidebarEntry::Empty(SidebarEmpty::NoMatches)); } - if !self.archived_rows.is_empty() || (self.pages.enabled && self.filter.is_empty()) { + if !self.archived_rows.is_empty() { entries.push(SidebarEntry::ArchivedHeader); if self.archived_expanded { entries.extend(self.archived_rows.iter().map(|&session| { @@ -1853,7 +1853,10 @@ impl Sidebar { })); } } - if self.archived_expanded && self.pages.has_more(PageKind::Archived) { + if self.pages.has_more(PageKind::Archived) + && (self.archived_expanded + || (self.archived_rows.is_empty() && !self.pages.loading(PageKind::Archived))) + { entries.push(SidebarEntry::LoadMore(PageKind::Archived)); } entries 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 2b7c98331..180a82905 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1797,7 +1797,7 @@ impl Sidebar { .map(|group| (index, group)), ); } - if self.archived_expanded { + if self.archived_expanded || searching { let source = if searching { &page.search } else { @@ -1811,6 +1811,9 @@ impl Sidebar { } } } + if searching { + self.computer_archived_count = archived.len(); + } let flat_prefix = self.computer_flat_prefix(&hosts, None); let active_prefix = self.computer_flat_prefix(&hosts, Some(false)); let settled_prefix = self.computer_flat_prefix(&hosts, Some(true)); @@ -1955,53 +1958,48 @@ impl Sidebar { } } } - let count_failed = hosts.iter().any(|index| { - self.group_pages + let has_archived = !archived.is_empty() || (!searching && self.computer_archived_count > 0); + if has_archived { + entries.push(SidebarEntry::ArchivedHeader); + } + for &index in &hosts { + let computer = &computers[index]; + if self + .group_pages .hosts - .get(&computers[*index].key) + .get(&computer.key) .is_some_and(|page| page.errors.contains_key("archive-count")) - }); - if self.computer_archived_count > 0 || !archived.is_empty() || count_failed { - entries.push(SidebarEntry::ArchivedHeader); - for &index in &hosts { - let computer = &computers[index]; - if self - .group_pages - .hosts - .get(&computer.key) - .is_some_and(|page| page.errors.contains_key("archive-count")) - { + { + self.computer_more_entry( + &mut entries, + &computer.key, + ComputerPageKind::ArchiveCount, + None, + &format!("archived task count from {}", computer.label), + ); + } + } + if has_archived && self.archived_expanded { + for (index, task) in archived { + self.push_computer_task(&mut entries, &computers[index], &task); + } + if !searching { + for &index in &hosts { + let computer = &computers[index]; + let next = self + .group_pages + .hosts + .get(&computer.key) + .and_then(|page| page.next_archived.clone()); self.computer_more_entry( &mut entries, &computer.key, - ComputerPageKind::ArchiveCount, - None, - &format!("archived task count from {}", computer.label), + ComputerPageKind::Archived, + next, + &format!("more archived tasks from {}", computer.label), ); } } - if self.archived_expanded { - for (index, task) in archived { - self.push_computer_task(&mut entries, &computers[index], &task); - } - if !searching { - for &index in &hosts { - let computer = &computers[index]; - let next = self - .group_pages - .hosts - .get(&computer.key) - .and_then(|page| page.next_archived.clone()); - self.computer_more_entry( - &mut entries, - &computer.key, - ComputerPageKind::Archived, - next, - &format!("more archived tasks from {}", computer.label), - ); - } - } - } } if searching && entries.len() == group_start && !has_pins && self.projects_expanded { entries.push(SidebarEntry::Empty(SidebarEmpty::NoMatches)); @@ -2549,6 +2547,61 @@ mod paging_tests { sidebar.rebuild_sections(); } + #[gpui::test] + fn archived_header_uses_known_counts_without_hiding_count_retries( + cx: &mut gpui::TestAppContext, + ) { + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let sidebar = test_sidebar(cx, backend, "user"); + sidebar.update(cx, |sidebar, _| { + seed_browse_pages(sidebar); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + + let remote = Some("remote".into()); + sidebar.group_pages.hosts.get_mut(&remote).unwrap().archived_count = 3; + sidebar.rebuild_sections(); + assert!(sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert!(sidebar.group_pages.hosts[&remote].archived.is_empty()); + assert!(!sidebar.archived_expanded); + + let page = sidebar.group_pages.hosts.get_mut(&remote).unwrap(); + page.archived_count = 0; + page.errors.insert("archive-count".into(), "Unavailable".into()); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert_eq!( + sidebar + .entries + .iter() + .filter(|entry| matches!(entry, SidebarEntry::ComputerMore(index) + if matches!(sidebar.group_pages.more[*index].kind, ComputerPageKind::ArchiveCount))) + .count(), + 1, + "count errors remain retryable without an empty heading" + ); + + let page = sidebar.group_pages.hosts.get_mut(&remote).unwrap(); + page.errors.clear(); + page.archived_count = 3; + sidebar.filter = "needle".into(); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + + sidebar.group_pages.hosts.get_mut(&remote).unwrap().search.push(TaskSummary { + title: "needle archive".into(), + state: "archived".into(), + ..task("archived-hit") + }); + sidebar.rebuild_sections(); + assert!(sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert_eq!(sidebar.computer_archived_count, 1); + assert!(!sidebar.archived_expanded); + }); + } + fn visible_tasks(sidebar: &Sidebar) -> Vec { sidebar .entries 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 1e32b7085..a07f4d0b2 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -85,17 +85,112 @@ mod tests { sidebar.rebuild_sections(); assert_eq!( sidebar.entries, - [ - SidebarEntry::NewTask, - SidebarEntry::ProjectsHeader, - SidebarEntry::ArchivedHeader, - ] + [SidebarEntry::NewTask, SidebarEntry::ProjectsHeader] ); } } }); } + #[gpui::test] + fn archived_header_follows_prefetched_and_live_tasks(cx: &mut gpui::TestAppContext) { + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); + let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.selected = None; + sidebar.running.clear(); + sidebar.archived_expanded = false; + sidebar.refresh_pages(cx); + assert!(sidebar.pages.loading(PageKind::Archived)); + sidebar.refresh_task_order(cx); + assert!(sidebar.pages.loading(PageKind::Archived)); + // Keep this fixture's explicit pages independent of the pending reads. + sidebar.pages = SidebarPages { + enabled: true, + started: HashSet::from([PageKind::Archived]), + ..Default::default() + }; + sidebar.apply_task_page(PageKind::Archived, Vec::new(), false); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + + sidebar.apply_task_page( + PageKind::Archived, + vec![TaskSummary { + state: "archived".into(), + ..page_summary("old") + }], + false, + ); + sidebar.rebuild_sections(); + assert!(sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert!( + !sidebar + .entries + .iter() + .any(|entry| { matches!(entry, SidebarEntry::Task(task) if task.archived) }) + ); + + sidebar + .pages + .next + .insert(PageKind::Archived, "continuation".into()); + sidebar.set_archived_expanded(true, cx); + assert!(!sidebar.pages.loading(PageKind::Archived)); + assert_eq!(sidebar.pages.next[&PageKind::Archived], "continuation"); + assert!( + sidebar + .entries + .iter() + .any(|entry| { matches!(entry, SidebarEntry::Task(task) if task.archived) }) + ); + sidebar.set_archived_expanded(false, cx); + + sidebar.apply_live_summary(summary("old", AgentTaskState::Active), cx); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + sidebar.apply_live_summary(summary("old", AgentTaskState::Archived), cx); + assert!(sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + sidebar.forget_page_tasks(&["old".into()]); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert_eq!(sidebar.pages.next[&PageKind::Archived], "continuation"); + assert!( + sidebar + .entries + .contains(&SidebarEntry::LoadMore(PageKind::Archived)), + "the unread archived suffix remains reachable without a cached row" + ); + + sidebar.pages.next.remove(&PageKind::Archived); + sidebar.pages.failed.insert(PageKind::Archived); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert_eq!( + sidebar + .entries + .iter() + .filter(|entry| **entry == SidebarEntry::LoadMore(PageKind::Archived)) + .count(), + 1, + "failed archive reads remain retryable without an empty heading" + ); + sidebar.pages.failed.remove(&PageKind::Archived); + sidebar.pages.loading.insert(PageKind::Archived); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(&SidebarEntry::ArchivedHeader)); + assert!( + !sidebar + .entries + .contains(&SidebarEntry::LoadMore(PageKind::Archived)) + ); + }); + } + #[gpui::test] fn fresh_empty_pages_do_not_publish_retained_display_rows(cx: &mut gpui::TestAppContext) { let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); @@ -133,6 +228,72 @@ mod tests { assert!(published[0].is_empty()); } + #[gpui::test] + fn prefetched_archives_do_not_displace_startup_candidates(cx: &mut gpui::TestAppContext) { + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); + let published = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + chat.update(cx, |chat, cx| { + let published = published.clone(); + cx.subscribe(&chat.sidebar, move |_, _, event: &SidebarEvent, _| { + if let SidebarEvent::PagesReady { sessions, .. } = event { + published.borrow_mut().push(sessions.clone()); + } + }) + .detach(); + }); + let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.selected = None; + sidebar.running.clear(); + sidebar.sessions = (0..60) + .map(|index| AgentSessionSummary { + updated_ms: 100 + index, + ..summary(&format!("archived-{index}"), AgentTaskState::Archived) + }) + .collect(); + let archived = sidebar.sessions.iter().map(|s| s.id.clone()).collect(); + sidebar + .sessions + .push(summary("active", AgentTaskState::Active)); + sidebar.pages = SidebarPages { + enabled: true, + ready: HashSet::from([PageKind::Active, PageKind::Settled, PageKind::Pinned]), + ids: HashMap::from([ + (PageKind::Archived, archived), + (PageKind::Active, vec!["active".into()]), + ]), + autoselect_generation: Some(u64::MAX), + ..Default::default() + }; + sidebar.pages_ready(cx); + assert_eq!( + sidebar.sessions.len(), + 61, + "archives stay in the sidebar cache" + ); + + // Explicit selection and running state keep their existing retention. + sidebar.selected = Some("archived-0".into()); + sidebar.running.insert("archived-1".into()); + sidebar.pages.autoselect_generation = Some(u64::MAX); + sidebar.pages_ready(cx); + }); + let published = published.borrow(); + assert_eq!(published.len(), 2); + let ids = |index: usize| { + published[index] + .iter() + .map(|session| session.id.as_str()) + .collect::>() + }; + assert_eq!(ids(0), ["active"]); + assert_eq!(ids(1), ["archived-1", "archived-0", "active"]); + } + #[gpui::test] fn known_empty_folders_wait_for_complete_task_pages(cx: &mut gpui::TestAppContext) { let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); @@ -813,11 +974,13 @@ impl Sidebar { // A normal refresh may keep grouped rows visible while its owner read // is pending. Those display-only rows cannot become fresh navigation // authority or auto-selection candidates after empty first pages. + // Prefetched archives likewise do not consume the startup candidate budget. let mut sessions = self .sessions .iter() .filter(|session| { - self.pages.contains(&session.id, false) + (session.state != AgentTaskState::Archived + && self.pages.contains(&session.id, false)) || self.running.contains(&session.id) || self.selected.as_ref() == Some(&session.id) }) @@ -852,9 +1015,7 @@ impl Sidebar { self.load_page(PageKind::Active, cx); self.load_page(PageKind::Settled, cx); self.load_page(PageKind::Pinned, cx); - if self.archived_expanded { - self.load_page(PageKind::Archived, cx); - } + self.load_page(PageKind::Archived, cx); } else { self.load_page(PageKind::Search, cx); } @@ -888,9 +1049,8 @@ impl Sidebar { if self.filter.is_empty() { self.load_page(PageKind::Active, cx); self.load_page(PageKind::Settled, cx); - if self.archived_expanded { - self.load_page(PageKind::Archived, cx); - } + // The first archived page decides whether its collapsed heading exists. + self.load_page(PageKind::Archived, cx); } else if self.backend.remote_host().is_none() { self.load_page(PageKind::Search, cx); } @@ -1011,10 +1171,7 @@ impl Sidebar { ids.retain(|current| current != &id); } } - if visible - && self.filter.is_empty() - && (target != PageKind::Archived || self.archived_expanded) - { + if visible && self.filter.is_empty() { let ids = self.pages.ids.entry(target).or_default(); ids.insert(0, id.clone()); // The continuation cursor is after the last loaded row. Dropping From 263cb89db2618767fd1031c6b9b93d95a864a39d Mon Sep 17 00:00:00 2001 From: marks Date: Thu, 29 Oct 2026 15:35:19 -0500 Subject: [PATCH 4/6] Agent: keep sidebar project gaps consistent --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 147 ++++++++++++++++++-- 1 file changed, 136 insertions(+), 11 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index da002ed94..787a179a1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -1901,6 +1901,13 @@ impl Sidebar { SidebarEntry::OtherTask(index) => !self.other_tasks[*index].archived, _ => false, }; + let project_header = |entry: &SidebarEntry| { + matches!( + entry, + SidebarEntry::SectionLabel(SidebarSection::Project(_)) + | SidebarEntry::ComputerProject(_) + ) + }; let row_spacing = entries .iter() .enumerate() @@ -1912,17 +1919,20 @@ impl Sidebar { ix.checked_sub(1).and_then(|ix| entries.get(ix)), Some(SidebarEntry::SectionLabel(_) | SidebarEntry::ArchivedHeader) ), - after: match entry { - SidebarEntry::SectionLabel(SidebarSection::Project(index)) => self - .collapsed_projects - .contains(&self.project_roots[*index].target), - SidebarEntry::ComputerProject(index) => self - .collapsed_projects - .contains(&self.computer_projects[*index].target), - _ => { - unarchived_task(entry) && !entries.get(ix + 1).is_some_and(unarchived_task) - } - }, + after: (project_header(entry) && entries.get(ix + 1).is_some_and(project_header)) + || match entry { + SidebarEntry::SectionLabel(SidebarSection::Project(index)) => self + .collapsed_projects + .contains(&self.project_roots[*index].target), + SidebarEntry::ComputerProject(index) => self + .collapsed_projects + .contains(&self.computer_projects[*index].target), + SidebarEntry::ProjectEmpty(_) => true, + _ => { + unarchived_task(entry) + && !entries.get(ix + 1).is_some_and(unarchived_task) + } + }, }) .collect(); let old_spacing = std::mem::replace(&mut self.row_spacing, row_spacing); @@ -4424,6 +4434,121 @@ fn remote_root_display_name(root: &str) -> String { mod tests { use super::{remote_root_display_name, root_display_name}; + #[gpui::test] + fn collapsed_and_empty_projects_keep_the_expanded_task_gap(cx: &mut gpui::TestAppContext) { + use super::*; + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); + let sidebar = chat.update(cx, |chat, _| chat.sidebar.clone()); + let folded = Navigate { + computer: Some("remote".into()), + destination: Destination::Project("/tmp/b".into()), + }; + sidebar.update(cx, |sidebar, _| { + sidebar.reset_persisted_for_test(); + sidebar.view.sort = SidebarSort::Manual; + let mut computer = Computer::pending("remote".into(), "Remote".into(), None); + computer.backend = Some(Arc::new(AgentBackend::for_remote_test("remote").unwrap())); + computer.loading = false; + computer.projects = ["a", "b", "c", "d"] + .into_iter() + .map(|name| super::super::computers::Project { + path: format!("/tmp/{name}"), + label: name.into(), + }) + .collect(); + computer.sessions = ["a", "b", "d"] + .into_iter() + .map(|name| AgentSessionSummary { + id: name.into(), + title: format!("Task {name}"), + project_root: format!("/tmp/{name}"), + created_ms: 0, + updated_ms: 0, + message_count: 1, + model: None, + web_enabled: true, + state: AgentTaskState::Active, + acp: false, + }) + .collect(); + sidebar.computers = Arc::new(vec![computer]); + sidebar.collapsed_projects.insert(ProjectTarget { + computer: folded.computer.clone(), + path: "/tmp/b".into(), + }); + sidebar.seed_computer_snapshot_pages_for_test(); + sidebar.list = + gpui::ListState::new(sidebar.entries.len(), gpui::ListAlignment::Top, px(0.)); + }); + struct Host(Entity); + impl Render for Host { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child(self.0.clone()) + } + } + let list = sidebar.update(cx, |sidebar, _| sidebar.list.clone()); + let (_host, cx) = cx.add_window_view(|_, _| Host(sidebar.clone())); + cx.simulate_resize(gpui::size(px(300.), px(700.))); + let rows = |cx: &mut gpui::VisualTestContext| { + sidebar.update(cx, |sidebar, _| { + let task = sidebar.entries.iter().position(|entry| { + matches!(entry, SidebarEntry::OtherTask(index) + if sidebar.other_tasks[*index].session.id == "a") + }); + let project = |path| { + sidebar.entries.iter().position(|entry| { + matches!(entry, SidebarEntry::ComputerProject(index) + if sidebar.computer_projects[*index].root == path) + }) + }; + [ + task.unwrap(), + project("/tmp/b").unwrap(), + project("/tmp/c").unwrap(), + ] + }) + }; + for group in [SidebarGroup::Project, SidebarGroup::Connection] { + sidebar.update(cx, |sidebar, cx| { + sidebar.view.group = group; + sidebar.rebuild_entries(); + cx.notify(); + }); + cx.run_until_parked(); + for (step, expanded) in [false, true, false, true, false].into_iter().enumerate() { + if step > 0 { + sidebar.update(cx, |sidebar, cx| { + assert!(sidebar.toggle_computer_group(&folded, cx)); + }); + cx.run_until_parked(); + } + let [task, folded_row, empty] = rows(cx); + let task_height = list.bounds_for_item(task).unwrap().size.height; + let folded_height = list.bounds_for_item(folded_row).unwrap().size.height; + let empty_height = list.bounds_for_item(empty).unwrap().size.height; + assert_eq!( + empty_height, task_height, + "empty project ends with the task gap" + ); + if expanded { + assert!( + folded_height < task_height, + "expanded header stays beside its task" + ); + } else { + assert_eq!( + folded_height, task_height, + "collapsed project ends with the task gap" + ); + } + } + } + } + #[gpui::test] fn sidebar_remeasures_an_offscreen_heading_when_its_gap_changes(cx: &mut gpui::TestAppContext) { use super::*; From 7c84aff7eec940def9f560002512082143581eb6 Mon Sep 17 00:00:00 2001 From: marks Date: Thu, 29 Oct 2026 17:32:09 -0500 Subject: [PATCH 5/6] Agent: share task actions across computers --- apps/maple-agent/app/src/desktop.rs | 257 ++++++++++++++- apps/maple-agent/app/src/ui/chat/computers.rs | 73 +++++ apps/maple-agent/app/src/ui/chat/dialogs.rs | 29 ++ apps/maple-agent/app/src/ui/chat/mod.rs | 4 + apps/maple-agent/app/src/ui/chat/sidebar.rs | 301 +++++++++++++----- .../app/src/ui/chat/sidebar_computers.rs | 76 ++++- apps/maple-agent/app/src/ui/chat/tests.rs | 154 +++++++++ 7 files changed, 812 insertions(+), 82 deletions(-) diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index eeaa7f31d..6f7223061 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -15,6 +15,7 @@ use crate::backend::AgentBackend; use crate::ui; use crate::ui::chat::computers::{ Computer, Destination, Navigate, NavigationChanged, ProjectRenameRequest, ProjectTarget, + TaskAction, TaskActionRequest, }; use crate::ui::chat::{ChatScreen, LoggedOut, SignOutFailed}; use crate::ui::login::{LoginScreen, LoginSucceeded}; @@ -61,6 +62,14 @@ struct PendingProjectRename { source: gpui::WeakEntity, } +struct PendingTaskAction { + request: TaskActionRequest, + account: String, + owner: gpui::EntityId, + grant: Option<[u8; 16]>, + source: gpui::WeakEntity, +} + enum ConnectionAttempt { Idle, Connecting(u64), @@ -1425,7 +1434,14 @@ impl MapleApp { &self, target: &ProjectTarget, ) -> Option<(Entity, Option<[u8; 16]>)> { - match &target.computer { + self.computer_owner(target.computer.as_deref()) + } + + fn computer_owner( + &self, + computer: Option<&str>, + ) -> Option<(Entity, Option<[u8; 16]>)> { + match computer { None => self.local_chat.clone().map(|chat| (chat, None)), Some(peer) => self.remote_computers.get(peer).and_then(|computer| { computer @@ -1535,6 +1551,117 @@ impl MapleApp { } } + fn task_action( + &mut self, + source: Entity, + request: TaskActionRequest, + cx: &mut Context, + ) { + let Some(account) = self + .user_id + .clone() + .filter(|_| self.has_chat(source.entity_id())) + else { + return; + }; + let Some((owner, grant)) = self + .computer_owner(request.target.computer.as_deref()) + .filter(|(chat, _)| chat.read(cx).project_names_available()) + else { + source.update(cx, |chat, cx| { + chat.sidebar_task_action_notice( + "This computer is unavailable. Reconnect it before changing the task.".into(), + cx, + ); + }); + return; + }; + if let TaskAction::Pin(pinned) = request.action { + owner.update(cx, |chat, cx| { + chat.set_sidebar_task_pin(&request.target.id, pinned, cx) + }); + self.sync_computers(cx); + return; + } + if matches!(request.action, TaskAction::SetState(state) if state != maple_agent::agent::AgentTaskState::Active) + { + owner.update(cx, |chat, cx| { + chat.set_sidebar_task_pin(&request.target.id, false, cx) + }); + } + let backend = owner.read(cx).execution_backend(); + let pending = PendingTaskAction { + request: request.clone(), + account: account.clone(), + owner: owner.entity_id(), + grant, + source: source.downgrade(), + }; + let executor = backend.clone(); + let bridge = ui::task::call( + &executor, + async move { + let id = &request.target.id; + match request.action { + TaskAction::Rename(name) => { + backend.rename_session(&account, id, name).await.map(Some) + } + TaskAction::SetState(state) => backend + .set_session_state(&account, id, state) + .await + .map(Some), + TaskAction::Delete => backend.delete_session(&account, id).await.map(|()| None), + TaskAction::Pin(_) => Err("This task action is unavailable.".into()), + } + }, + cx, + move |app, result, cx| app.complete_task_action(pending, result, cx), + ); + ui::task::retain(&self.remote_bridges, bridge); + } + + fn complete_task_action( + &mut self, + pending: PendingTaskAction, + result: Result, String>, + cx: &mut Context, + ) { + if self.user_id.as_deref() != Some(&pending.account) { + return; + } + let owner = self + .computer_owner(pending.request.target.computer.as_deref()) + .filter(|(chat, grant)| { + chat.entity_id() == pending.owner + && *grant == pending.grant + && !chat.read(cx).connection_ended() + }); + let result = match owner { + Some((owner, _)) => result.map(|session| { + owner.update(cx, |chat, cx| { + chat.apply_sidebar_task_action( + &pending.request.target.id, + &pending.request.action, + session, + cx, + ); + }); + self.sync_computers(cx); + }), + None => { + Err("The computer connection changed. Check the task before trying again.".into()) + } + }; + if let Some(source) = pending.source.upgrade() + && self.has_chat(source.entity_id()) + { + source.update(cx, |chat, cx| match result { + Ok(()) => chat.refresh_sidebar_task_actions(cx), + Err(message) => chat.sidebar_task_action_notice(message, cx), + }); + } + } + fn navigate(&mut self, request: Navigate, cx: &mut Context) { self.requested_navigation = Some(PendingNavigation { request: request.clone(), @@ -1864,6 +1991,10 @@ impl MapleApp { app.rename_project(source, request.clone(), cx) }) .detach(); + cx.subscribe(chat, |app, source, request: &TaskActionRequest, cx| { + app.task_action(source, request.clone(), cx) + }) + .detach(); cx.subscribe( chat, |app: &mut MapleApp, emitter, _event: &LoggedOut, cx| { @@ -4550,6 +4681,130 @@ mod tests { }); } + fn task_summary(title: &str) -> maple_agent::agent::AgentSessionSummary { + maple_agent::agent::AgentSessionSummary { + id: "same-task".into(), + title: title.into(), + project_root: "/same/project".into(), + created_ms: 1, + updated_ms: 2, + message_count: 1, + model: None, + web_enabled: true, + state: maple_agent::agent::AgentTaskState::Active, + acp: false, + } + } + + fn task_attempt(app: &MapleApp, computer: Option<&str>) -> PendingTaskAction { + let (owner, grant) = app.computer_owner(computer).unwrap(); + let Screen::Chat(source) = &app.screen else { + panic!("chat fixture") + }; + PendingTaskAction { + request: TaskActionRequest { + target: crate::settings::SidebarItemKey { + computer: computer.map(str::to_string), + id: "same-task".into(), + }, + action: TaskAction::Rename("New title".into()), + }, + account: "user".into(), + owner: owner.entity_id(), + grant, + source: source.downgrade(), + } + } + + fn seed_task_owners(app: &MapleApp, cx: &mut Context) { + for (computer, title) in [ + (None, "Local task"), + (Some("east"), "East task"), + (Some("west"), "West task"), + ] { + let (owner, _) = app.computer_owner(computer).unwrap(); + owner.update(cx, |chat, cx| { + chat.apply_sidebar_task_action( + "same-task", + &TaskAction::Rename(title.into()), + Some(task_summary(title)), + cx, + ); + }); + } + } + + fn task_title(app: &MapleApp, computer: Option<&str>, cx: &App) -> String { + let (owner, _) = app.computer_owner(computer).unwrap(); + owner + .read(cx) + .computer_snapshot("Fixture".into(), cx) + .sessions + .into_iter() + .find(|session| session.id == "same-task") + .unwrap() + .title + } + + #[gpui::test] + fn task_action_results_route_duplicate_ids_without_navigation(cx: &mut gpui::TestAppContext) { + let app = project_shell(cx); + app.update(cx, |app, cx| { + seed_task_owners(app, cx); + let east = app.remote_computers["east"].chat.as_ref().unwrap().clone(); + let navigation = east.read(cx).navigation_generation(); + app.screen = Screen::Chat(east.clone()); + app.selected_host = Some("east".into()); + + let pending = task_attempt(app, None); + app.complete_task_action(pending, Ok(Some(task_summary("Renamed local"))), cx); + assert_eq!(task_title(app, None, cx), "Renamed local"); + assert_eq!(task_title(app, Some("east"), cx), "East task"); + assert_eq!(task_title(app, Some("west"), cx), "West task"); + + let pending = task_attempt(app, Some("west")); + app.complete_task_action(pending, Ok(Some(task_summary("Renamed west"))), cx); + assert_eq!(task_title(app, Some("west"), cx), "Renamed west"); + assert_eq!(task_title(app, None, cx), "Renamed local"); + assert_eq!(task_title(app, Some("east"), cx), "East task"); + assert!(matches!(&app.screen, Screen::Chat(chat) + if chat.entity_id() == east.entity_id() + && chat.read(cx).navigation_generation() == navigation)); + assert_eq!(app.selected_host.as_deref(), Some("east")); + assert!(app.requested_navigation.is_none()); + }); + } + + #[gpui::test] + fn stale_task_action_results_cannot_mutate_replacement_owners(cx: &mut gpui::TestAppContext) { + let app = project_shell(cx); + app.update(cx, |app, cx| { + seed_task_owners(app, cx); + let pending = task_attempt(app, Some("east")); + app.remote_computers.get_mut("east").unwrap().grant = [3; 16]; + app.complete_task_action(pending, Ok(Some(task_summary("Old grant"))), cx); + assert_eq!(task_title(app, Some("east"), cx), "East task"); + + let pending = task_attempt(app, Some("east")); + let west = app.remote_computers["west"].chat.clone(); + app.remote_computers.get_mut("east").unwrap().chat = west; + app.complete_task_action(pending, Ok(Some(task_summary("Old owner"))), cx); + assert_eq!(task_title(app, Some("east"), cx), "West task"); + + let pending = task_attempt(app, Some("east")); + app.remote_computers.remove("east"); + app.complete_task_action(pending, Ok(Some(task_summary("Unpaired"))), cx); + assert!(!app.remote_computers.contains_key("east")); + assert_eq!(task_title(app, Some("west"), cx), "West task"); + + let pending = task_attempt(app, None); + app.user_id = Some("another-account".into()); + app.complete_task_action(pending, Ok(Some(task_summary("Old account"))), cx); + assert_eq!(task_title(app, None, cx), "Local task"); + assert!(app.requested_navigation.is_none()); + }); + } + fn rename_attempt(app: &MapleApp, computer: Option<&str>) -> PendingProjectRename { let target = ProjectTarget { computer: computer.map(str::to_string), diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index bb9acd3a5..5e8a9a582 100644 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -32,6 +32,20 @@ pub(crate) struct ProjectRenameRequest { pub name: String, } +#[derive(Clone, Debug)] +pub(crate) enum TaskAction { + Rename(String), + SetState(AgentTaskState), + Pin(bool), + Delete, +} + +#[derive(Clone, Debug)] +pub(crate) struct TaskActionRequest { + pub target: crate::settings::SidebarItemKey, + pub action: TaskAction, +} + pub(crate) struct NavigationChanged; #[derive(Clone)] @@ -78,6 +92,7 @@ impl Computer { impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} +impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} impl ChatScreen { @@ -95,6 +110,64 @@ impl ChatScreen { cx.notify(); } + pub(crate) fn set_sidebar_task_pin( + &mut self, + session_id: &str, + pinned: bool, + cx: &mut Context, + ) { + let was_pinned = self + .sidebar + .read(cx) + .pinned_tasks() + .iter() + .any(|id| id == session_id); + if was_pinned != pinned { + self.toggle_task_pin(session_id, cx); + } + } + + pub(crate) fn apply_sidebar_task_action( + &mut self, + session_id: &str, + action: &TaskAction, + session: Option, + cx: &mut Context, + ) { + if let Some(session) = session { + let root = session.project_root.clone(); + self.upsert_session(session, cx); + if matches!(action, TaskAction::SetState(AgentTaskState::Archived)) + && self.selected_session.as_deref() == Some(session_id) + { + self.leave_selected_session(cx); + let next = self + .sessions + .iter() + .find(|session| { + session.state != AgentTaskState::Archived && session.project_root == root + }) + .map(|session| session.id.clone()); + if let Some(id) = next { + self.select_session(&id, cx); + } + } + } else if matches!(action, TaskAction::Delete) { + self.remove_session(session_id, cx); + } + cx.notify(); + } + + pub(crate) fn sidebar_task_action_notice(&mut self, message: String, cx: &mut Context) { + self.notice = Some(message.into()); + cx.notify(); + } + + pub(crate) fn refresh_sidebar_task_actions(&mut self, cx: &mut Context) { + self.sidebar + .update(cx, |sidebar, cx| sidebar.refresh_computer_pages(cx)); + } + #[cfg(test)] pub(crate) fn seed_projects_for_test( &mut self, diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index d03e07af1..1f293370c 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -7,6 +7,8 @@ use gpui::{Context, Div, div, prelude::*, px}; use maple_agent::agent::{AgentProjectTrustStatus, AgentTaskState}; use super::ChatScreen; +use super::computers::{TaskAction, TaskActionRequest}; +use crate::settings::SidebarItemKey; use crate::ui::theme; use crate::ui::widgets; @@ -17,6 +19,11 @@ pub(super) enum ConfirmDialog { RemoveRoot(String), /// Delete a task and its stored transcript for good. DeleteTask(String), + /// Confirm on the visible screen before deleting another computer's task. + DeleteComputerTask { + target: SidebarItemKey, + title: String, + }, } /// The words a confirmation shows. @@ -223,6 +230,15 @@ impl ChatScreen { self.open_confirm(ConfirmDialog::DeleteTask(session_id.to_string()), cx); } + pub(super) fn request_delete_computer_task( + &mut self, + target: SidebarItemKey, + title: String, + cx: &mut Context, + ) { + self.open_confirm(ConfirmDialog::DeleteComputerTask { target, title }, cx); + } + fn open_confirm(&mut self, dialog: ConfirmDialog, cx: &mut Context) { self.confirm = Some(dialog); self.dialog_focus.get_or_insert_with(|| cx.focus_handle()); @@ -235,6 +251,12 @@ impl ChatScreen { match self.confirm.take() { Some(ConfirmDialog::RemoveRoot(root)) => self.archive_root(&root, cx), Some(ConfirmDialog::DeleteTask(session_id)) => self.delete_task(&session_id, cx), + Some(ConfirmDialog::DeleteComputerTask { target, .. }) => { + cx.emit(TaskActionRequest { + target, + action: TaskAction::Delete, + }); + } None => {} } cx.notify(); @@ -267,6 +289,13 @@ impl ChatScreen { confirm_label: "Delete", } } + ConfirmDialog::DeleteComputerTask { title, .. } => ConfirmCopy { + title: "Delete this task?".to_string(), + body: "The task and its transcript are deleted for good. Files the \ + agent wrote in the project stay where they are.", + detail: title.clone(), + confirm_label: "Delete", + }, } } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 94e36f9e9..abcb5e3d7 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -907,6 +907,10 @@ impl ChatScreen { match event.clone() { SidebarEvent::Navigate(request) => cx.emit(request), SidebarEvent::RenameProject(request) => cx.emit(request), + SidebarEvent::TaskAction(request) => cx.emit(request), + SidebarEvent::ConfirmTaskDelete { target, title } => { + self.request_delete_computer_task(target, title, cx); + } SidebarEvent::PreferencesChanged => { self.refresh_project_caption(cx); cx.emit(NavigationChanged); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 787a179a1..432911fc0 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -20,7 +20,10 @@ mod ordering; mod paging; #[path = "sidebar_prefix.rs"] mod prefix; -use super::computers::{Computer, Destination, Navigate, ProjectRenameRequest, ProjectTarget}; +use super::computers::{ + Computer, Destination, Navigate, ProjectRenameRequest, ProjectTarget, TaskAction, + TaskActionRequest, +}; use computers::{ComputerPages, OtherTask}; use paging::{PageKind, SidebarPages}; @@ -146,6 +149,11 @@ pub(super) enum SidebarEvent { }, Navigate(Navigate), RenameProject(ProjectRenameRequest), + TaskAction(TaskActionRequest), + ConfirmTaskDelete { + target: SidebarItemKey, + title: String, + }, PreferencesChanged, ProjectsChanged(Vec), /// Show a task. @@ -185,9 +193,32 @@ pub(super) enum SessionActivity { #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum RenameTarget { Task(String), + OtherTask(Arc), Project(ProjectTarget), } +#[derive(Clone)] +enum TaskTarget { + Own(Arc), + Other(Arc), +} + +impl TaskTarget { + fn rename_target(&self) -> RenameTarget { + match self { + Self::Own(id) => RenameTarget::Task(id.to_string()), + Self::Other(target) => RenameTarget::OtherTask(target.clone()), + } + } + + fn popup_key(&self) -> SidebarPopup { + match self { + Self::Own(id) => SidebarPopup::Task(id.clone()), + Self::Other(target) => SidebarPopup::OtherTask(target.clone()), + } + } +} + /// Strings and element ids one sidebar task row shows, built when the /// session list changes instead of on every frame. pub(super) struct SidebarRow { @@ -223,9 +254,17 @@ pub(super) struct SidebarRow { impl SidebarRow { fn build(session: &AgentSessionSummary, project_name: &str, connection: &str) -> Self { - let id = &session.id; + Self::build_scoped(session, project_name, connection, &session.id) + } + + fn build_scoped( + session: &AgentSessionSummary, + project_name: &str, + connection: &str, + id: &str, + ) -> Self { Self { - id: Arc::from(id.as_str()), + id: Arc::from(session.id.as_str()), element_id: SharedString::from(format!("session-{id}")), group: SharedString::from(format!("task-row-{id}")), rename_id: SharedString::from(format!("rename-session-{id}")), @@ -283,6 +322,7 @@ enum SidebarPopup { NewProject, /// A task row's overflow menu, by the row's shared id. Task(Arc), + OtherTask(Arc), } #[derive(Clone, Copy, PartialEq, Eq)] @@ -1995,6 +2035,10 @@ impl Sidebar { .get(task.session) .is_some_and(|session| session.id == *id)) }), + RenameTarget::OtherTask(target) => self.entries.iter().position(|entry| { + matches!(entry, SidebarEntry::OtherTask(index) + if self.other_tasks[*index].target == *target) + }), RenameTarget::Project(target) => self .drag_ranges .get(&SidebarDragItem::Project(target.clone())) @@ -2266,6 +2310,12 @@ impl Sidebar { .find(|session| &session.id == id) .map(|session| session.title.clone()) .unwrap_or_default(), + RenameTarget::OtherTask(target) => self + .other_tasks + .iter() + .find(|task| task.target == *target) + .map(|task| task.session.title.clone()) + .unwrap_or_default(), RenameTarget::Project(target) => self .computer_projects .iter() @@ -2375,6 +2425,12 @@ impl Sidebar { }, ); } + RenameTarget::OtherTask(target) => { + cx.emit(SidebarEvent::TaskAction(TaskActionRequest { + target: target.as_ref().clone(), + action: TaskAction::Rename(name), + })) + } RenameTarget::Project(root) => { if self.pending_project_names.contains_key(&root) { return; @@ -2492,30 +2548,43 @@ impl Sidebar { } /// Task actions are grouped by organization/editing, workflow, then deletion. + #[cfg(test)] fn task_menu_items(&self, task: SidebarTaskEntry) -> Option { let row = self.rows.get(task.session)?; - let rung = TaskRung::of(task); + Some(self.task_menu_items_for( + row, + TaskTarget::Own(row.id.clone()), + task.pinned, + TaskRung::of(task), + )) + } + + fn task_menu_items_for( + &self, + row: &SidebarRow, + target: TaskTarget, + pinned: bool, + rung: TaskRung, + ) -> TaskMenuItems { let mut actions = Vec::new(); if rung == TaskRung::Active { - let task_id = row.id.to_string(); + let pin_target = target.clone(); actions.push(SidebarMenuItem { id: row.menu_pin_id.clone(), icon: "pin", - label: if task.pinned { - "Unpin task" - } else { - "Pin task" - }, - on_click: Box::new(move |this, cx| this.toggle_task_pin(&task_id, cx)), + label: if pinned { "Unpin task" } else { "Pin task" }, + on_click: Box::new(move |this, cx| { + this.toggle_task_target_pin(&pin_target, pinned, cx) + }), }); } - let rename_id = row.id.to_string(); + let rename_target = target.clone(); actions.push(SidebarMenuItem { id: row.menu_rename_id.clone(), icon: "pencil", label: "Rename task", on_click: Box::new(move |this, cx| { - this.begin_rename(RenameTarget::Task(rename_id.clone()), cx) + this.begin_rename(rename_target.rename_target(), cx) }), }); let moves: &[(TaskMove, &SharedString, &'static str, &'static str)] = match rung { @@ -2557,27 +2626,71 @@ impl Sidebar { let workflow = moves .iter() .map(|&(to, id, icon, label)| { - let task_id = row.id.to_string(); + let target = target.clone(); SidebarMenuItem { id: id.clone(), icon, label, - on_click: Box::new(move |this, cx| this.move_task(&task_id, to, cx)), + on_click: Box::new(move |this, cx| this.move_task_target(&target, to, cx)), } }) .collect(); - let task_id = row.id.to_string(); + let delete_target = target.clone(); let delete = SidebarMenuItem { id: row.menu_delete_id.clone(), icon: "trash-2", label: "Delete task", - on_click: Box::new(move |this, cx| this.move_task(&task_id, TaskMove::Deleted, cx)), + on_click: Box::new(move |this, cx| { + this.move_task_target(&delete_target, TaskMove::Deleted, cx) + }), }; - Some(TaskMenuItems { + TaskMenuItems { actions, workflow, delete, - }) + } + } + + fn toggle_task_target_pin( + &mut self, + target: &TaskTarget, + pinned: bool, + cx: &mut Context, + ) { + match target { + TaskTarget::Own(id) => self.toggle_task_pin(id, cx), + TaskTarget::Other(target) => cx.emit(SidebarEvent::TaskAction(TaskActionRequest { + target: target.as_ref().clone(), + action: TaskAction::Pin(!pinned), + })), + } + } + + fn move_task_target(&mut self, target: &TaskTarget, to: TaskMove, cx: &mut Context) { + let TaskTarget::Other(target) = target else { + if let TaskTarget::Own(id) = target { + self.move_task(id, to, cx); + } + return; + }; + let action = match to { + TaskMove::Active => TaskAction::SetState(AgentTaskState::Active), + TaskMove::Settled => TaskAction::SetState(AgentTaskState::Settled), + TaskMove::Archived => TaskAction::SetState(AgentTaskState::Archived), + TaskMove::Deleted => { + if let Some(task) = self.other_tasks.iter().find(|task| task.target == *target) { + cx.emit(SidebarEvent::ConfirmTaskDelete { + target: target.as_ref().clone(), + title: task.session.title.clone(), + }); + } + return; + } + }; + cx.emit(SidebarEvent::TaskAction(TaskActionRequest { + target: target.as_ref().clone(), + action, + })); } /// Send a task to a rung. The runtime owns the state, so every move @@ -3851,27 +3964,27 @@ impl Sidebar { fn task_menu_trigger( &self, row: &SidebarRow, - task: SidebarTaskEntry, + target: TaskTarget, + pinned: bool, + rung: TaskRung, window: &mut Window, cx: &mut Context, ) -> Div { - let key = SidebarPopup::Task(row.id.clone()); + let key = target.popup_key(); let open = self.popup.is_open(&key); let menu = open.then(|| { let mut menu = Menu::new(row.menu_panel_id.clone(), px(180.)) .label("Task options") .application_vim(self.application_vim_enabled); - if let Some(items) = self.task_menu_items(task) { - menu = menu - .items(items.actions.into_iter().map(menu_item)) - .separator() - .items(items.workflow.into_iter().map(menu_item)) - .separator() - .item(menu_item(items.delete)); - } + let items = self.task_menu_items_for(row, target.clone(), pinned, rung); + menu = menu + .items(items.actions.into_iter().map(menu_item)) + .separator() + .items(items.workflow.into_iter().map(menu_item)) + .separator() + .item(menu_item(items.delete)); self.popup.render(menu, Placement::BelowStart, window, cx) }); - let session = row.id.clone(); div() .relative() .child( @@ -3882,7 +3995,12 @@ impl Sidebar { button.tooltip(widgets::tooltip("More", None)) }), cx, - move |this, _, cx| this.toggle_task_menu(session.clone(), cx), + move |this, _, cx| match &target { + TaskTarget::Own(id) => this.toggle_task_menu(id.clone(), cx), + TaskTarget::Other(_) => { + this.popup.toggle(target.popup_key(), cx); + } + }, ), ) .children(menu) @@ -3910,6 +4028,75 @@ impl Sidebar { } } + fn other_task_rename_field( + &self, + target: &Arc, + cx: &mut Context, + ) -> Option> { + match self.rename.as_ref() { + Some(RenameTarget::OtherTask(renaming)) if renaming == target => { + self.rename_field().map(|field| { + field.on_mouse_down_out( + cx.listener(|this, _event, _window, cx| this.leave_rename(cx)), + ) + }) + } + _ => None, + } + } + + fn task_hover_actions( + &self, + row: &SidebarRow, + target: TaskTarget, + state: (bool, TaskRung), + hover_bg: u32, + window: &mut Window, + cx: &mut Context, + ) -> Div { + let (pinned, rung) = state; + let rename = target.clone(); + let advance = target.clone(); + let step = rung.step(); + hover_actions(&row.group, hover_bg) + .pl_0() + .when(pinned, |overlay| overlay.right(px(26.))) + .when(!pinned, |overlay| overlay.right_0().rounded_r(ROW_RADIUS)) + .pr_4() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_full() + .w(ROW_FADE) + .group_hover(row.group.clone(), |style| { + style.bg(row_fade_background(hover_bg)) + }), + ) + .child(self.task_menu_trigger(row, target, pinned, rung, window, cx)) + .child(row_action( + row.rename_id.clone(), + &row.group, + "pencil", + "Rename", + cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.begin_rename(rename.rename_target(), cx); + }), + )) + .child(row_action( + row.step_id.clone(), + &row.group, + step.icon, + step.row_label, + cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.move_task_target(&advance, rung.next(), cx); + }), + )) + } + /// The rename field for the project being edited, if it is this one. fn project_rename_field(&self, root: &ProjectTarget) -> Option> { match self.rename.as_ref() { @@ -3956,10 +4143,7 @@ impl Sidebar { .then(|| self.session_activity(&row.id)) .flatten(); let session_id = Arc::clone(&row.id); - let step_id = Arc::clone(&row.id); - let rename_id = Arc::clone(&row.id); let rung = TaskRung::of(task); - let step = rung.step(); let pin_task_id = Arc::clone(&row.id); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); @@ -4061,47 +4245,14 @@ impl Sidebar { ) }) .when(!renaming && !self.dragging, |el| { - el.child( - hover_actions(&row.group, hover_bg) - .pl_0() - .when(is_pinned, |overlay| overlay.right(px(26.))) - .when(!is_pinned, |overlay| { - overlay.right_0().rounded_r(ROW_RADIUS) - }) - .pr_4() - .child( - div() - .absolute() - .top_0() - .bottom_0() - .right_full() - .w(ROW_FADE) - .group_hover(row.group.clone(), |style| { - style.bg(row_fade_background(hover_bg)) - }), - ) - .child(self.task_menu_trigger(row, task, window, cx)) - .child(row_action( - row.rename_id.clone(), - &row.group, - "pencil", - "Rename", - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.begin_rename(RenameTarget::Task(rename_id.to_string()), cx); - }), - )) - .child(row_action( - row.step_id.clone(), - &row.group, - step.icon, - step.row_label, - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.move_task(&step_id, rung.next(), cx); - }), - )), - ) + el.child(self.task_hover_actions( + row, + TaskTarget::Own(row.id.clone()), + (is_pinned, rung), + hover_bg, + window, + cx, + )) }) } } 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 180a82905..cf9a2a2f4 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -467,6 +467,9 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C } pub(super) struct OtherTask { + pub(super) target: Arc, + pub(super) row: SidebarRow, + pub(super) rung: TaskRung, pub(super) request: Navigate, pub(super) archived: bool, pub(super) session: AgentSessionSummary, @@ -814,7 +817,7 @@ impl Sidebar { self.ensure_computer_pages(cx); } - pub(super) fn refresh_computer_pages(&mut self, cx: &mut Context) { + pub(in crate::ui::chat) fn refresh_computer_pages(&mut self, cx: &mut Context) { self.manual_catalog.cancel_pending(); self.group_pages.revalidation_pending = false; self.group_pages.generation = self.group_pages.generation.wrapping_add(1); @@ -2110,7 +2113,29 @@ impl Sidebar { } else { SharedString::default() }; + let id: SharedString = format!("computer-task-{:?}-{}", computer.key, task.uuid).into(); + let mut row = SidebarRow::build_scoped(&session, "", "", &id); + row.title = + one_line(latest.map_or(task.title.as_str(), |session| session.title.as_str())); + row.metadata = metadata.clone(); + row.group = id.clone(); + row.element_id = id.clone(); + let rung = if archived { + TaskRung::Archived + } else if session.state == AgentTaskState::Settled + && !computer.running.contains(&task.uuid) + { + TaskRung::Settled + } else { + TaskRung::Active + }; self.other_tasks.push(OtherTask { + target: Arc::new(SidebarItemKey { + computer: computer.key.clone(), + id: task.uuid.clone(), + }), + row, + rung, archived, session, metadata, @@ -2118,7 +2143,7 @@ impl Sidebar { computer: computer.key.clone(), destination: Destination::Task(task.uuid.clone()), }, - id: format!("computer-task-{:?}-{}", computer.key, task.uuid).into(), + id, title: one_line( latest.map_or(task.title.as_str(), |session| session.title.as_str()), ), @@ -2281,15 +2306,21 @@ impl Sidebar { } SidebarEntry::OtherTask(index) => { let task = &self.other_tasks[index]; + let row = &task.row; let request = task.request.clone(); + let rename_field = self.other_task_rename_field(&task.target, cx); + let renaming = rename_field.is_some(); + let pin_target = TaskTarget::Other(task.target.clone()); let row_bg = if selected { theme::bg_sidebar_row_hover() } else { theme::bg_sidebar() }; - let hover_group = (!dragging).then_some(&task.id); + let hover_group = (!renaming && !dragging).then_some(&row.group); div() + .relative() .id(task.id.clone()) + .debug_selector(|| task.id.to_string()) .group(task.id.clone()) .role(gpui::Role::ListBoxOption) .aria_label(task.title.clone()) @@ -2310,7 +2341,7 @@ impl Sidebar { .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) .hover(move |s| { - if dragging { + if dragging || renaming { s } else { s.bg(gpui::rgb(hover_bg)).cursor_pointer() @@ -2325,7 +2356,15 @@ impl Sidebar { .min_w_0() .flex() .flex_col() - .child(row_line(task.title.clone(), hover_group, row_bg, hover_bg)) + .map(|column| match rename_field { + Some(field) => column.child(field), + None => column.child(row_line( + task.title.clone(), + hover_group, + row_bg, + hover_bg, + )), + }) .when(!task.metadata.is_empty(), |column| { column.child( row_line(task.metadata.clone(), hover_group, row_bg, hover_bg) @@ -2338,7 +2377,32 @@ impl Sidebar { row.child(activity_indicator(&task.id, activity)) }) .when(task.pinned, |row| { - row.child(icon("pin", px(13.), theme::accent())) + row.child( + row_button( + task.row.pin_id.clone(), + &task.row.group, + "pin", + "Unpin task", + ) + .opacity(1.) + .tooltip(widgets::tooltip("Unpin", None)) + .on_click(cx.listener( + move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_task_target_pin(&pin_target, true, cx); + }, + )), + ) + }) + .when(!renaming && !dragging, |element| { + element.child(self.task_hover_actions( + row, + TaskTarget::Other(task.target.clone()), + (task.pinned, task.rung), + hover_bg, + window, + cx, + )) }) .into_any_element() } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 5f3e4f2b1..d0abbecf7 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -8242,6 +8242,160 @@ mod state_tests { }); } + fn inactive_task_window( + cx: &mut TestAppContext, + ) -> (Entity, &mut gpui::VisualTestContext) { + chat_window(cx, |this, cx| { + this.selected_session = None; + this.draft = true; + this.project_root = Some("/shared".into()); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx); + }); + this.sessions = vec![summary_at("same-id", "Existing task", "/shared")]; + this.apply_recent_roots(vec![named_root("/shared", "Local project", 1)], cx); + set_computer_snapshots(this, identical_path_project_catalog(), cx); + this.composer.as_ref().unwrap().update(cx, |input, cx| { + input.set_text("Keep my draft", cx); + }); + }) + } + + #[gpui::test] + fn inactive_task_hover_controls_are_scoped_and_do_not_navigate(cx: &mut TestAppContext) { + use crate::settings::SidebarItemKey; + use crate::ui::chat::computers::{TaskAction, TaskActionRequest}; + use std::cell::RefCell; + use std::rc::Rc; + + let (chat, cx) = inactive_task_window(cx); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let generation = chat.update(cx, |this, _| this.selection_generation); + let requests = Rc::new(RefCell::new(Vec::new())); + let observed = requests.clone(); + let _subscription = cx.update(|_, app| { + app.subscribe(&chat, move |_, request: &TaskActionRequest, _| { + observed.borrow_mut().push(request.clone()); + }) + }); + let navigation = Rc::new(RefCell::new(Vec::new())); + let observed = navigation.clone(); + let _navigation = cx.update(|_, app| { + app.subscribe(&sidebar, move |_, event: &SidebarEvent, _| { + if matches!(event, SidebarEvent::Navigate(_) | SidebarEvent::Select(_)) { + observed.borrow_mut().push(event.clone()); + } + }) + }); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let own = cx + .debug_bounds("menu-session-same-id") + .expect("local task menu"); + let east = cx + .debug_bounds("menu-session-computer-task-Some(\"east\")-same-id") + .expect("inactive task menu before selection"); + let west = cx + .debug_bounds("menu-session-computer-task-Some(\"west\")-same-id") + .expect("other inactive task menu before selection"); + assert_ne!(own.center(), east.center()); + assert_ne!(east.center(), west.center()); + for selector in [ + "rename-session-computer-task-Some(\"east\")-same-id", + "step-session-computer-task-Some(\"east\")-same-id", + "rename-session-computer-task-Some(\"west\")-same-id", + "step-session-computer-task-Some(\"west\")-same-id", + ] { + assert!(cx.debug_bounds(selector).is_some(), "{selector}"); + } + press(cx, "menu-session-computer-task-Some(\"east\")-same-id"); + press(cx, "rename-task-computer-task-Some(\"east\")-same-id"); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.rename_target()), + Some(RenameTarget::OtherTask(Arc::new(SidebarItemKey { + computer: Some("east".into()), + id: "same-id".into(), + }))) + ); + cx.simulate_keystrokes("secondary-a"); + cx.simulate_input("East task renamed"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + press(cx, "step-session-computer-task-Some(\"west\")-same-id"); + cx.run_until_parked(); + let requests = requests.borrow(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].target.computer.as_deref(), Some("east")); + assert_eq!(requests[0].target.id, "same-id"); + assert!( + matches!(&requests[0].action, TaskAction::Rename(name) if name == "East task renamed") + ); + assert_eq!(requests[1].target.computer.as_deref(), Some("west")); + assert_eq!(requests[1].target.id, "same-id"); + assert!(matches!( + requests[1].action, + TaskAction::SetState(AgentTaskState::Settled) + )); + assert!(navigation.borrow().is_empty()); + chat.update(cx, |this, cx| { + assert!(this.draft); + assert!(this.selected_session.is_none()); + assert_eq!(this.project_root.as_deref(), Some("/shared")); + assert_eq!(this.selection_generation, generation); + assert_eq!(this.composer_text(cx).as_deref(), Some("Keep my draft")); + }); + } + + #[gpui::test] + fn inactive_task_delete_confirms_on_the_visible_screen(cx: &mut TestAppContext) { + use crate::settings::SidebarItemKey; + use crate::ui::chat::computers::{TaskAction, TaskActionRequest}; + use std::cell::RefCell; + use std::rc::Rc; + + let (chat, cx) = inactive_task_window(cx); + let requests = Rc::new(RefCell::new(Vec::new())); + let observed = requests.clone(); + let _subscription = cx.update(|_, app| { + app.subscribe(&chat, move |_, request: &TaskActionRequest, _| { + observed.borrow_mut().push(request.clone()); + }) + }); + press(cx, "menu-session-computer-task-Some(\"east\")-same-id"); + press(cx, "delete-task-computer-task-Some(\"east\")-same-id"); + cx.run_until_parked(); + let target = SidebarItemKey { + computer: Some("east".into()), + id: "same-id".into(), + }; + chat.update(cx, |this, _| { + assert_eq!( + this.confirm, + Some(dialogs::ConfirmDialog::DeleteComputerTask { + target: target.clone(), + title: "Existing task".into(), + }) + ); + assert!(this.selected_session.is_none()); + }); + assert!( + requests.borrow().is_empty(), + "opening confirmation performs no mutation" + ); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + let requests = requests.borrow(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].target, target); + assert!(matches!(requests[0].action, TaskAction::Delete)); + chat.update(cx, |this, cx| { + assert!(this.confirm.is_none()); + assert!(this.draft); + assert!(this.selected_session.is_none()); + assert_eq!(this.composer_text(cx).as_deref(), Some("Keep my draft")); + }); + } + #[gpui::test] fn inactive_project_rename_controls_are_scoped_and_do_not_navigate(cx: &mut TestAppContext) { use std::cell::RefCell; From c581556dba7b0638c9a56df79c2bb464d4fc402e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:28:56 +0000 Subject: [PATCH 6/6] Agent: use the newest summary for another computer's task actions The row already showed the newest title, but rename prefill, the delete confirmation and the settle/archive step read the older page summary. A rename made elsewhere could reopen with the old title. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/sidebar_computers.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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..43a1fef10 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2091,7 +2091,10 @@ impl Sidebar { return; } let index = self.other_tasks.len(); - let session = session_from_summary(task); + // Rename, delete and the hover step read the newest summary too. + let session = latest + .cloned() + .unwrap_or_else(|| session_from_summary(task)); let pinned = self.computer_task_pin(computer, task).is_some(); let metadata = if self.show_task_metadata(pinned, archived) { one_line(&format!(