1use action_log::DiffStats;
2use agent_client_protocol as acp;
3use agent_ui::thread_metadata_store::ThreadMetadata;
4use gpui::{
5 Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers,
6 ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*,
7};
8use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, prelude::*};
9use workspace::{ModalView, Workspace};
10use zed_actions::agents_sidebar::ToggleThreadSwitcher;
11
12pub(crate) struct ThreadSwitcherEntry {
13 pub session_id: acp::SessionId,
14 pub title: SharedString,
15 pub icon: IconName,
16 pub icon_from_external_svg: Option<SharedString>,
17 pub status: AgentThreadStatus,
18 pub metadata: ThreadMetadata,
19 pub workspace: Entity<Workspace>,
20 pub project_name: Option<SharedString>,
21 pub worktrees: Vec<ThreadItemWorktreeInfo>,
22 pub diff_stats: DiffStats,
23 pub is_title_generating: bool,
24 pub notified: bool,
25 pub timestamp: SharedString,
26}
27
28pub(crate) enum ThreadSwitcherEvent {
29 Preview {
30 metadata: ThreadMetadata,
31 workspace: Entity<Workspace>,
32 },
33 Confirmed {
34 metadata: ThreadMetadata,
35 workspace: Entity<Workspace>,
36 },
37 Dismissed,
38}
39
40pub(crate) struct ThreadSwitcher {
41 focus_handle: FocusHandle,
42 entries: Vec<ThreadSwitcherEntry>,
43 selected_index: usize,
44 init_modifiers: Option<Modifiers>,
45 scroll_handle: ScrollHandle,
46}
47
48impl ThreadSwitcher {
49 pub fn new(
50 entries: Vec<ThreadSwitcherEntry>,
51 select_last: bool,
52 window: &mut gpui::Window,
53 cx: &mut Context<Self>,
54 ) -> Self {
55 let init_modifiers = window.modifiers().modified().then_some(window.modifiers());
56 let selected_index = if entries.is_empty() {
57 0
58 } else if select_last {
59 entries.len() - 1
60 } else {
61 1.min(entries.len().saturating_sub(1))
62 };
63
64 if let Some(entry) = entries.get(selected_index) {
65 cx.emit(ThreadSwitcherEvent::Preview {
66 metadata: entry.metadata.clone(),
67 workspace: entry.workspace.clone(),
68 });
69 }
70
71 let focus_handle = cx.focus_handle();
72 cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| {
73 cx.emit(ThreadSwitcherEvent::Dismissed);
74 cx.emit(DismissEvent);
75 })
76 .detach();
77
78 let scroll_handle = ScrollHandle::new();
79 scroll_handle.scroll_to_item(selected_index);
80
81 Self {
82 focus_handle,
83 entries,
84 selected_index,
85 init_modifiers,
86 scroll_handle,
87 }
88 }
89
90 pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> {
91 self.entries.get(self.selected_index)
92 }
93
94 #[cfg(test)]
95 pub fn entries(&self) -> &[ThreadSwitcherEntry] {
96 &self.entries
97 }
98
99 #[cfg(test)]
100 pub fn selected_index(&self) -> usize {
101 self.selected_index
102 }
103
104 pub fn cycle_selection(&mut self, cx: &mut Context<Self>) {
105 if self.entries.is_empty() {
106 return;
107 }
108 self.selected_index = (self.selected_index + 1) % self.entries.len();
109 self.emit_preview(cx);
110 }
111
112 pub fn select_last(&mut self, cx: &mut Context<Self>) {
113 if self.entries.is_empty() {
114 return;
115 }
116 if self.selected_index == 0 {
117 self.selected_index = self.entries.len() - 1;
118 } else {
119 self.selected_index -= 1;
120 }
121 self.emit_preview(cx);
122 }
123
124 fn emit_preview(&mut self, cx: &mut Context<Self>) {
125 self.scroll_handle.scroll_to_item(self.selected_index);
126 if let Some(entry) = self.entries.get(self.selected_index) {
127 cx.emit(ThreadSwitcherEvent::Preview {
128 metadata: entry.metadata.clone(),
129 workspace: entry.workspace.clone(),
130 });
131 }
132 }
133
134 fn confirm(&mut self, _: &menu::Confirm, _window: &mut gpui::Window, cx: &mut Context<Self>) {
135 self.confirm_selected(cx);
136 }
137
138 fn confirm_selected(&mut self, cx: &mut Context<Self>) {
139 if let Some(entry) = self.entries.get(self.selected_index) {
140 cx.emit(ThreadSwitcherEvent::Confirmed {
141 metadata: entry.metadata.clone(),
142 workspace: entry.workspace.clone(),
143 });
144 }
145 cx.emit(DismissEvent);
146 }
147
148 fn select_and_confirm(&mut self, index: usize, cx: &mut Context<Self>) {
149 if index < self.entries.len() {
150 self.selected_index = index;
151 self.confirm_selected(cx);
152 }
153 }
154
155 fn select_index(&mut self, index: usize, cx: &mut Context<Self>) {
156 if index >= self.entries.len() || index == self.selected_index {
157 return;
158 }
159 self.selected_index = index;
160 self.scroll_handle.scroll_to_item(index);
161 self.emit_preview(cx);
162 cx.notify();
163 }
164
165 fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context<Self>) {
166 cx.emit(ThreadSwitcherEvent::Dismissed);
167 cx.emit(DismissEvent);
168 }
169
170 fn toggle(
171 &mut self,
172 action: &ToggleThreadSwitcher,
173 _window: &mut gpui::Window,
174 cx: &mut Context<Self>,
175 ) {
176 if action.select_last {
177 self.select_last(cx);
178 } else {
179 self.cycle_selection(cx);
180 }
181 }
182
183 fn handle_modifiers_changed(
184 &mut self,
185 event: &ModifiersChangedEvent,
186 window: &mut gpui::Window,
187 cx: &mut Context<Self>,
188 ) {
189 let Some(init_modifiers) = self.init_modifiers else {
190 return;
191 };
192 if !event.modified() || !init_modifiers.is_subset_of(event) {
193 self.init_modifiers = None;
194 if self.entries.is_empty() {
195 cx.emit(DismissEvent);
196 } else {
197 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
198 }
199 }
200 }
201}
202
203impl ModalView for ThreadSwitcher {}
204
205impl EventEmitter<DismissEvent> for ThreadSwitcher {}
206impl EventEmitter<ThreadSwitcherEvent> for ThreadSwitcher {}
207
208impl Focusable for ThreadSwitcher {
209 fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
210 self.focus_handle.clone()
211 }
212}
213
214impl Render for ThreadSwitcher {
215 fn render(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
216 let selected_index = self.selected_index;
217
218 v_flex()
219 .key_context("ThreadSwitcher")
220 .track_focus(&self.focus_handle)
221 .p_1p5()
222 .w(rems_from_px(440.))
223 .elevation_3(cx)
224 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
225 .on_action(cx.listener(Self::confirm))
226 .on_action(cx.listener(Self::cancel))
227 .on_action(cx.listener(Self::toggle))
228 .child(
229 v_flex()
230 .id("thread-switcher-list")
231 .gap_0p5()
232 .max_h_128()
233 .overflow_y_scroll()
234 .track_scroll(&self.scroll_handle)
235 .children(self.entries.iter().enumerate().map(|(ix, entry)| {
236 let id =
237 SharedString::from(format!("thread-switcher-{}", entry.session_id));
238
239 ThreadItem::new(id, entry.title.clone())
240 .rounded(true)
241 .icon(entry.icon)
242 .status(entry.status)
243 .when_some(entry.icon_from_external_svg.clone(), |this, svg| {
244 this.custom_icon_from_external_svg(svg)
245 })
246 .when_some(entry.project_name.clone(), |this, name| {
247 this.project_name(name)
248 })
249 .worktrees(entry.worktrees.clone())
250 .timestamp(entry.timestamp.clone())
251 .title_generating(entry.is_title_generating)
252 .notified(entry.notified)
253 .when(entry.diff_stats.lines_added > 0, |this| {
254 this.added(entry.diff_stats.lines_added as usize)
255 })
256 .when(entry.diff_stats.lines_removed > 0, |this| {
257 this.removed(entry.diff_stats.lines_removed as usize)
258 })
259 .selected(ix == selected_index)
260 .base_bg(cx.theme().colors().elevated_surface_background)
261 .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
262 if *hovered {
263 this.select_index(ix, cx);
264 }
265 }))
266 // TODO: This is not properly propagating to the tread item.
267 .on_click(cx.listener(
268 move |this, _event: &gpui::ClickEvent, _window, cx| {
269 this.select_and_confirm(ix, cx);
270 },
271 ))
272 .into_any_element()
273 })),
274 )
275 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
276 }
277}