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