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