thread_switcher.rs

  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}