Skip to repository content422 lines · 13.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:50:26.027Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
thread_switcher.rs
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