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" + ); }); }