Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:26.027Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

thread_switcher.rs

422 lines · 13.7 KB · rust
1use action_log::DiffStats;
2#[cfg(test)]
3use agent_ui::TerminalId;
4use agent_ui::{
5    terminal_thread_metadata_store::TerminalThreadMetadata, thread_metadata_store::ThreadMetadata,
6};
7use gpui::{
8    Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers,
9    ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*,
10};
11use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, prelude::*};
12use workspace::{ModalView, Workspace};
13use zed_actions::agents_sidebar::ToggleThreadSwitcher;
14
15use super::ThreadEntryWorkspace;
16
17#[derive(Clone)]
18pub(crate) struct ThreadSwitcherThreadEntry {
19    pub title: SharedString,
20    pub icon: IconName,
21    pub icon_from_external_svg: Option<SharedString>,
22    pub status: AgentThreadStatus,
23    pub metadata: ThreadMetadata,
24    pub workspace: Entity<Workspace>,
25    pub project_name: Option<SharedString>,
26    pub worktrees: Vec<ThreadItemWorktreeInfo>,
27    pub diff_stats: DiffStats,
28    pub is_draft: bool,
29    pub is_title_generating: bool,
30    pub notified: bool,
31    pub timestamp: SharedString,
32}
33
34#[derive(Clone)]
35pub(crate) struct ThreadSwitcherTerminalEntry {
36    pub metadata: TerminalThreadMetadata,
37    pub(super) workspace: ThreadEntryWorkspace,
38    pub project_name: Option<SharedString>,
39    pub worktrees: Vec<ThreadItemWorktreeInfo>,
40    pub notified: bool,
41    pub timestamp: SharedString,
42}
43
44#[derive(Clone)]
45pub(crate) enum ThreadSwitcherEntry {
46    Thread(ThreadSwitcherThreadEntry),
47    Terminal(ThreadSwitcherTerminalEntry),
48}
49
50#[derive(Clone)]
51pub(super) enum ThreadSwitcherSelection {
52    Thread {
53        metadata: ThreadMetadata,
54        workspace: Entity<Workspace>,
55    },
56    Terminal {
57        metadata: TerminalThreadMetadata,
58        workspace: ThreadEntryWorkspace,
59    },
60}
61
62impl ThreadSwitcherEntry {
63    pub(super) fn selection(&self) -> ThreadSwitcherSelection {
64        match self {
65            Self::Thread(entry) => ThreadSwitcherSelection::Thread {
66                metadata: entry.metadata.clone(),
67                workspace: entry.workspace.clone(),
68            },
69            Self::Terminal(entry) => ThreadSwitcherSelection::Terminal {
70                metadata: entry.metadata.clone(),
71                workspace: entry.workspace.clone(),
72            },
73        }
74    }
75
76    fn element_id(&self) -> SharedString {
77        match self {
78            Self::Thread(entry) => SharedString::from(format!(
79                "thread-switcher-thread-{:?}",
80                entry.metadata.thread_id
81            )),
82            Self::Terminal(entry) => SharedString::from(format!(
83                "thread-switcher-terminal-{}",
84                entry.metadata.terminal_id
85            )),
86        }
87    }
88
89    fn title(&self) -> SharedString {
90        match self {
91            Self::Thread(entry) => entry.title.clone(),
92            Self::Terminal(entry) => entry.metadata.display_title(),
93        }
94    }
95
96    fn icon(&self) -> IconName {
97        match self {
98            Self::Thread(entry) if entry.is_draft => IconName::Circle,
99            Self::Thread(entry) => entry.icon,
100            Self::Terminal(_) => IconName::Terminal,
101        }
102    }
103
104    fn icon_from_external_svg(&self) -> Option<SharedString> {
105        match self {
106            Self::Thread(entry) if entry.is_draft => None,
107            Self::Thread(entry) => entry.icon_from_external_svg.clone(),
108            Self::Terminal(_) => None,
109        }
110    }
111
112    fn status(&self) -> AgentThreadStatus {
113        match self {
114            Self::Thread(entry) => entry.status,
115            Self::Terminal(_) => AgentThreadStatus::default(),
116        }
117    }
118
119    fn project_name(&self) -> Option<SharedString> {
120        match self {
121            Self::Thread(entry) => entry.project_name.clone(),
122            Self::Terminal(entry) => entry.project_name.clone(),
123        }
124    }
125
126    fn worktrees(&self) -> Vec<ThreadItemWorktreeInfo> {
127        match self {
128            Self::Thread(entry) => entry.worktrees.clone(),
129            Self::Terminal(entry) => entry.worktrees.clone(),
130        }
131    }
132
133    fn timestamp(&self) -> SharedString {
134        match self {
135            Self::Thread(entry) => entry.timestamp.clone(),
136            Self::Terminal(entry) => entry.timestamp.clone(),
137        }
138    }
139
140    fn is_draft(&self) -> bool {
141        match self {
142            Self::Thread(entry) => entry.is_draft,
143            Self::Terminal(_) => false,
144        }
145    }
146
147    fn is_title_generating(&self) -> bool {
148        match self {
149            Self::Thread(entry) => entry.is_title_generating,
150            Self::Terminal(_) => false,
151        }
152    }
153
154    fn notified(&self) -> bool {
155        match self {
156            Self::Thread(entry) => entry.notified,
157            Self::Terminal(entry) => entry.notified,
158        }
159    }
160
161    fn diff_stats(&self) -> DiffStats {
162        match self {
163            Self::Thread(entry) => entry.diff_stats,
164            Self::Terminal(_) => DiffStats::default(),
165        }
166    }
167
168    #[cfg(test)]
169    pub fn thread_id(&self) -> Option<agent_ui::ThreadId> {
170        match self {
171            Self::Thread(entry) => Some(entry.metadata.thread_id),
172            Self::Terminal(_) => None,
173        }
174    }
175
176    #[cfg(test)]
177    pub fn terminal_id(&self) -> Option<TerminalId> {
178        match self {
179            Self::Thread(_) => None,
180            Self::Terminal(entry) => Some(entry.metadata.terminal_id),
181        }
182    }
183}
184
185pub(super) enum ThreadSwitcherEvent {
186    Preview(ThreadSwitcherSelection),
187    Confirmed(ThreadSwitcherSelection),
188    Dismissed,
189}
190
191pub(crate) struct ThreadSwitcher {
192    focus_handle: FocusHandle,
193    entries: Vec<ThreadSwitcherEntry>,
194    selected_index: usize,
195    init_modifiers: Option<Modifiers>,
196    scroll_handle: ScrollHandle,
197}
198
199impl ThreadSwitcher {
200    pub fn new(
201        entries: Vec<ThreadSwitcherEntry>,
202        select_last: bool,
203        window: &mut gpui::Window,
204        cx: &mut Context<Self>,
205    ) -> Self {
206        let init_modifiers = window.modifiers().modified().then_some(window.modifiers());
207        let selected_index = if entries.is_empty() {
208            0
209        } else if select_last {
210            entries.len() - 1
211        } else {
212            1.min(entries.len().saturating_sub(1))
213        };
214
215        if let Some(entry) = entries.get(selected_index) {
216            cx.emit(ThreadSwitcherEvent::Preview(entry.selection()));
217        }
218
219        let focus_handle = cx.focus_handle();
220        cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| {
221            cx.emit(ThreadSwitcherEvent::Dismissed);
222            cx.emit(DismissEvent);
223        })
224        .detach();
225
226        let scroll_handle = ScrollHandle::new();
227        scroll_handle.scroll_to_item(selected_index);
228
229        Self {
230            focus_handle,
231            entries,
232            selected_index,
233            init_modifiers,
234            scroll_handle,
235        }
236    }
237
238    pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> {
239        self.entries.get(self.selected_index)
240    }
241
242    #[cfg(test)]
243    pub fn entries(&self) -> &[ThreadSwitcherEntry] {
244        &self.entries
245    }
246
247    #[cfg(test)]
248    pub fn selected_index(&self) -> usize {
249        self.selected_index
250    }
251
252    pub fn cycle_selection(&mut self, cx: &mut Context<Self>) {
253        if self.entries.is_empty() {
254            return;
255        }
256        self.selected_index = (self.selected_index + 1) % self.entries.len();
257        self.emit_preview(cx);
258    }
259
260    pub fn select_last(&mut self, cx: &mut Context<Self>) {
261        if self.entries.is_empty() {
262            return;
263        }
264        if self.selected_index == 0 {
265            self.selected_index = self.entries.len() - 1;
266        } else {
267            self.selected_index -= 1;
268        }
269        self.emit_preview(cx);
270    }
271
272    fn emit_preview(&mut self, cx: &mut Context<Self>) {
273        self.scroll_handle.scroll_to_item(self.selected_index);
274        if let Some(entry) = self.entries.get(self.selected_index) {
275            cx.emit(ThreadSwitcherEvent::Preview(entry.selection()));
276        }
277    }
278
279    fn confirm(&mut self, _: &menu::Confirm, _window: &mut gpui::Window, cx: &mut Context<Self>) {
280        self.confirm_selected(cx);
281    }
282
283    fn confirm_selected(&mut self, cx: &mut Context<Self>) {
284        if let Some(entry) = self.entries.get(self.selected_index) {
285            cx.emit(ThreadSwitcherEvent::Confirmed(entry.selection()));
286        }
287        cx.emit(DismissEvent);
288    }
289
290    fn select_and_confirm(&mut self, index: usize, cx: &mut Context<Self>) {
291        if index < self.entries.len() {
292            self.selected_index = index;
293            self.confirm_selected(cx);
294        }
295    }
296
297    fn select_index(&mut self, index: usize, cx: &mut Context<Self>) {
298        if index >= self.entries.len() || index == self.selected_index {
299            return;
300        }
301        self.selected_index = index;
302        self.scroll_handle.scroll_to_item(index);
303        self.emit_preview(cx);
304        cx.notify();
305    }
306
307    fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context<Self>) {
308        cx.emit(ThreadSwitcherEvent::Dismissed);
309        cx.emit(DismissEvent);
310    }
311
312    fn toggle(
313        &mut self,
314        action: &ToggleThreadSwitcher,
315        _window: &mut gpui::Window,
316        cx: &mut Context<Self>,
317    ) {
318        if action.select_last {
319            self.select_last(cx);
320        } else {
321            self.cycle_selection(cx);
322        }
323    }
324
325    fn handle_modifiers_changed(
326        &mut self,
327        event: &ModifiersChangedEvent,
328        window: &mut gpui::Window,
329        cx: &mut Context<Self>,
330    ) {
331        let Some(init_modifiers) = self.init_modifiers else {
332            return;
333        };
334        if !event.modified() || !init_modifiers.is_subset_of(event) {
335            self.init_modifiers = None;
336            if self.entries.is_empty() {
337                cx.emit(DismissEvent);
338            } else {
339                window.dispatch_action(menu::Confirm.boxed_clone(), cx);
340            }
341        }
342    }
343}
344
345impl ModalView for ThreadSwitcher {}
346
347impl EventEmitter<DismissEvent> for ThreadSwitcher {}
348impl EventEmitter<ThreadSwitcherEvent> for ThreadSwitcher {}
349
350impl Focusable for ThreadSwitcher {
351    fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
352        self.focus_handle.clone()
353    }
354}
355
356impl Render for ThreadSwitcher {
357    fn render(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
358        let selected_index = self.selected_index;
359
360        v_flex()
361            .key_context("ThreadSwitcher")
362            .track_focus(&self.focus_handle)
363            .p_1p5()
364            .w(rems_from_px(440.))
365            .elevation_3(cx)
366            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
367            .on_action(cx.listener(Self::confirm))
368            .on_action(cx.listener(Self::cancel))
369            .on_action(cx.listener(Self::toggle))
370            .child(
371                v_flex()
372                    .id("thread-switcher-list")
373                    .gap_0p5()
374                    .max_h_128()
375                    .overflow_y_scroll()
376                    .track_scroll(&self.scroll_handle)
377                    .children(self.entries.iter().enumerate().map(|(ix, entry)| {
378                        let diff_stats = entry.diff_stats();
379
380                        ThreadItem::new(entry.element_id(), entry.title())
381                            .rounded(true)
382                            .icon(entry.icon())
383                            .when(entry.is_draft(), |this| {
384                                this.icon_color(Color::Custom(
385                                    cx.theme().colors().icon_muted.opacity(0.2),
386                                ))
387                            })
388                            .status(entry.status())
389                            .when_some(entry.icon_from_external_svg(), |this, svg| {
390                                this.custom_icon_from_external_svg(svg)
391                            })
392                            .when_some(entry.project_name(), |this, name| this.project_name(name))
393                            .worktrees(entry.worktrees())
394                            .timestamp(entry.timestamp())
395                            .title_generating(entry.is_title_generating())
396                            .notified(entry.notified())
397                            .when(diff_stats.lines_added > 0, |this| {
398                                this.added(diff_stats.lines_added as usize)
399                            })
400                            .when(diff_stats.lines_removed > 0, |this| {
401                                this.removed(diff_stats.lines_removed as usize)
402                            })
403                            .selected(ix == selected_index)
404                            .base_bg(cx.theme().colors().elevated_surface_background)
405                            .on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
406                                if *hovered {
407                                    this.select_index(ix, cx);
408                                }
409                            }))
410                            // TODO: This is not properly propagating to the tread item.
411                            .on_click(cx.listener(
412                                move |this, _event: &gpui::ClickEvent, _window, cx| {
413                                    this.select_and_confirm(ix, cx);
414                                },
415                            ))
416                            .into_any_element()
417                    })),
418            )
419            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
420    }
421}
422
Served at tenant.openagents/omega Member data and write actions are omitted.