From 213d1ebbc38b7410381074897f0f632f7bc2fe9f Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:03:35 -0500 Subject: [PATCH] Agent: fade long sidebar task titles instead of cutting them A long task title ended in an ellipsis, and on hover the action strip sliced through it with a hard edge. The title and project lines now fade into the row over 20 px, in the row's own color (normal, selected, or hovered), so over empty space the fade is invisible. The hover strip leads with the same fade instead of solid padding, so the title fades out just before the buttons. A row being renamed has no hover highlight, so its fade keeps the row color too. Other computers' task rows get the same fade, in their own selected and hovered colors. The fade is always drawn, so text that just fits softens slightly at its end; detecting overflow would take a custom element for a few pixels. The lines no longer wrap, so a line break in a title or project name (\r\n, \n or \r) becomes a space when the row is built. Search still uses the title as written, and a row built from a multiline title is reused while that title is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 89 ++++++++++++++++--- .../app/src/ui/chat/sidebar_computers.rs | 25 ++++-- apps/maple-agent/app/src/ui/chat/tests.rs | 26 +++++- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index c1c4b7da0..03c2d234d 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -178,8 +178,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, shown on every row. + /// Display name of the task's project, shown on every row, on one line. pub(super) project_name: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, @@ -204,16 +205,28 @@ 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), 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 + } + } } /// The switcher's "All projects" row. const ALL_PROJECTS_ID: &str = "switcher-all-projects"; +/// How far a cut-off task title fades into its row. +const ROW_FADE: gpui::Pixels = px(20.); + /// The sidebar's popup menus. One is open at a time; a project's own menu /// opens inside the switcher (see `Sidebar::project_popup`). #[derive(Clone, Debug, PartialEq, Eq)] @@ -1435,7 +1448,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) && self.root_name_matches(&session.project_root, &row.project_name) }); if fresh { @@ -1619,7 +1632,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 ------------------------------------------------------------ @@ -2762,6 +2775,12 @@ impl Sidebar { let pin_id = row.pin_id.clone(); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); + let row_bg = if is_selected { + theme::bg_sidebar_row_selected() + } else { + theme::bg_sidebar() + }; + let hover_group = (!renaming).then_some(&row.group); div() .relative() .w_full() @@ -2809,21 +2828,17 @@ 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)), }) .when(self.computers.len() <= 1, |column| { column.child( - div() + row_line(row.project_name.clone(), hover_group, row_bg) .text_xs() .text_color(gpui::rgb(if is_selected { theme::text_secondary() } else { theme::text_muted() - })) - .line_clamp(1) - .text_ellipsis() - .child(row.project_name.clone()), + })), ) }), ) @@ -2865,11 +2880,21 @@ impl Sidebar { .flex() .items_center() .gap_1p5() - .pl_2() .pr_6() .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_full() + .w(ROW_FADE) + .group_hover(row.group.clone(), |style| { + style.bg(row_fade_background(theme::bg_sidebar_row_hover())) + }), + ) .child(self.task_menu_trigger(row, task, window, cx)) .child(row_action( row.rename_id.clone(), @@ -3093,6 +3118,44 @@ 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 omit the hover group. +fn row_line(text: SharedString, hover_group: Option<&SharedString>, row_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(theme::bg_sidebar_row_hover())) + }) + }), + ) +} + +/// 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 eeaead3f9..f4de59606 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1109,9 +1109,9 @@ impl Sidebar { destination: Destination::Task(task.uuid.clone()), }, id: format!("computer-{computer_index}-task-{}", 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: task.pinned_ms.is_some(), activity: if computer.running.contains(&task.uuid) { Some(SessionActivity::Running) @@ -1125,6 +1125,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, @@ -1253,8 +1259,14 @@ 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() + }; div() .id(task.id.clone()) + .group(task.id.clone()) .role(gpui::Role::ListBoxOption) .aria_label(task.title.clone()) .aria_selected(false) @@ -1279,12 +1291,9 @@ impl Sidebar { cx.emit(SidebarEvent::Navigate(request.clone())) })) .child( - div() + row_line(task.title.clone(), Some(&task.id), row_bg) .flex_1() - .min_w_0() - .line_clamp(1) - .text_ellipsis() - .child(task.title.clone()), + .min_w_0(), ) .when_some(task.activity, |row, activity| { row.child(activity_indicator(&task.id, activity)) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1dd1eaf02..4e2210fc6 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -183,7 +183,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(), @@ -213,6 +213,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 { @@ -7167,6 +7171,19 @@ 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()); + } + // 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"); }); @@ -7174,6 +7191,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" + ); }); }