@@ -29,8 +29,7 @@ use std::sync::Arc;
use theme::ActiveTheme;
use ui::{
AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
- ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar,
- prelude::*,
+ PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
};
use util::ResultExt as _;
use util::path_list::PathList;
@@ -110,6 +109,7 @@ struct ThreadEntry {
is_title_generating: bool,
highlight_positions: Vec<usize>,
worktree_name: Option<SharedString>,
+ worktree_full_path: Option<SharedString>,
worktree_highlight_positions: Vec<usize>,
diff_stats: DiffStats,
}
@@ -127,7 +127,6 @@ enum ListEntry {
Thread(ThreadEntry),
ViewMore {
path_list: PathList,
- remaining_count: usize,
is_fully_expanded: bool,
},
NewThread {
@@ -599,6 +598,19 @@ impl Sidebar {
let query = self.filter_editor.read(cx).text(cx);
+ // Re-derive agent_panel_visible from the active workspace so it stays
+ // correct after workspace switches.
+ self.agent_panel_visible = active_workspace
+ .as_ref()
+ .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
+
+ // Derive active_thread_is_draft BEFORE focused_thread so we can
+ // use it as a guard below.
+ self.active_thread_is_draft = active_workspace
+ .as_ref()
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
+
// Derive focused_thread from the active workspace's agent panel.
// Only update when the panel gives us a positive signal β if the
// panel returns None (e.g. still loading after a thread activation),
@@ -612,21 +624,10 @@ impl Sidebar {
.active_conversation()
.and_then(|cv| cv.read(cx).parent_id(cx))
});
- if panel_focused.is_some() {
+ if panel_focused.is_some() && !self.active_thread_is_draft {
self.focused_thread = panel_focused;
}
- // Re-derive agent_panel_visible from the active workspace so it stays
- // correct after workspace switches.
- self.agent_panel_visible = active_workspace
- .as_ref()
- .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
-
- self.active_thread_is_draft = active_workspace
- .as_ref()
- .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
- .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
-
let previous = mem::take(&mut self.contents);
let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = previous
@@ -756,6 +757,7 @@ impl Sidebar {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
});
@@ -842,6 +844,9 @@ impl Sidebar {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: Some(worktree_name.clone()),
+ worktree_full_path: Some(
+ worktree_path.display().to_string().into(),
+ ),
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
});
@@ -886,9 +891,7 @@ impl Sidebar {
ThreadEntryWorkspace::Closed(_) => false,
};
- if thread.is_background && thread.status == AgentThreadStatus::Completed {
- notified_threads.insert(session_id.clone());
- } else if thread.status == AgentThreadStatus::Completed
+ if thread.status == AgentThreadStatus::Completed
&& !is_thread_workspace_active
&& old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
{
@@ -965,6 +968,16 @@ impl Sidebar {
entries.push(thread.into());
}
} else {
+ let thread_count = threads.len();
+ let is_draft_for_workspace = self.agent_panel_visible
+ && self.active_thread_is_draft
+ && self.focused_thread.is_none()
+ && active_workspace
+ .as_ref()
+ .is_some_and(|active| active == workspace);
+
+ let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
+
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
path_list: path_list.clone(),
@@ -979,10 +992,12 @@ impl Sidebar {
continue;
}
- entries.push(ListEntry::NewThread {
- path_list: path_list.clone(),
- workspace: workspace.clone(),
- });
+ if show_new_thread_entry {
+ entries.push(ListEntry::NewThread {
+ path_list: path_list.clone(),
+ workspace: workspace.clone(),
+ });
+ }
let total = threads.len();
@@ -1027,7 +1042,6 @@ impl Sidebar {
if total > DEFAULT_THREADS_SHOWN {
entries.push(ListEntry::ViewMore {
path_list: path_list.clone(),
- remaining_count: total.saturating_sub(visible),
is_fully_expanded,
});
}
@@ -1126,16 +1140,8 @@ impl Sidebar {
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
ListEntry::ViewMore {
path_list,
- remaining_count,
is_fully_expanded,
- } => self.render_view_more(
- ix,
- path_list,
- *remaining_count,
- *is_fully_expanded,
- is_selected,
- cx,
- ),
+ } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
ListEntry::NewThread {
path_list,
workspace,
@@ -1178,6 +1184,13 @@ impl Sidebar {
IconName::ChevronDown
};
+ let has_new_thread_entry = self
+ .contents
+ .entries
+ .get(ix + 1)
+ .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
+ let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
+
let workspace_for_remove = workspace.clone();
let workspace_for_menu = workspace.clone();
@@ -1200,10 +1213,27 @@ impl Sidebar {
.into_any_element()
};
- ListItem::new(id)
- .height(Tab::content_height(cx))
- .group_name(group_name)
- .focused(is_selected)
+ let color = cx.theme().colors();
+ let hover_color = color
+ .element_active
+ .blend(color.element_background.opacity(0.2));
+
+ h_flex()
+ .id(id)
+ .group(&group_name)
+ .h(Tab::content_height(cx))
+ .w_full()
+ .px_1p5()
+ .border_1()
+ .map(|this| {
+ if is_selected {
+ this.border_color(color.border_focused)
+ } else {
+ this.border_color(gpui::transparent_black())
+ }
+ })
+ .justify_between()
+ .hover(|s| s.bg(hover_color))
.child(
h_flex()
.relative()
@@ -1214,7 +1244,7 @@ impl Sidebar {
h_flex().size_4().flex_none().justify_center().child(
Icon::new(disclosure_icon)
.size(IconSize::Small)
- .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
+ .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))),
),
)
.child(label)
@@ -1244,11 +1274,13 @@ impl Sidebar {
)
}),
)
- .end_hover_gradient_overlay(true)
- .end_slot({
+ .child({
+ let workspace_for_new_thread = workspace.clone();
+ let path_list_for_new_thread = path_list.clone();
+
h_flex()
.when(self.project_header_menu_ix != Some(ix), |this| {
- this.visible_on_hover("list_item")
+ this.visible_on_hover(group_name)
})
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
@@ -1300,6 +1332,30 @@ impl Sidebar {
)),
)
})
+ .when(show_new_thread_button, |this| {
+ this.child(
+ IconButton::new(
+ SharedString::from(format!(
+ "{id_prefix}project-header-new-thread-{ix}",
+ )),
+ IconName::Plus,
+ )
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("New Thread"))
+ .on_click(cx.listener({
+ let workspace_for_new_thread = workspace_for_new_thread.clone();
+ let path_list_for_new_thread = path_list_for_new_thread.clone();
+ move |this, _, window, cx| {
+ // Uncollapse the group if collapsed so
+ // the new-thread entry becomes visible.
+ this.collapsed_groups.remove(&path_list_for_new_thread);
+ this.selection = None;
+ this.create_new_thread(&workspace_for_new_thread, window, cx);
+ }
+ })),
+ )
+ })
})
.on_click(cx.listener(move |this, _, window, cx| {
this.selection = None;
@@ -1513,7 +1569,7 @@ impl Sidebar {
let color = cx.theme().colors();
let background = color
.title_bar_background
- .blend(color.panel_background.opacity(0.8));
+ .blend(color.panel_background.opacity(0.2));
let element = v_flex()
.absolute()
@@ -2348,17 +2404,21 @@ impl Sidebar {
ThreadItem::new(id, title)
.icon(thread.icon)
+ .status(thread.status)
.when_some(thread.icon_from_external_svg.clone(), |this, svg| {
this.custom_icon_from_external_svg(svg)
})
.when_some(thread.worktree_name.clone(), |this, name| {
- this.worktree(name)
+ let this = this.worktree(name);
+ match thread.worktree_full_path.clone() {
+ Some(path) => this.worktree_full_path(path),
+ None => this,
+ }
})
.worktree_highlight_positions(thread.worktree_highlight_positions.clone())
.when_some(timestamp, |this, ts| this.timestamp(ts))
.highlight_positions(thread.highlight_positions.to_vec())
- .status(thread.status)
- .generating_title(thread.is_title_generating)
+ .title_generating(thread.is_title_generating)
.notified(has_notification)
.when(thread.diff_stats.lines_added > 0, |this| {
this.added(thread.diff_stats.lines_added as usize)
@@ -2521,7 +2581,6 @@ impl Sidebar {
&self,
ix: usize,
path_list: &PathList,
- remaining_count: usize,
is_fully_expanded: bool,
is_selected: bool,
cx: &mut Context<Self>,
@@ -2529,23 +2588,15 @@ impl Sidebar {
let path_list = path_list.clone();
let id = SharedString::from(format!("view-more-{}", ix));
- let icon = if is_fully_expanded {
- IconName::ListCollapse
- } else {
- IconName::Plus
- };
-
let label: SharedString = if is_fully_expanded {
"Collapse".into()
- } else if remaining_count > 0 {
- format!("View More ({})", remaining_count).into()
} else {
"View More".into()
};
ThreadItem::new(id, label)
- .icon(icon)
.focused(is_selected)
+ .icon_visible(false)
.title_label_color(Color::Muted)
.on_click(cx.listener(move |this, _, _window, cx| {
this.selection = None;
@@ -2650,9 +2701,9 @@ impl Sidebar {
let thread_item = ThreadItem::new(id, label)
.icon(IconName::Plus)
+ .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
.selected(is_active)
.focused(is_selected)
- .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85)))
.when(!is_active, |this| {
this.on_click(cx.listener(move |this, _, window, cx| {
this.selection = None;
@@ -2927,11 +2978,11 @@ impl Render for Sidebar {
let _titlebar_height = ui::utils::platform_title_bar_height(window);
let ui_font = theme::setup_ui_font(window, cx);
let sticky_header = self.render_sticky_header(window, cx);
- let bg = cx
- .theme()
- .colors()
+
+ let color = cx.theme().colors();
+ let bg = color
.title_bar_background
- .blend(cx.theme().colors().panel_background.opacity(0.8));
+ .blend(color.panel_background.opacity(0.32));
let no_open_projects = !self.contents.has_open_projects;
let no_search_results = self.contents.entries.is_empty();
@@ -2965,7 +3016,7 @@ impl Render for Sidebar {
.w(self.width)
.bg(bg)
.border_r_1()
- .border_color(cx.theme().colors().border)
+ .border_color(color.border)
.map(|this| match &self.view {
SidebarView::ThreadList => this
.child(self.render_sidebar_header(no_open_projects, window, cx))
@@ -3240,14 +3291,12 @@ mod tests {
)
}
ListEntry::ViewMore {
- remaining_count,
- is_fully_expanded,
- ..
+ is_fully_expanded, ..
} => {
if *is_fully_expanded {
format!(" - Collapse{}", selected)
} else {
- format!(" + View More ({}){}", remaining_count, selected)
+ format!(" + View More{}", selected)
}
}
ListEntry::NewThread { .. } => {
@@ -3345,7 +3394,6 @@ mod tests {
visible_entries_as_strings(&sidebar, cx),
vec![
"v [my-project]",
- " [+ New Thread]",
" Fix crash in project panel",
" Add inline diff view",
]
@@ -3377,7 +3425,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " [+ New Thread]", " Thread A1"]
+ vec!["v [project-a]", " Thread A1"]
);
// Add a second workspace
@@ -3388,7 +3436,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " [+ New Thread]", " Thread A1",]
+ vec!["v [project-a]", " Thread A1",]
);
// Remove the second workspace
@@ -3399,7 +3447,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " [+ New Thread]", " Thread A1"]
+ vec!["v [project-a]", " Thread A1"]
);
}
@@ -3420,13 +3468,12 @@ mod tests {
visible_entries_as_strings(&sidebar, cx),
vec![
"v [my-project]",
- " [+ New Thread]",
" Thread 12",
" Thread 11",
" Thread 10",
" Thread 9",
" Thread 8",
- " + View More (7)",
+ " + View More",
]
);
}
@@ -3445,23 +3492,23 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- // Initially shows NewThread + 5 threads + View More (12 remaining)
+ // Initially shows 5 threads + View More
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More (12)")));
+ assert_eq!(entries.len(), 7); // header + 5 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More")));
// Focus and navigate to View More, then confirm to expand by one batch
open_and_focus_sidebar(&sidebar, cx);
- for _ in 0..8 {
+ for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
cx.dispatch_action(Confirm);
cx.run_until_parked();
- // Now shows NewThread + 10 threads + View More (7 remaining)
+ // Now shows 10 threads + View More
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 13); // header + NewThread + 10 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More (7)")));
+ assert_eq!(entries.len(), 12); // header + 10 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More")));
// Expand again by one batch
sidebar.update_in(cx, |s, _window, cx| {
@@ -3471,10 +3518,10 @@ mod tests {
});
cx.run_until_parked();
- // Now shows NewThread + 15 threads + View More (2 remaining)
+ // Now shows 15 threads + View More
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 18); // header + NewThread + 15 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More (2)")));
+ assert_eq!(entries.len(), 17); // header + 15 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More")));
// Expand one more time - should show all 17 threads with Collapse button
sidebar.update_in(cx, |s, _window, cx| {
@@ -3486,7 +3533,7 @@ mod tests {
// All 17 threads shown with Collapse button
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 20); // header + NewThread + 17 threads + Collapse
+ assert_eq!(entries.len(), 19); // header + 17 threads + Collapse
assert!(!entries.iter().any(|e| e.contains("View More")));
assert!(entries.iter().any(|e| e.contains("Collapse")));
@@ -3497,10 +3544,10 @@ mod tests {
});
cx.run_until_parked();
- // Back to initial state: NewThread + 5 threads + View More (12 remaining)
+ // Back to initial state: 5 threads + View More
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More
- assert!(entries.iter().any(|e| e.contains("View More (12)")));
+ assert_eq!(entries.len(), 7); // header + 5 threads + View More
+ assert!(entries.iter().any(|e| e.contains("View More")));
}
#[gpui::test]
@@ -3518,7 +3565,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Thread 1"]
+ vec!["v [my-project]", " Thread 1"]
);
// Collapse
@@ -3540,7 +3587,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Thread 1"]
+ vec!["v [my-project]", " Thread 1"]
);
}
@@ -3570,7 +3617,6 @@ mod tests {
has_running_threads: false,
waiting_thread_count: 0,
},
- // Thread with default (Completed) status, not active
ListEntry::Thread(ThreadEntry {
agent: Agent::NativeAgent,
session_info: acp_thread::AgentSessionInfo {
@@ -3590,6 +3636,7 @@ mod tests {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
}),
@@ -3613,6 +3660,7 @@ mod tests {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
}),
@@ -3636,6 +3684,7 @@ mod tests {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
}),
@@ -3659,6 +3708,7 @@ mod tests {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
}),
@@ -3682,13 +3732,13 @@ mod tests {
is_title_generating: false,
highlight_positions: Vec::new(),
worktree_name: None,
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
}),
// View More entry
ListEntry::ViewMore {
path_list: expanded_path.clone(),
- remaining_count: 42,
is_fully_expanded: false,
},
// Collapsed project header
@@ -3701,6 +3751,7 @@ mod tests {
waiting_thread_count: 0,
},
];
+
// Select the Running thread (index 2)
s.selection = Some(2);
});
@@ -3714,7 +3765,7 @@ mod tests {
" Error thread * (error)",
" Waiting thread (waiting)",
" Notified thread * (!)",
- " + View More (42)",
+ " + View More",
"> [collapsed-project]",
]
);
@@ -3758,7 +3809,7 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- // Entries: [header, new_thread, thread3, thread2, thread1]
+ // Entries: [header, thread3, thread2, thread1]
// Focusing the sidebar does not set a selection; select_next/select_previous
// handle None gracefully by starting from the first or last entry.
open_and_focus_sidebar(&sidebar, cx);
@@ -3778,9 +3829,6 @@ mod tests {
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
- cx.dispatch_action(SelectNext);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4));
-
// At the end, wraps back to first entry
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
@@ -3792,13 +3840,8 @@ mod tests {
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
- cx.dispatch_action(SelectNext);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4));
// Move back up
- cx.dispatch_action(SelectPrevious);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
-
cx.dispatch_action(SelectPrevious);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
@@ -3829,7 +3872,7 @@ mod tests {
// SelectLast jumps to the end
cx.dispatch_action(SelectLast);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4));
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
// SelectFirst jumps to the beginning
cx.dispatch_action(SelectFirst);
@@ -3882,7 +3925,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Thread 1"]
+ vec!["v [my-project]", " Thread 1"]
);
// Focus the sidebar and select the header (index 0)
@@ -3906,11 +3949,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [my-project] <== selected",
- " [+ New Thread]",
- " Thread 1",
- ]
+ vec!["v [my-project] <== selected", " Thread 1",]
);
}
@@ -3926,17 +3965,17 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- // Should show header + NewThread + 5 threads + "View More (3)"
+ // Should show header + 5 threads + "View More"
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 8);
- assert!(entries.iter().any(|e| e.contains("View More (3)")));
+ assert_eq!(entries.len(), 7);
+ assert!(entries.iter().any(|e| e.contains("View More")));
- // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 7)
+ // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
open_and_focus_sidebar(&sidebar, cx);
- for _ in 0..8 {
+ for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(7));
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6));
// Confirm on "View More" to expand
cx.dispatch_action(Confirm);
@@ -3944,7 +3983,7 @@ mod tests {
// All 8 threads should now be visible with a "Collapse" button
let entries = visible_entries_as_strings(&sidebar, cx);
- assert_eq!(entries.len(), 11); // header + NewThread + 8 threads + Collapse button
+ assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button
assert!(!entries.iter().any(|e| e.contains("View More")));
assert!(entries.iter().any(|e| e.contains("Collapse")));
}
@@ -3963,7 +4002,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Thread 1"]
+ vec!["v [my-project]", " Thread 1"]
);
// Focus sidebar and manually select the header (index 0). Press left to collapse.
@@ -3986,11 +4025,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [my-project] <== selected",
- " [+ New Thread]",
- " Thread 1",
- ]
+ vec!["v [my-project] <== selected", " Thread 1",]
);
// Press right again on already-expanded header moves selection down
@@ -4014,16 +4049,11 @@ mod tests {
open_and_focus_sidebar(&sidebar, cx);
cx.dispatch_action(SelectNext);
cx.dispatch_action(SelectNext);
- cx.dispatch_action(SelectNext);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [my-project]",
- " [+ New Thread]",
- " Thread 1 <== selected",
- ]
+ vec!["v [my-project]", " Thread 1 <== selected",]
);
// Pressing left on a child collapses the parent group and selects it
@@ -4044,7 +4074,7 @@ mod tests {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- // Even an empty project has the header and a new thread button
+ // An empty project has the header and a new thread button.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec!["v [empty-project]", " [+ New Thread]"]
@@ -4083,12 +4113,11 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- // Focus sidebar (selection starts at None), navigate down to the thread (index 2)
+ // Focus sidebar (selection starts at None), navigate down to the thread (index 1)
open_and_focus_sidebar(&sidebar, cx);
cx.dispatch_action(SelectNext);
cx.dispatch_action(SelectNext);
- cx.dispatch_action(SelectNext);
- assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
+ assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
// Collapse the group, which removes the thread from the list
cx.dispatch_action(SelectParent);
@@ -4188,15 +4217,10 @@ mod tests {
cx.run_until_parked();
let mut entries = visible_entries_as_strings(&sidebar, cx);
- entries[2..].sort();
+ entries[1..].sort();
assert_eq!(
entries,
- vec![
- "v [my-project]",
- " [+ New Thread]",
- " Hello *",
- " Hello * (running)",
- ]
+ vec!["v [my-project]", " Hello *", " Hello * (running)",]
);
}
@@ -4237,7 +4261,7 @@ mod tests {
// Thread A is still running; no notification yet.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " [+ New Thread]", " Hello * (running)",]
+ vec!["v [project-a]", " Hello * (running)",]
);
// Complete thread A's turn (transition Running β Completed).
@@ -4247,7 +4271,7 @@ mod tests {
// The completed background thread shows a notification indicator.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " [+ New Thread]", " Hello * (!)",]
+ vec!["v [project-a]", " Hello * (!)",]
);
}
@@ -4290,7 +4314,6 @@ mod tests {
visible_entries_as_strings(&sidebar, cx),
vec![
"v [my-project]",
- " [+ New Thread]",
" Fix crash in project panel",
" Add inline diff view",
" Refactor settings module",
@@ -4381,12 +4404,7 @@ mod tests {
// Confirm the full list is showing.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [my-project]",
- " [+ New Thread]",
- " Alpha thread",
- " Beta thread",
- ]
+ vec!["v [my-project]", " Alpha thread", " Beta thread",]
);
// User types a search query to filter down.
@@ -4398,16 +4416,14 @@ mod tests {
);
// User presses Escape β filter clears, full list is restored.
- // The selection index (1) now points at the NewThread entry that was
- // re-inserted when the filter was removed.
+ // The selection index (1) now points at the first thread entry.
cx.dispatch_action(Cancel);
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
"v [my-project]",
- " [+ New Thread] <== selected",
- " Alpha thread",
+ " Alpha thread <== selected",
" Beta thread",
]
);
@@ -4463,7 +4479,6 @@ mod tests {
visible_entries_as_strings(&sidebar, cx),
vec![
"v [project-a]",
- " [+ New Thread]",
" Fix bug in sidebar",
" Add tests for editor",
]
@@ -4781,7 +4796,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Historical Thread",]
+ vec!["v [my-project]", " Historical Thread",]
);
// Switch to workspace 1 so we can verify the confirm switches back.
@@ -4843,22 +4858,17 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [my-project]",
- " [+ New Thread]",
- " Thread A",
- " Thread B",
- ]
+ vec!["v [my-project]", " Thread A", " Thread B",]
);
// Keyboard confirm preserves selection.
sidebar.update_in(cx, |sidebar, window, cx| {
- sidebar.selection = Some(2);
+ sidebar.selection = Some(1);
sidebar.confirm(&Confirm, window, cx);
});
assert_eq!(
sidebar.read_with(cx, |sidebar, _| sidebar.selection),
- Some(2)
+ Some(1)
);
// Click handlers clear selection to None so no highlight lingers
@@ -4901,7 +4911,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Hello *"]
+ vec!["v [my-project]", " Hello *"]
);
// Simulate the agent generating a title. The notification chain is:
@@ -4923,11 +4933,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [my-project]",
- " [+ New Thread]",
- " Friendly Greeting with AI *"
- ]
+ vec!["v [my-project]", " Friendly Greeting with AI *"]
);
}
@@ -5179,7 +5185,7 @@ mod tests {
// Verify the thread appears in the sidebar.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a]", " [+ New Thread]", " Hello *",]
+ vec!["v [project-a]", " Hello *",]
);
// The "New Thread" button should NOT be in "active/draft" state
@@ -5340,11 +5346,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Worktree Thread {rosewood}",
- ]
+ vec!["v [project]", " Worktree Thread {rosewood}",]
);
}
@@ -5421,10 +5423,8 @@ mod tests {
visible_entries_as_strings(&sidebar, cx),
vec![
"v [wt-feature-a]",
- " [+ New Thread]",
" Thread A",
"v [wt-feature-b]",
- " [+ New Thread]",
" Thread B",
]
);
@@ -5461,7 +5461,6 @@ mod tests {
visible_entries_as_strings(&sidebar, cx),
vec![
"v [project]",
- " [+ New Thread]",
" Thread A {wt-feature-a}",
" Thread B {wt-feature-b}",
]
@@ -5482,11 +5481,7 @@ mod tests {
// under the main repo.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Thread A {wt-feature-a}",
- ]
+ vec!["v [project]", " Thread A {wt-feature-a}",]
);
}
@@ -5603,11 +5598,7 @@ mod tests {
let entries = visible_entries_as_strings(&sidebar, cx);
assert_eq!(
entries,
- vec![
- "v [project]",
- " [+ New Thread]",
- " Hello {wt-feature-a} * (running)",
- ]
+ vec!["v [project]", " Hello {wt-feature-a} * (running)",]
);
}
@@ -5706,11 +5697,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Hello {wt-feature-a} * (running)",
- ]
+ vec!["v [project]", " Hello {wt-feature-a} * (running)",]
);
connection.end_turn(session_id, acp::StopReason::EndTurn);
@@ -5718,11 +5705,7 @@ mod tests {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Hello {wt-feature-a} * (!)",
- ]
+ vec!["v [project]", " Hello {wt-feature-a} * (!)",]
);
}
@@ -5790,11 +5773,7 @@ mod tests {
// Thread should appear under the main repo with a worktree chip.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " WT Thread {wt-feature-a}"
- ],
+ vec!["v [project]", " WT Thread {wt-feature-a}"],
);
// Only 1 workspace should exist.
@@ -5806,7 +5785,7 @@ mod tests {
// Focus the sidebar and select the worktree thread.
open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
- sidebar.selection = Some(2); // index 0 is header, 1 is NewThread, 2 is the thread
+ sidebar.selection = Some(1); // index 0 is header, 1 is the thread
});
// Confirm to open the worktree thread.
@@ -22,25 +22,26 @@ pub enum AgentThreadStatus {
pub struct ThreadItem {
id: ElementId,
icon: IconName,
+ icon_color: Option<Color>,
+ icon_visible: bool,
custom_icon_from_external_svg: Option<SharedString>,
title: SharedString,
+ title_label_color: Option<Color>,
+ title_generating: bool,
+ highlight_positions: Vec<usize>,
timestamp: SharedString,
notified: bool,
status: AgentThreadStatus,
- generating_title: bool,
selected: bool,
focused: bool,
hovered: bool,
- docked_right: bool,
added: Option<usize>,
removed: Option<usize>,
worktree: Option<SharedString>,
- highlight_positions: Vec<usize>,
+ worktree_full_path: Option<SharedString>,
worktree_highlight_positions: Vec<usize>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
- title_label_color: Option<Color>,
- title_label_size: Option<LabelSize>,
action_slot: Option<AnyElement>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
}
@@ -50,25 +51,26 @@ impl ThreadItem {
Self {
id: id.into(),
icon: IconName::ZedAgent,
+ icon_color: None,
+ icon_visible: true,
custom_icon_from_external_svg: None,
title: title.into(),
+ title_label_color: None,
+ title_generating: false,
+ highlight_positions: Vec::new(),
timestamp: "".into(),
notified: false,
status: AgentThreadStatus::default(),
- generating_title: false,
selected: false,
focused: false,
hovered: false,
- docked_right: false,
added: None,
removed: None,
worktree: None,
- highlight_positions: Vec::new(),
+ worktree_full_path: None,
worktree_highlight_positions: Vec::new(),
on_click: None,
on_hover: Box::new(|_, _, _| {}),
- title_label_color: None,
- title_label_size: None,
action_slot: None,
tooltip: None,
}
@@ -84,6 +86,16 @@ impl ThreadItem {
self
}
+ pub fn icon_color(mut self, color: Color) -> Self {
+ self.icon_color = Some(color);
+ self
+ }
+
+ pub fn icon_visible(mut self, visible: bool) -> Self {
+ self.icon_visible = visible;
+ self
+ }
+
pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
self.custom_icon_from_external_svg = Some(svg.into());
self
@@ -99,8 +111,18 @@ impl ThreadItem {
self
}
- pub fn generating_title(mut self, generating: bool) -> Self {
- self.generating_title = generating;
+ pub fn title_generating(mut self, generating: bool) -> Self {
+ self.title_generating = generating;
+ self
+ }
+
+ pub fn title_label_color(mut self, color: Color) -> Self {
+ self.title_label_color = Some(color);
+ self
+ }
+
+ pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
+ self.highlight_positions = positions;
self
}
@@ -124,18 +146,13 @@ impl ThreadItem {
self
}
- pub fn docked_right(mut self, docked_right: bool) -> Self {
- self.docked_right = docked_right;
- self
- }
-
pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
self.worktree = Some(worktree.into());
self
}
- pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
- self.highlight_positions = positions;
+ pub fn worktree_full_path(mut self, worktree_full_path: impl Into<SharedString>) -> Self {
+ self.worktree_full_path = Some(worktree_full_path.into());
self
}
@@ -162,16 +179,6 @@ impl ThreadItem {
self
}
- pub fn title_label_color(mut self, color: Color) -> Self {
- self.title_label_color = Some(color);
- self
- }
-
- pub fn title_label_size(mut self, size: LabelSize) -> Self {
- self.title_label_size = Some(size);
- self
- }
-
pub fn action_slot(mut self, element: impl IntoElement) -> Self {
self.action_slot = Some(element.into_any_element());
self
@@ -186,6 +193,26 @@ impl ThreadItem {
impl RenderOnce for ThreadItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let color = cx.theme().colors();
+ let base_bg = color
+ .title_bar_background
+ .blend(color.panel_background.opacity(0.2));
+
+ let base_bg = if self.selected {
+ color.element_active
+ } else {
+ base_bg
+ };
+
+ let hover_color = color
+ .element_active
+ .blend(color.element_background.opacity(0.2));
+
+ let gradient_overlay = GradientFade::new(base_bg, hover_color, hover_color)
+ .width(px(64.0))
+ .right(px(-10.0))
+ .gradient_stop(0.75)
+ .group_name("thread-item");
+
let dot_separator = || {
Label::new("β’")
.size(LabelSize::Small)
@@ -194,25 +221,26 @@ impl RenderOnce for ThreadItem {
};
let icon_id = format!("icon-{}", self.id);
+ let icon_visible = self.icon_visible;
let icon_container = || {
h_flex()
.id(icon_id.clone())
.size_4()
.flex_none()
.justify_center()
+ .when(!icon_visible, |this| this.invisible())
};
+ let icon_color = self.icon_color.unwrap_or(Color::Muted);
let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
Icon::from_external_svg(custom_svg)
- .color(Color::Muted)
+ .color(icon_color)
.size(IconSize::Small)
} else {
- Icon::new(self.icon)
- .color(Color::Muted)
- .size(IconSize::Small)
+ Icon::new(self.icon).color(icon_color).size(IconSize::Small)
};
let decoration = |icon: IconDecorationKind, color: Hsla| {
- IconDecoration::new(icon, cx.theme().colors().surface_background, cx)
+ IconDecoration::new(icon, base_bg, cx)
.color(color)
.position(gpui::Point {
x: px(-2.),
@@ -264,10 +292,9 @@ impl RenderOnce for ThreadItem {
let title = self.title;
let highlight_positions = self.highlight_positions;
- let title_label_size = self.title_label_size.unwrap_or(LabelSize::Default);
- let title_label = if self.generating_title {
+
+ let title_label = if self.title_generating {
Label::new(title)
- .size(title_label_size)
.color(Color::Muted)
.with_animation(
"generating-title",
@@ -278,66 +305,38 @@ impl RenderOnce for ThreadItem {
)
.into_any_element()
} else if highlight_positions.is_empty() {
- let label = Label::new(title).size(title_label_size);
- let label = if let Some(color) = self.title_label_color {
- label.color(color)
- } else {
- label
- };
- label.into_any_element()
- } else {
- let label = HighlightedLabel::new(title, highlight_positions).size(title_label_size);
- let label = if let Some(color) = self.title_label_color {
- label.color(color)
- } else {
- label
- };
- label.into_any_element()
- };
-
- let b_bg = color
- .title_bar_background
- .blend(color.panel_background.opacity(0.8));
-
- let base_bg = if self.selected {
- color.element_active
+ Label::new(title)
+ .when_some(self.title_label_color, |label, color| label.color(color))
+ .into_any_element()
} else {
- b_bg
+ HighlightedLabel::new(title, highlight_positions)
+ .when_some(self.title_label_color, |label, color| label.color(color))
+ .into_any_element()
};
- let gradient_overlay =
- GradientFade::new(base_bg, color.element_hover, color.element_active)
- .width(px(64.0))
- .right(px(-10.0))
- .gradient_stop(0.75)
- .group_name("thread-item");
-
let has_diff_stats = self.added.is_some() || self.removed.is_some();
+ let diff_stat_id = self.id.clone();
let added_count = self.added.unwrap_or(0);
let removed_count = self.removed.unwrap_or(0);
- let diff_stat_id = self.id.clone();
+
let has_worktree = self.worktree.is_some();
let has_timestamp = !self.timestamp.is_empty();
let timestamp = self.timestamp;
v_flex()
.id(self.id.clone())
+ .cursor_pointer()
.group("thread-item")
.relative()
.overflow_hidden()
- .cursor_pointer()
.w_full()
.py_1()
.px_1p5()
.when(self.selected, |s| s.bg(color.element_active))
.border_1()
.border_color(gpui::transparent_black())
- .when(self.focused, |s| {
- s.when(self.docked_right, |s| s.border_r_2())
- .border_color(color.border_focused)
- })
- .hover(|s| s.bg(color.element_hover))
- .active(|s| s.bg(color.element_active))
+ .when(self.focused, |s| s.border_color(color.border_focused))
+ .hover(|s| s.bg(hover_color))
.on_hover(self.on_hover)
.child(
h_flex()
@@ -358,15 +357,11 @@ impl RenderOnce for ThreadItem {
.child(gradient_overlay)
.when(self.hovered, |this| {
this.when_some(self.action_slot, |this, slot| {
- let overlay = GradientFade::new(
- base_bg,
- color.element_hover,
- color.element_active,
- )
- .width(px(64.0))
- .right(px(6.))
- .gradient_stop(0.75)
- .group_name("thread-item");
+ let overlay = GradientFade::new(base_bg, hover_color, hover_color)
+ .width(px(64.0))
+ .right(px(6.))
+ .gradient_stop(0.75)
+ .group_name("thread-item");
this.child(
h_flex()
@@ -380,57 +375,56 @@ impl RenderOnce for ThreadItem {
})
}),
)
- .when_some(self.worktree, |this, worktree| {
- let worktree_highlight_positions = self.worktree_highlight_positions;
- let worktree_label = if worktree_highlight_positions.is_empty() {
- Label::new(worktree)
- .size(LabelSize::Small)
- .color(Color::Muted)
- .into_any_element()
- } else {
- HighlightedLabel::new(worktree, worktree_highlight_positions)
- .size(LabelSize::Small)
- .color(Color::Muted)
- .into_any_element()
- };
+ .when(has_worktree || has_diff_stats || has_timestamp, |this| {
+ let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default();
+ let worktree_label = self.worktree.map(|worktree| {
+ let positions = self.worktree_highlight_positions;
+ if positions.is_empty() {
+ Label::new(worktree)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .into_any_element()
+ } else {
+ HighlightedLabel::new(worktree, positions)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .into_any_element()
+ }
+ });
this.child(
h_flex()
.min_w_0()
.gap_1p5()
.child(icon_container()) // Icon Spacing
- .child(worktree_label)
- .when(has_diff_stats || has_timestamp, |this| {
- this.child(dot_separator())
- })
- .when(has_diff_stats, |this| {
+ .when_some(worktree_label, |this, label| {
this.child(
- DiffStat::new(diff_stat_id.clone(), added_count, removed_count)
- .tooltip("Unreviewed changes"),
+ h_flex()
+ .id(format!("{}-worktree", self.id.clone()))
+ .gap_1()
+ .child(
+ Icon::new(IconName::GitWorktree)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(label)
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(
+ "Thread Running in a Local Git Worktree",
+ None,
+ worktree_full_path.clone(),
+ cx,
+ )
+ }),
)
})
- .when(has_diff_stats && has_timestamp, |this| {
+ .when(has_worktree && (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_worktree && (has_diff_stats || has_timestamp), |this| {
- this.child(
- h_flex()
- .min_w_0()
- .gap_1p5()
- .child(icon_container()) // Icon Spacing
.when(has_diff_stats, |this| {
this.child(
DiffStat::new(diff_stat_id, added_count, removed_count)
- .tooltip("Unreviewed Changes"),
+ .tooltip("Unreviewed changes"),
)
})
.when(has_diff_stats && has_timestamp, |this| {
@@ -583,18 +577,6 @@ impl Component for ThreadItem {
)
.into_any_element(),
),
- single_example(
- "Focused + Docked Right",
- container()
- .child(
- ThreadItem::new("ti-7b", "Focused with right dock border")
- .icon(IconName::AiClaude)
- .timestamp("1w")
- .focused(true)
- .docked_right(true),
- )
- .into_any_element(),
- ),
single_example(
"Selected + Focused",
container()