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 cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context<Self>) {
150 cx.emit(ThreadSwitcherEvent::Dismissed);
151 cx.emit(DismissEvent);
152 }
153
154 fn toggle(
155 &mut self,
156 action: &ToggleThreadSwitcher,
157 _window: &mut gpui::Window,
158 cx: &mut Context<Self>,
159 ) {
160 if action.select_last {
161 self.select_last(cx);
162 } else {
163 self.cycle_selection(cx);
164 }
165 }
166
167 fn handle_modifiers_changed(
168 &mut self,
169 event: &ModifiersChangedEvent,
170 window: &mut gpui::Window,
171 cx: &mut Context<Self>,
172 ) {
173 let Some(init_modifiers) = self.init_modifiers else {
174 return;
175 };
176 if !event.modified() || !init_modifiers.is_subset_of(event) {
177 self.init_modifiers = None;
178 if self.entries.is_empty() {
179 cx.emit(DismissEvent);
180 } else {
181 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
182 }
183 }
184 }
185}
186
187impl ModalView for ThreadSwitcher {}
188
189impl EventEmitter<DismissEvent> for ThreadSwitcher {}
190impl EventEmitter<ThreadSwitcherEvent> for ThreadSwitcher {}
191
192impl Focusable for ThreadSwitcher {
193 fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
194 self.focus_handle.clone()
195 }
196}
197
198impl Render for ThreadSwitcher {
199 fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
200 let selected_index = self.selected_index;
201
202 v_flex()
203 .key_context("ThreadSwitcher")
204 .track_focus(&self.focus_handle)
205 .w(rems_from_px(440.))
206 .p_1p5()
207 .gap_0p5()
208 .elevation_3(cx)
209 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
210 .on_action(cx.listener(Self::confirm))
211 .on_action(cx.listener(Self::cancel))
212 .on_action(cx.listener(Self::toggle))
213 .children(self.entries.iter().enumerate().map(|(ix, entry)| {
214 let id = SharedString::from(format!("thread-switcher-{}", entry.session_id));
215
216 div()
217 .id(id.clone())
218 .on_click(
219 cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| {
220 this.select_and_confirm(ix, cx);
221 }),
222 )
223 .child(
224 ThreadItem::new(id, entry.title.clone())
225 .rounded(true)
226 .icon(entry.icon)
227 .status(entry.status)
228 .when_some(entry.icon_from_external_svg.clone(), |this, svg| {
229 this.custom_icon_from_external_svg(svg)
230 })
231 .when_some(entry.project_name.clone(), |this, name| {
232 this.project_name(name)
233 })
234 .worktrees(entry.worktrees.clone())
235 .timestamp(entry.timestamp.clone())
236 .title_generating(entry.is_title_generating)
237 .notified(entry.notified)
238 .when(entry.diff_stats.lines_added > 0, |this| {
239 this.added(entry.diff_stats.lines_added as usize)
240 })
241 .when(entry.diff_stats.lines_removed > 0, |this| {
242 this.removed(entry.diff_stats.lines_removed as usize)
243 })
244 .selected(ix == selected_index)
245 .base_bg(cx.theme().colors().elevated_surface_background),
246 )
247 .into_any_element()
248 }))
249 }
250}