diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index d525f6d67838c82e0e222fa4755227664f93d166..97c291e0dc928dfb94a530234002bd4e99e2b3be 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -146,6 +146,15 @@ impl ThreadSwitcher { } } + fn select_index(&mut self, index: usize, cx: &mut Context) { + if index >= self.entries.len() || index == self.selected_index { + return; + } + self.selected_index = index; + self.emit_preview(cx); + cx.notify(); + } + fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context) { cx.emit(ThreadSwitcherEvent::Dismissed); cx.emit(DismissEvent); @@ -213,37 +222,39 @@ impl Render for ThreadSwitcher { .children(self.entries.iter().enumerate().map(|(ix, entry)| { let id = SharedString::from(format!("thread-switcher-{}", entry.session_id)); - div() - .id(id.clone()) + ThreadItem::new(id, entry.title.clone()) + .rounded(true) + .icon(entry.icon) + .status(entry.status) + .when_some(entry.icon_from_external_svg.clone(), |this, svg| { + this.custom_icon_from_external_svg(svg) + }) + .when_some(entry.project_name.clone(), |this, name| { + this.project_name(name) + }) + .worktrees(entry.worktrees.clone()) + .timestamp(entry.timestamp.clone()) + .title_generating(entry.is_title_generating) + .notified(entry.notified) + .when(entry.diff_stats.lines_added > 0, |this| { + this.added(entry.diff_stats.lines_added as usize) + }) + .when(entry.diff_stats.lines_removed > 0, |this| { + this.removed(entry.diff_stats.lines_removed as usize) + }) + .selected(ix == selected_index) + .base_bg(cx.theme().colors().elevated_surface_background) + .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| { + if *hovered { + this.select_index(ix, cx); + } + })) + // TODO: This is not properly propagating to the tread item. .on_click( cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| { this.select_and_confirm(ix, cx); }), ) - .child( - ThreadItem::new(id, entry.title.clone()) - .rounded(true) - .icon(entry.icon) - .status(entry.status) - .when_some(entry.icon_from_external_svg.clone(), |this, svg| { - this.custom_icon_from_external_svg(svg) - }) - .when_some(entry.project_name.clone(), |this, name| { - this.project_name(name) - }) - .worktrees(entry.worktrees.clone()) - .timestamp(entry.timestamp.clone()) - .title_generating(entry.is_title_generating) - .notified(entry.notified) - .when(entry.diff_stats.lines_added > 0, |this| { - this.added(entry.diff_stats.lines_added as usize) - }) - .when(entry.diff_stats.lines_removed > 0, |this| { - this.removed(entry.diff_stats.lines_removed as usize) - }) - .selected(ix == selected_index) - .base_bg(cx.theme().colors().elevated_surface_background), - ) .into_any_element() })) } diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 7aa5c682256d906d9c2b1f3125fd7e1500999306..ccac7a7723b266d4c0707ada506a68f3259a838d 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -244,11 +244,11 @@ impl RenderOnce for ThreadItem { .gradient_stop(0.75) .group_name("thread-item"); + let separator_color = Color::Custom(color.text_muted.opacity(0.4)); let dot_separator = || { Label::new("•") .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5) + .color(separator_color) }; let icon_id = format!("icon-{}", self.id); @@ -307,12 +307,6 @@ impl RenderOnce for ThreadItem { icon_container().child(agent_icon).into_any_element() }; - let tooltip_title = self.title.clone(); - let tooltip_status = self.status; - let tooltip_worktrees = self.worktrees.clone(); - let tooltip_added = self.added; - let tooltip_removed = self.removed; - let title = self.title; let highlight_positions = self.highlight_positions; @@ -361,185 +355,24 @@ impl RenderOnce for ThreadItem { let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; - let mut worktree_labels: Vec = Vec::new(); - - let slash_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.4)); - - for wt in self.worktrees { - match wt.kind { - WorktreeKind::Main => continue, - WorktreeKind::Linked => { - let chip_index = worktree_labels.len(); - - let label = if wt.highlight_positions.is_empty() { - Label::new(wt.name) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate() - .into_any_element() - } else { - HighlightedLabel::new(wt.name, wt.highlight_positions) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate() - .into_any_element() - }; - - worktree_labels.push( - h_flex() - .id(format!("{}-worktree-{chip_index}", self.id.clone())) - .min_w_0() - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(label) - .when_some(wt.branch_name, |this, branch| { - this.child( - Label::new("/") - .size(LabelSize::Small) - .color(slash_color) - .flex_shrink_0(), - ) - .child( - Label::new(branch) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ) - }) - .into_any_element(), - ); - } - } - } + let show_tooltip = matches!( + self.status, + AgentThreadStatus::Error | AgentThreadStatus::WaitingForConfirmation + ); - let has_worktree = !worktree_labels.is_empty(); + let linked_worktrees: Vec = self + .worktrees + .into_iter() + .filter(|wt| wt.kind == WorktreeKind::Linked) + .collect(); - let unified_tooltip = { - let title = tooltip_title; - let status = tooltip_status; - let worktrees = tooltip_worktrees; - let added = tooltip_added; - let removed = tooltip_removed; + let has_worktree = !linked_worktrees.is_empty(); - Tooltip::element(move |_window, cx| { - v_flex() - .min_w_0() - .gap_1() - .child(Label::new(title.clone())) - .children(worktrees.iter().map(|wt| { - let is_linked = wt.kind == WorktreeKind::Linked; - - v_flex() - .gap_1() - .when(is_linked, |this| { - this.child( - v_flex() - .child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(wt.name.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - div() - .pl(IconSize::Small.rems() + rems(0.25)) - .w(px(280.)) - .whitespace_normal() - .text_ui_sm(cx) - .text_color( - cx.theme().colors().text_muted.opacity(0.8), - ) - .child(wt.full_path.clone()), - ), - ) - }) - .when_some(wt.branch_name.clone(), |this, branch| { - this.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::GitBranch) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(branch) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - })) - .when(status == AgentThreadStatus::Error, |this| { - this.child( - h_flex() - .gap_1() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .child(Label::new("Error").size(LabelSize::Small)), - ) - }) - .when( - status == AgentThreadStatus::WaitingForConfirmation, - |this| { - this.child( - h_flex() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .gap_1() - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child( - Label::new("Waiting for Confirmation") - .size(LabelSize::Small), - ), - ) - }, - ) - .when(added.is_some() || removed.is_some(), |this| { - this.child( - h_flex() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .gap_1() - .child(DiffStat::new( - "diff", - added.unwrap_or(0), - removed.unwrap_or(0), - )) - .child( - Label::new("Unreviewed Changes") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - .into_any_element() - }) - }; + let has_metadata = has_project_name + || has_project_paths + || has_worktree + || has_diff_stats + || has_timestamp; v_flex() .id(self.id.clone()) @@ -557,7 +390,6 @@ impl RenderOnce for ThreadItem { .when(self.rounded, |s| s.rounded_sm()) .hover(|s| s.bg(hover_color)) .on_hover(self.on_hover) - .tooltip(unified_tooltip) .child( h_flex() .min_w_0() @@ -594,75 +426,120 @@ impl RenderOnce for ThreadItem { }) }), ) - .when( - has_project_name - || has_project_paths - || has_worktree - || has_diff_stats - || has_timestamp, - |this| { - this.child( - h_flex() - .min_w_0() - .gap_1p5() - .child(icon_container()) // Icon Spacing - .when( - has_project_name || has_project_paths || has_worktree, - |this| { + .when(has_metadata, |this| { + this.child( + h_flex() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .when( + has_project_name || has_project_paths || has_worktree, + |this| { + this.when_some(self.project_name, |this, name| { this.child( + Label::new(name).size(LabelSize::Small).color(Color::Muted), + ) + }) + .when( + has_project_name && (has_project_paths || has_worktree), + |this| this.child(dot_separator()), + ) + .when_some(project_paths, |this, paths| { + this.child( + Label::new(paths) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .when(has_project_paths && has_worktree, |this| { + this.child(dot_separator()) + }) + .children( + linked_worktrees.into_iter().map(|wt| { + let worktree_label = if wt.highlight_positions.is_empty() { + Label::new(wt.name) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element() + } else { + HighlightedLabel::new(wt.name, wt.highlight_positions) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + .into_any_element() + }; + h_flex() .min_w_0() - .flex_shrink() - .overflow_hidden() - .gap_1p5() - .when_some(self.project_name, |this, name| { + .gap_0p5() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(worktree_label) + .when_some(wt.branch_name, |this, branch| { this.child( - Label::new(name) + Label::new("/") .size(LabelSize::Small) - .color(Color::Muted), + .color(separator_color) + .flex_shrink_0(), ) - }) - .when( - has_project_name - && (has_project_paths || has_worktree), - |this| this.child(dot_separator()), - ) - .when_some(project_paths, |this, paths| { - this.child( - Label::new(paths) + .child( + Label::new(branch) .size(LabelSize::Small) .color(Color::Muted) - .into_any_element(), + .truncate(), ) }) - .when(has_project_paths && has_worktree, |this| { - this.child(dot_separator()) - }) - .children(worktree_labels), - ) - }, - ) - .when( - (has_project_name || has_project_paths || has_worktree) - && (has_diff_stats || has_timestamp), - |this| this.child(dot_separator()), - ) - .when(has_diff_stats, |this| { - this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) - }) - .when(has_diff_stats && has_timestamp, |this| { - this.child(dot_separator()) - }) - .when(has_timestamp, |this| { - this.child( - Label::new(timestamp.clone()) - .size(LabelSize::Small) - .color(Color::Muted), + }), ) - }), - ) - }, - ) + }, + ) + .when( + (has_project_name || has_project_paths || has_worktree) + && (has_diff_stats || has_timestamp), + |this| this.child(dot_separator()), + ) + .when(has_diff_stats, |this| { + this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + }) + .when(show_tooltip, |this| { + let status = self.status; + this.tooltip(Tooltip::element(move |_, _| match status { + AgentThreadStatus::Error => h_flex() + .gap_1() + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new("Thread has an Error")) + .into_any_element(), + AgentThreadStatus::WaitingForConfirmation => h_flex() + .gap_1() + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child(Label::new("Waiting for Confirmation")) + .into_any_element(), + _ => gpui::Empty.into_any_element(), + })) + }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } } @@ -688,7 +565,7 @@ impl Component for ThreadItem { let thread_item_examples = vec![ single_example( - "Default (minutes)", + "Default", container() .child( ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings") @@ -697,16 +574,6 @@ impl Component for ThreadItem { ) .into_any_element(), ), - single_example( - "Notified (weeks)", - container() - .child( - ThreadItem::new("ti-2", "Refine thread view scrolling behavior") - .timestamp("1w") - .notified(true), - ) - .into_any_element(), - ), single_example( "Waiting for Confirmation", container() @@ -756,7 +623,7 @@ impl Component for ThreadItem { .into_any_element(), ), single_example( - "With Changes (months)", + "With Changes", container() .child( ThreadItem::new("ti-5", "Managing user and project settings interactions") @@ -844,80 +711,174 @@ impl Component for ThreadItem { .into_any_element(), ), single_example( - "Selected Item", + "Long Worktree Name (truncation)", container() .child( - ThreadItem::new("ti-6", "Refine textarea interaction behavior") - .icon(IconName::AiGemini) - .timestamp("45m") - .selected(true), + ThreadItem::new("ti-5f", "Thread with a very long worktree name") + .icon(IconName::AiClaude) + .worktrees(vec![ThreadItemWorktreeInfo { + name: "very-long-worktree-name-that-should-truncate".into(), + full_path: "/worktrees/very-long-worktree-name/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: None, + }]) + .timestamp("1h"), ) .into_any_element(), ), single_example( - "Focused Item (Keyboard Selection)", + "Worktree with Search Highlights", container() .child( - ThreadItem::new("ti-7", "Implement keyboard navigation") + ThreadItem::new("ti-5g", "Filtered thread with highlighted worktree") .icon(IconName::AiClaude) - .timestamp("12h") - .focused(true), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "jade-glen".into(), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: vec![0, 1, 2, 3], + kind: WorktreeKind::Linked, + branch_name: Some("fix-scrolling".into()), + }]) + .timestamp("3d"), ) .into_any_element(), ), single_example( - "Selected + Focused", + "Multiple Worktrees (no branches)", container() .child( - ThreadItem::new("ti-8", "Active and keyboard-focused thread") - .icon(IconName::AiGemini) - .timestamp("2mo") - .selected(true) - .focused(true), + ThreadItem::new("ti-5h", "Thread spanning multiple worktrees") + .icon(IconName::AiClaude) + .worktrees(vec![ + ThreadItemWorktreeInfo { + name: "jade-glen".into(), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: None, + }, + ThreadItemWorktreeInfo { + name: "fawn-otter".into(), + full_path: "/worktrees/fawn-otter/zed-slides".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: None, + }, + ]) + .timestamp("2h"), ) .into_any_element(), ), single_example( - "Hovered with Action Slot", + "Multiple Worktrees with Branches", container() .child( - ThreadItem::new("ti-9", "Hover to see action button") - .icon(IconName::AiClaude) - .timestamp("6h") - .hovered(true) - .action_slot( - IconButton::new("delete", IconName::Trash) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), - ), + ThreadItem::new("ti-5i", "Multi-root with per-worktree branches") + .icon(IconName::ZedAgent) + .worktrees(vec![ + ThreadItemWorktreeInfo { + name: "jade-glen".into(), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("fix".into()), + }, + ThreadItemWorktreeInfo { + name: "fawn-otter".into(), + full_path: "/worktrees/fawn-otter/zed-slides".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("main".into()), + }, + ]) + .timestamp("15m"), ) .into_any_element(), ), single_example( - "Search Highlight", + "Project Name + Worktree + Branch", container() .child( - ThreadItem::new("ti-10", "Implement keyboard navigation") + ThreadItem::new("ti-5j", "Thread with project context") .icon(IconName::AiClaude) - .timestamp("4w") - .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + .project_name("my-remote-server") + .worktrees(vec![ThreadItemWorktreeInfo { + name: "jade-glen".into(), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("feature-branch".into()), + }]) + .timestamp("1d"), ) .into_any_element(), ), single_example( - "Worktree Search Highlight", + "Project Paths + Worktree (archive view)", container() .child( - ThreadItem::new("ti-11", "Search in worktree name") + ThreadItem::new("ti-5k", "Archived thread with folder paths") .icon(IconName::AiClaude) - .timestamp("3mo") + .project_paths(Arc::from(vec![ + PathBuf::from("/projects/zed"), + PathBuf::from("/projects/zed-slides"), + ])) .worktrees(vec![ThreadItemWorktreeInfo { - name: "my-project-name".into(), - full_path: "my-project-name".into(), - highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11], + name: "jade-glen".into(), + full_path: "/worktrees/jade-glen/zed".into(), + highlight_positions: Vec::new(), kind: WorktreeKind::Linked, - branch_name: None, - }]), + branch_name: Some("feature".into()), + }]) + .timestamp("2mo"), + ) + .into_any_element(), + ), + single_example( + "All Metadata", + container() + .child( + ThreadItem::new("ti-5l", "Thread with every metadata field populated") + .icon(IconName::ZedAgent) + .project_name("remote-dev") + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-worktree".into(), + full_path: "/worktrees/my-worktree/zed".into(), + highlight_positions: Vec::new(), + kind: WorktreeKind::Linked, + branch_name: Some("main".into()), + }]) + .added(15) + .removed(4) + .timestamp("8h"), + ) + .into_any_element(), + ), + single_example( + "Focused Item (Keyboard Selection)", + container() + .child( + ThreadItem::new("ti-7", "Implement keyboard navigation") + .icon(IconName::AiClaude) + .timestamp("12h") + .focused(true), + ) + .into_any_element(), + ), + single_example( + "Action Slot", + container() + .child( + ThreadItem::new("ti-9", "Hover to see action button") + .icon(IconName::AiClaude) + .timestamp("6h") + .hovered(true) + .action_slot( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), ) .into_any_element(), ),