Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:02:11.799Z 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

threads_archive_view.rs

1690 lines · 60.0 KB · rust
1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use crate::agent_connection_store::AgentConnectionStore;
6
7use crate::thread_metadata_store::{
8    ThreadId, ThreadMetadata, ThreadMetadataStore, worktree_info_from_thread_paths,
9};
10use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread};
11
12use agent::ThreadStore;
13use agent_client_protocol::schema::v1 as acp;
14use agent_settings::AgentSettings;
15use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
16use collections::HashMap;
17use editor::Editor;
18use fs::Fs;
19use fuzzy::{StringMatch, StringMatchCandidate};
20use gpui::{
21    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
22    ListState, Render, SharedString, Subscription, Task, TaskExt, WeakEntity, Window, list,
23    prelude::*, px,
24};
25use itertools::Itertools as _;
26use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
27use picker::{
28    Picker, PickerDelegate,
29    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
30};
31use project::{AgentId, AgentServerStore};
32use settings::Settings as _;
33use theme::ActiveTheme;
34use ui::{
35    AgentThreadStatus, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, ScrollAxes,
36    Scrollbars, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*,
37    utils::platform_title_bar_height,
38};
39use util::ResultExt;
40use util::paths::PathExt;
41use workspace::{
42    CloseWindow, ModalView, PathList, RecentWorkspace, SerializedWorkspaceLocation, Workspace,
43    WorkspaceDb, WorkspaceId,
44};
45
46use zed_actions::agents_sidebar::FocusSidebarFilter;
47use zed_actions::editor::{MoveDown, MoveUp};
48
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50enum ThreadFilter {
51    #[default]
52    All,
53    ArchivedOnly,
54}
55
56#[derive(Clone)]
57enum ArchiveListItem {
58    BucketSeparator(TimeBucket),
59    Entry {
60        thread: ThreadMetadata,
61        highlight_positions: Vec<usize>,
62    },
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66enum TimeBucket {
67    Today,
68    Yesterday,
69    ThisWeek,
70    PastWeek,
71    Older,
72}
73
74impl TimeBucket {
75    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
76        if date == reference {
77            return TimeBucket::Today;
78        }
79        if date == reference - TimeDelta::days(1) {
80            return TimeBucket::Yesterday;
81        }
82        let week = date.iso_week();
83        if reference.iso_week() == week {
84            return TimeBucket::ThisWeek;
85        }
86        let last_week = (reference - TimeDelta::days(7)).iso_week();
87        if week == last_week {
88            return TimeBucket::PastWeek;
89        }
90        TimeBucket::Older
91    }
92
93    fn label(&self) -> &'static str {
94        match self {
95            TimeBucket::Today => "Today",
96            TimeBucket::Yesterday => "Yesterday",
97            TimeBucket::ThisWeek => "This Week",
98            TimeBucket::PastWeek => "Past Week",
99            TimeBucket::Older => "Older",
100        }
101    }
102}
103
104pub fn fuzzy_match_positions(query: &str, candidate: &str) -> Option<Vec<usize>> {
105    let query_chars: Vec<char> = query.chars().collect();
106    if query_chars.is_empty() {
107        return Some(Vec::new());
108    }
109
110    let candidate_chars: Vec<(usize, char)> = candidate.char_indices().collect();
111    let window_count = candidate_chars.len().checked_sub(query_chars.len() - 1)?;
112
113    'outer: for window_start in 0..window_count {
114        for (qi, &query_char) in query_chars.iter().enumerate() {
115            let (_, cand_char) = candidate_chars[window_start + qi];
116            if !cand_char.eq_ignore_ascii_case(&query_char) {
117                continue 'outer;
118            }
119        }
120        return Some(
121            (0..query_chars.len())
122                .map(|qi| candidate_chars[window_start + qi].0)
123                .collect(),
124        );
125    }
126
127    None
128}
129
130pub enum ThreadsArchiveViewEvent {
131    Close,
132    Activate { thread: ThreadMetadata },
133    CancelRestore { thread_id: ThreadId },
134    Import,
135    NewThread,
136}
137
138impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
139
140pub struct ThreadsArchiveView {
141    _history_subscription: Subscription,
142    focus_handle: FocusHandle,
143    list_state: ListState,
144    items: Vec<ArchiveListItem>,
145    selection: Option<usize>,
146    hovered_index: Option<usize>,
147    preserve_selection_on_next_update: bool,
148    filter_editor: Entity<Editor>,
149    _subscriptions: Vec<gpui::Subscription>,
150    _refresh_history_task: Task<()>,
151    workspace: WeakEntity<Workspace>,
152    agent_connection_store: WeakEntity<AgentConnectionStore>,
153    agent_server_store: WeakEntity<AgentServerStore>,
154    restoring: HashSet<ThreadId>,
155    archived_thread_ids: HashSet<ThreadId>,
156    archived_branch_names: HashMap<ThreadId, HashMap<PathBuf, String>>,
157    _load_branch_names_task: Task<()>,
158    thread_filter: ThreadFilter,
159}
160
161impl ThreadsArchiveView {
162    pub fn new(
163        workspace: WeakEntity<Workspace>,
164        agent_connection_store: WeakEntity<AgentConnectionStore>,
165        agent_server_store: WeakEntity<AgentServerStore>,
166        window: &mut Window,
167        cx: &mut Context<Self>,
168    ) -> Self {
169        let focus_handle = cx.focus_handle();
170
171        let filter_editor = cx.new(|cx| {
172            let mut editor = Editor::single_line(window, cx);
173            editor.set_placeholder_text("Search all threads…", window, cx);
174            editor
175        });
176
177        let filter_editor_subscription =
178            cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
179                if let editor::EditorEvent::BufferEdited = event {
180                    this.update_items(cx);
181                }
182            });
183
184        let filter_focus_handle = filter_editor.read(cx).focus_handle(cx);
185        cx.on_focus_in(
186            &filter_focus_handle,
187            window,
188            |this: &mut Self, _window, cx| {
189                if this.selection.is_some() {
190                    this.selection = None;
191                    cx.notify();
192                }
193            },
194        )
195        .detach();
196
197        let thread_metadata_store_subscription = cx.observe(
198            &ThreadMetadataStore::global(cx),
199            |this: &mut Self, _, cx| {
200                this.update_items(cx);
201                this.reload_branch_names_if_threads_changed(cx);
202            },
203        );
204
205        cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| {
206            this.selection = None;
207            cx.notify();
208        })
209        .detach();
210
211        let mut this = Self {
212            _history_subscription: Subscription::new(|| {}),
213            focus_handle,
214            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
215            items: Vec::new(),
216            selection: None,
217            hovered_index: None,
218            preserve_selection_on_next_update: false,
219            filter_editor,
220            _subscriptions: vec![
221                filter_editor_subscription,
222                thread_metadata_store_subscription,
223            ],
224            _refresh_history_task: Task::ready(()),
225            workspace,
226            agent_connection_store,
227            agent_server_store,
228            restoring: HashSet::default(),
229            archived_thread_ids: HashSet::default(),
230            archived_branch_names: HashMap::default(),
231            _load_branch_names_task: Task::ready(()),
232            thread_filter: ThreadFilter::All,
233        };
234
235        this.update_items(cx);
236        this.reload_branch_names_if_threads_changed(cx);
237        this
238    }
239
240    pub fn has_selection(&self) -> bool {
241        self.selection.is_some()
242    }
243
244    pub fn clear_selection(&mut self) {
245        self.selection = None;
246    }
247
248    pub fn mark_restoring(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
249        self.restoring.insert(*thread_id);
250        cx.notify();
251    }
252
253    pub fn clear_restoring(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
254        self.restoring.remove(thread_id);
255        cx.notify();
256    }
257
258    pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) {
259        let handle = self.filter_editor.read(cx).focus_handle(cx);
260        handle.focus(window, cx);
261    }
262
263    pub fn is_filter_editor_focused(&self, window: &Window, cx: &App) -> bool {
264        self.filter_editor
265            .read(cx)
266            .focus_handle(cx)
267            .is_focused(window)
268    }
269
270    fn update_items(&mut self, cx: &mut Context<Self>) {
271        let store = ThreadMetadataStore::global(cx).read(cx);
272
273        // If we're filtering to archived threads but none remain (e.g. the
274        // user just deleted the last one), fall back to showing all threads
275        // so they aren't stranded with an empty list and a disabled toggle.
276        if self.thread_filter == ThreadFilter::ArchivedOnly
277            && store.archived_entries().next().is_none()
278        {
279            self.thread_filter = ThreadFilter::All;
280        }
281
282        let thread_filter = self.thread_filter;
283        let sessions = store
284            .entries()
285            .filter(|t| match thread_filter {
286                ThreadFilter::All => true,
287                ThreadFilter::ArchivedOnly => t.archived,
288            })
289            .sorted_by_cached_key(|t| t.created_at.unwrap_or(t.updated_at))
290            .rev()
291            .cloned()
292            .collect::<Vec<_>>();
293
294        let query = self.filter_editor.read(cx).text(cx).to_lowercase();
295        let today = Local::now().naive_local().date();
296
297        let mut items = Vec::with_capacity(sessions.len() + 5);
298        let mut current_bucket: Option<TimeBucket> = None;
299
300        for session in sessions {
301            let highlight_positions = if !query.is_empty() {
302                let title = session
303                    .title
304                    .as_ref()
305                    .map(|t| t.as_ref())
306                    .unwrap_or(DEFAULT_THREAD_TITLE);
307                if let Some(positions) = fuzzy_match_positions(&query, title) {
308                    positions
309                } else {
310                    // If title didn't match, also try matching the project name
311                    // (the basename of any of the thread's worktree paths), so
312                    // typing a project name surfaces its threads here too.
313                    let worktree_matched = session.folder_paths().paths().iter().any(|p| {
314                        p.as_path()
315                            .file_name()
316                            .and_then(|name| name.to_str())
317                            .is_some_and(|name| fuzzy_match_positions(&query, name).is_some())
318                    });
319                    if !worktree_matched {
320                        continue;
321                    }
322                    Vec::new()
323                }
324            } else {
325                Vec::new()
326            };
327
328            let entry_bucket = {
329                let entry_date = session
330                    .created_at
331                    .unwrap_or(session.updated_at)
332                    .with_timezone(&Local)
333                    .naive_local()
334                    .date();
335                TimeBucket::from_dates(today, entry_date)
336            };
337
338            if Some(entry_bucket) != current_bucket {
339                current_bucket = Some(entry_bucket);
340                items.push(ArchiveListItem::BucketSeparator(entry_bucket));
341            }
342
343            items.push(ArchiveListItem::Entry {
344                thread: session,
345                highlight_positions,
346            });
347        }
348
349        let preserve = self.preserve_selection_on_next_update;
350        self.preserve_selection_on_next_update = false;
351
352        let saved_scroll = self.list_state.logical_scroll_top();
353
354        self.list_state.reset(items.len());
355        self.items = items;
356
357        if let Some(ix) = self.hovered_index {
358            if ix >= self.items.len() || !self.is_selectable_item(ix) {
359                self.hovered_index = None;
360            }
361        }
362
363        self.list_state.scroll_to(saved_scroll);
364
365        if preserve {
366            if let Some(ix) = self.selection {
367                let next = self.find_next_selectable(ix).or_else(|| {
368                    ix.checked_sub(1)
369                        .and_then(|i| self.find_previous_selectable(i))
370                });
371                self.selection = next;
372                if let Some(next) = next {
373                    self.list_state.scroll_to_reveal_item(next);
374                }
375            }
376        } else {
377            self.selection = None;
378        }
379
380        cx.notify();
381    }
382
383    fn reload_branch_names_if_threads_changed(&mut self, cx: &mut Context<Self>) {
384        let current_ids: HashSet<ThreadId> = self
385            .items
386            .iter()
387            .filter_map(|item| match item {
388                ArchiveListItem::Entry { thread, .. } => Some(thread.thread_id),
389                _ => None,
390            })
391            .collect();
392
393        if current_ids != self.archived_thread_ids {
394            self.archived_thread_ids = current_ids;
395            self.load_archived_branch_names(cx);
396        }
397    }
398
399    fn load_archived_branch_names(&mut self, cx: &mut Context<Self>) {
400        let task = ThreadMetadataStore::global(cx)
401            .read(cx)
402            .get_all_archived_branch_names(cx);
403        self._load_branch_names_task = cx.spawn(async move |this, cx| {
404            if let Some(branch_names) = task.await.log_err() {
405                this.update(cx, |this, cx| {
406                    this.archived_branch_names = branch_names;
407                    cx.notify();
408                })
409                .log_err();
410            }
411        });
412    }
413
414    fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context<Self>) {
415        self.filter_editor.update(cx, |editor, cx| {
416            editor.set_text("", window, cx);
417        });
418    }
419
420    fn archive_thread(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
421        self.preserve_selection_on_next_update = true;
422        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(thread_id, None, cx));
423    }
424
425    fn archive_selected_thread(
426        &mut self,
427        _: &ArchiveSelectedThread,
428        _window: &mut Window,
429        cx: &mut Context<Self>,
430    ) {
431        let Some(ix) = self.selection else { return };
432        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
433            return;
434        };
435
436        if thread.archived {
437            return;
438        }
439
440        self.archive_thread(thread.thread_id, cx);
441    }
442
443    fn unarchive_thread(
444        &mut self,
445        thread: ThreadMetadata,
446        window: &mut Window,
447        cx: &mut Context<Self>,
448    ) {
449        if self.restoring.contains(&thread.thread_id) {
450            return;
451        }
452
453        if thread.folder_paths().is_empty() {
454            self.show_project_picker_for_thread(thread, window, cx);
455            return;
456        }
457
458        self.mark_restoring(&thread.thread_id, cx);
459        self.selection = None;
460        self.reset_filter_editor_text(window, cx);
461        cx.emit(ThreadsArchiveViewEvent::Activate { thread });
462    }
463
464    fn show_project_picker_for_thread(
465        &mut self,
466        thread: ThreadMetadata,
467        window: &mut Window,
468        cx: &mut Context<Self>,
469    ) {
470        let Some(workspace) = self.workspace.upgrade() else {
471            return;
472        };
473
474        let archive_view = cx.weak_entity();
475        let fs = workspace.read(cx).app_state().fs.clone();
476        let current_workspace_id = workspace.read(cx).database_id();
477        let sibling_workspace_ids: HashSet<WorkspaceId> = workspace
478            .read(cx)
479            .multi_workspace()
480            .and_then(|mw| mw.upgrade())
481            .map(|mw| {
482                mw.read(cx)
483                    .workspaces()
484                    .filter_map(|ws| ws.read(cx).database_id())
485                    .collect()
486            })
487            .unwrap_or_default();
488
489        workspace.update(cx, |workspace, cx| {
490            workspace.toggle_modal(window, cx, |window, cx| {
491                ProjectPickerModal::new(
492                    thread,
493                    fs,
494                    archive_view,
495                    current_workspace_id,
496                    sibling_workspace_ids,
497                    window,
498                    cx,
499                )
500            });
501        });
502    }
503
504    fn is_selectable_item(&self, ix: usize) -> bool {
505        matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
506    }
507
508    fn find_next_selectable(&self, start: usize) -> Option<usize> {
509        (start..self.items.len()).find(|&i| self.is_selectable_item(i))
510    }
511
512    fn find_previous_selectable(&self, start: usize) -> Option<usize> {
513        (0..=start).rev().find(|&i| self.is_selectable_item(i))
514    }
515
516    fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context<Self>) {
517        self.select_next(&SelectNext, window, cx);
518        if self.selection.is_some() {
519            self.focus_handle.focus(window, cx);
520        }
521    }
522
523    fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context<Self>) {
524        self.select_previous(&SelectPrevious, window, cx);
525        if self.selection.is_some() {
526            self.focus_handle.focus(window, cx);
527        }
528    }
529
530    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
531        let next = match self.selection {
532            Some(ix) => self.find_next_selectable(ix + 1),
533            None => self.find_next_selectable(0),
534        };
535        if let Some(next) = next {
536            self.selection = Some(next);
537            self.list_state.scroll_to_reveal_item(next);
538            cx.notify();
539        }
540    }
541
542    fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
543        match self.selection {
544            Some(ix) => {
545                if let Some(prev) = (ix > 0)
546                    .then(|| self.find_previous_selectable(ix - 1))
547                    .flatten()
548                {
549                    self.selection = Some(prev);
550                    self.list_state.scroll_to_reveal_item(prev);
551                } else {
552                    self.selection = None;
553                    self.focus_filter_editor(window, cx);
554                }
555                cx.notify();
556            }
557            None => {
558                let last = self.items.len().saturating_sub(1);
559                if let Some(prev) = self.find_previous_selectable(last) {
560                    self.selection = Some(prev);
561                    self.list_state.scroll_to_reveal_item(prev);
562                    cx.notify();
563                }
564            }
565        }
566    }
567
568    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
569        if let Some(first) = self.find_next_selectable(0) {
570            self.selection = Some(first);
571            self.list_state.scroll_to_reveal_item(first);
572            cx.notify();
573        }
574    }
575
576    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
577        let last = self.items.len().saturating_sub(1);
578        if let Some(last) = self.find_previous_selectable(last) {
579            self.selection = Some(last);
580            self.list_state.scroll_to_reveal_item(last);
581            cx.notify();
582        }
583    }
584
585    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
586        let Some(ix) = self.selection else { return };
587        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
588            return;
589        };
590
591        self.unarchive_thread(thread.clone(), window, cx);
592    }
593
594    fn render_list_entry(
595        &mut self,
596        ix: usize,
597        _window: &mut Window,
598        cx: &mut Context<Self>,
599    ) -> AnyElement {
600        let Some(item) = self.items.get(ix) else {
601            return div().into_any_element();
602        };
603
604        match item {
605            ArchiveListItem::BucketSeparator(bucket) => div()
606                .w_full()
607                .px_2p5()
608                .pt_3()
609                .pb_1()
610                .child(
611                    Label::new(bucket.label())
612                        .size(LabelSize::Small)
613                        .color(Color::Muted),
614                )
615                .into_any_element(),
616            ArchiveListItem::Entry {
617                thread,
618                highlight_positions,
619            } => {
620                let id = SharedString::from(format!("archive-entry-{}", ix));
621
622                let is_focused = self.selection == Some(ix);
623                let is_hovered = self.hovered_index == Some(ix);
624
625                let focus_handle = self.focus_handle.clone();
626
627                let timestamp =
628                    format_history_entry_timestamp(thread.created_at.unwrap_or(thread.updated_at));
629
630                let icon_from_external_svg = self
631                    .agent_server_store
632                    .upgrade()
633                    .and_then(|store| store.read(cx).agent_icon(&thread.agent_id));
634
635                let icon = if thread.agent_id.as_ref() == agent::OMEGA_AGENT_ID.as_ref() {
636                    IconName::OmegaAgent
637                } else {
638                    IconName::Sparkle
639                };
640
641                let is_restoring = self.restoring.contains(&thread.thread_id);
642
643                let is_archived = thread.archived;
644
645                let branch_names_for_thread: HashMap<PathBuf, SharedString> = self
646                    .archived_branch_names
647                    .get(&thread.thread_id)
648                    .map(|map| {
649                        map.iter()
650                            .map(|(k, v)| (k.clone(), SharedString::from(v.clone())))
651                            .collect()
652                    })
653                    .unwrap_or_default();
654
655                let worktrees = worktree_info_from_thread_paths(
656                    &thread.worktree_paths,
657                    &branch_names_for_thread,
658                );
659
660                let archived_color = Color::Custom(cx.theme().colors().icon_muted.opacity(0.6));
661
662                let base = ThreadItem::new(id, thread.display_title())
663                    .icon(icon)
664                    .when(is_archived, |this| {
665                        this.archived(true)
666                            .icon_color(archived_color)
667                            .title_label_color(Color::Muted)
668                    })
669                    .when_some(icon_from_external_svg, |this, svg| {
670                        this.custom_icon_from_external_svg(svg)
671                    })
672                    .timestamp(timestamp)
673                    .highlight_positions(highlight_positions.clone())
674                    .project_paths(thread.folder_paths().paths_owned())
675                    .worktrees(worktrees)
676                    .focused(is_focused)
677                    .hovered(is_hovered)
678                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
679                        let previously_hovered = this.hovered_index;
680                        this.hovered_index = if *is_hovered {
681                            Some(ix)
682                        } else {
683                            previously_hovered.filter(|&i| i != ix)
684                        };
685                        if this.hovered_index != previously_hovered {
686                            cx.notify();
687                        }
688                    }));
689
690                if is_restoring {
691                    base.status(AgentThreadStatus::Running)
692                        .action_slot(
693                            IconButton::new("cancel-restore", IconName::Close)
694                                .icon_size(IconSize::Small)
695                                .icon_color(Color::Muted)
696                                .tooltip(Tooltip::text("Cancel Restore"))
697                                .on_click({
698                                    let thread_id = thread.thread_id;
699                                    cx.listener(move |this, _, _, cx| {
700                                        this.clear_restoring(&thread_id, cx);
701                                        cx.emit(ThreadsArchiveViewEvent::CancelRestore {
702                                            thread_id,
703                                        });
704                                        cx.stop_propagation();
705                                    })
706                                }),
707                        )
708                        .into_any_element()
709                } else if is_archived {
710                    base.action_slot(
711                        IconButton::new("delete-thread", IconName::Trash)
712                            .icon_size(IconSize::Small)
713                            .icon_color(Color::Muted)
714                            .tooltip({
715                                move |_window, cx| {
716                                    Tooltip::for_action_in(
717                                        "Delete Thread",
718                                        &RemoveSelectedThread,
719                                        &focus_handle,
720                                        cx,
721                                    )
722                                }
723                            })
724                            .on_click({
725                                let agent = thread.agent_id.clone();
726                                let thread_id = thread.thread_id;
727                                let session_id = thread.session_id.clone();
728                                cx.listener(move |this, _, _, cx| {
729                                    this.preserve_selection_on_next_update = true;
730                                    this.delete_thread(
731                                        thread_id,
732                                        session_id.clone(),
733                                        agent.clone(),
734                                        cx,
735                                    );
736                                    cx.stop_propagation();
737                                })
738                            }),
739                    )
740                    .on_click({
741                        let thread = thread.clone();
742                        cx.listener(move |this, _, window, cx| {
743                            this.unarchive_thread(thread.clone(), window, cx);
744                        })
745                    })
746                    .into_any_element()
747                } else {
748                    base.action_slot(
749                        IconButton::new("archive-thread", IconName::Archive)
750                            .icon_size(IconSize::Small)
751                            .icon_color(Color::Muted)
752                            .tooltip({
753                                move |_window, cx| {
754                                    Tooltip::for_action_in(
755                                        "Archive Thread",
756                                        &ArchiveSelectedThread,
757                                        &focus_handle,
758                                        cx,
759                                    )
760                                }
761                            })
762                            .on_click({
763                                let thread_id = thread.thread_id;
764                                cx.listener(move |this, _, _, cx| {
765                                    this.archive_thread(thread_id, cx);
766                                    cx.stop_propagation();
767                                })
768                            }),
769                    )
770                    .on_click({
771                        let thread = thread.clone();
772                        cx.listener(move |this, _, window, cx| {
773                            telemetry::event!(
774                                "Archived Thread Opened",
775                                agent = thread.agent_id.as_ref(),
776                                side = crate::agent_sidebar_side(cx)
777                            );
778                            this.unarchive_thread(thread.clone(), window, cx);
779                        })
780                    })
781                    .into_any_element()
782                }
783            }
784        }
785    }
786
787    fn remove_selected_thread(
788        &mut self,
789        _: &RemoveSelectedThread,
790        _window: &mut Window,
791        cx: &mut Context<Self>,
792    ) {
793        let Some(ix) = self.selection else { return };
794        let Some(ArchiveListItem::Entry { thread, .. }) = self.items.get(ix) else {
795            return;
796        };
797
798        self.preserve_selection_on_next_update = true;
799        self.delete_thread(
800            thread.thread_id,
801            thread.session_id.clone(),
802            thread.agent_id.clone(),
803            cx,
804        );
805    }
806
807    fn delete_thread(
808        &mut self,
809        thread_id: ThreadId,
810        session_id: Option<acp::SessionId>,
811        agent: AgentId,
812        cx: &mut Context<Self>,
813    ) {
814        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.delete(thread_id, cx));
815
816        let agent = Agent::from(agent);
817
818        let Some(agent_connection_store) = self.agent_connection_store.upgrade() else {
819            return;
820        };
821        let fs = <dyn Fs>::global(cx);
822
823        let task = agent_connection_store.update(cx, |store, cx| {
824            store
825                .request_connection(agent.clone(), agent.server(fs, ThreadStore::global(cx)), cx)
826                .read(cx)
827                .wait_for_connection()
828        });
829        cx.spawn(async move |_this, cx| {
830            crate::thread_worktree_archive::cleanup_thread_archived_worktrees(thread_id, cx).await;
831
832            let state = task.await?;
833            let task = cx.update(|cx| {
834                if let Some(session_id) = &session_id {
835                    if let Some(list) = state
836                        .connection
837                        .session_list(cx)
838                        .filter(|list| list.supports_delete())
839                    {
840                        list.delete_session(session_id, cx)
841                    } else {
842                        Task::ready(Ok(()))
843                    }
844                } else {
845                    Task::ready(Ok(()))
846                }
847            });
848            task.await
849        })
850        .detach_and_log_err(cx);
851    }
852
853    fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
854        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
855        let sidebar_on_left = matches!(
856            AgentSettings::get_global(cx).sidebar_side(),
857            settings::SidebarSide::Left
858        );
859        let sidebar_on_right = !sidebar_on_left;
860        let not_fullscreen = !window.is_fullscreen();
861        let traffic_lights = cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
862        let left_window_controls = !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_left;
863        let right_window_controls =
864            !cfg!(target_os = "macos") && not_fullscreen && sidebar_on_right;
865        let header_height = platform_title_bar_height(window);
866        let show_focus_keybinding =
867            self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window);
868
869        h_flex()
870            .h(header_height)
871            .mt_px()
872            .pb_px()
873            .when(left_window_controls, |this| {
874                this.children(Self::render_left_window_controls(window, cx))
875            })
876            .map(|this| {
877                if traffic_lights {
878                    this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
879                } else if !left_window_controls {
880                    this.pl_1p5()
881                } else {
882                    this
883                }
884            })
885            .when(!right_window_controls, |this| this.pr_1p5())
886            .gap_1()
887            .justify_between()
888            .border_b_1()
889            .border_color(cx.theme().colors().border)
890            .when(traffic_lights, |this| {
891                this.child(Divider::vertical().color(ui::DividerColor::Border))
892            })
893            .child(
894                h_flex()
895                    .ml_1()
896                    .min_w_0()
897                    .w_full()
898                    .gap_1()
899                    .child(
900                        Icon::new(IconName::MagnifyingGlass)
901                            .size(IconSize::Small)
902                            .color(Color::Muted),
903                    )
904                    .child(self.filter_editor.clone()),
905            )
906            .when(show_focus_keybinding, |this| {
907                this.child(KeyBinding::for_action(&FocusSidebarFilter, cx))
908            })
909            .when(has_query, |this| {
910                this.child(
911                    IconButton::new("clear-filter", IconName::Close)
912                        .icon_size(IconSize::Small)
913                        .tooltip(Tooltip::text("Clear Search"))
914                        .on_click(cx.listener(|this, _, window, cx| {
915                            this.reset_filter_editor_text(window, cx);
916                            this.update_items(cx);
917                        })),
918                )
919            })
920            .when(right_window_controls, |this| {
921                this.children(Self::render_right_window_controls(window, cx))
922            })
923    }
924
925    fn render_left_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
926        platform_title_bar::render_left_window_controls(
927            cx.button_layout(),
928            Box::new(CloseWindow),
929            window,
930        )
931    }
932
933    fn render_right_window_controls(window: &Window, cx: &mut App) -> Option<AnyElement> {
934        platform_title_bar::render_right_window_controls(
935            cx.button_layout(),
936            Box::new(CloseWindow),
937            window,
938        )
939    }
940
941    fn render_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
942        let entry_count = self
943            .items
944            .iter()
945            .filter(|item| matches!(item, ArchiveListItem::Entry { .. }))
946            .count();
947
948        let has_archived_threads = {
949            let store = ThreadMetadataStore::global(cx).read(cx);
950            store.archived_entries().next().is_some()
951        };
952
953        let count_label = if entry_count == 1 {
954            "1 thread".to_string()
955        } else {
956            format!("{} threads", entry_count)
957        };
958
959        h_flex()
960            .mt_px()
961            .pl_2p5()
962            .pr_1p5()
963            .h(Tab::content_height(cx))
964            .justify_between()
965            .border_b_1()
966            .border_color(cx.theme().colors().border)
967            .child(
968                Label::new(count_label)
969                    .size(LabelSize::Small)
970                    .color(Color::Muted),
971            )
972            .child(
973                h_flex()
974                    .gap_1()
975                    .child(
976                        IconButton::new("new-thread", IconName::Plus)
977                            .icon_size(IconSize::Small)
978                            .tooltip(Tooltip::text("Start New Agent Thread"))
979                            .on_click(cx.listener(|_this, _, _, cx| {
980                                cx.emit(ThreadsArchiveViewEvent::NewThread);
981                            })),
982                    )
983                    .child(
984                        IconButton::new("thread-import", IconName::Download)
985                            .icon_size(IconSize::Small)
986                            .tooltip(Tooltip::text("Import Threads"))
987                            .on_click(cx.listener(|_this, _, _, cx| {
988                                cx.emit(ThreadsArchiveViewEvent::Import);
989                            })),
990                    )
991                    .child(
992                        IconButton::new("filter-archived-only", IconName::Archive)
993                            .icon_size(IconSize::Small)
994                            .disabled(!has_archived_threads)
995                            .toggle_state(self.thread_filter == ThreadFilter::ArchivedOnly)
996                            .tooltip(Tooltip::text(
997                                if self.thread_filter == ThreadFilter::ArchivedOnly {
998                                    "Show All Threads"
999                                } else {
1000                                    "Show Only Archived Threads"
1001                                },
1002                            ))
1003                            .on_click(cx.listener(|this, _, _, cx| {
1004                                this.thread_filter =
1005                                    if this.thread_filter == ThreadFilter::ArchivedOnly {
1006                                        ThreadFilter::All
1007                                    } else {
1008                                        ThreadFilter::ArchivedOnly
1009                                    };
1010                                this.update_items(cx);
1011                            })),
1012                    ),
1013            )
1014    }
1015}
1016
1017pub fn format_history_entry_timestamp(entry_time: DateTime<Utc>) -> String {
1018    let now = Utc::now();
1019    let duration = now.signed_duration_since(entry_time);
1020
1021    let minutes = duration.num_minutes();
1022    let hours = duration.num_hours();
1023    let days = duration.num_days();
1024    let weeks = days / 7;
1025    let months = days / 30;
1026
1027    if minutes < 60 {
1028        format!("{}m", minutes.max(1))
1029    } else if hours < 24 {
1030        format!("{}h", hours.max(1))
1031    } else if days < 7 {
1032        format!("{}d", days.max(1))
1033    } else if weeks < 4 {
1034        format!("{}w", weeks.max(1))
1035    } else {
1036        format!("{}mo", months.max(1))
1037    }
1038}
1039
1040impl Focusable for ThreadsArchiveView {
1041    fn focus_handle(&self, _cx: &App) -> FocusHandle {
1042        self.focus_handle.clone()
1043    }
1044}
1045
1046impl Render for ThreadsArchiveView {
1047    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1048        let is_empty = self.items.is_empty();
1049        let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
1050
1051        let content = if is_empty {
1052            let message = if has_query {
1053                "No threads match your search."
1054            } else {
1055                "No threads yet."
1056            };
1057
1058            v_flex()
1059                .flex_1()
1060                .justify_center()
1061                .items_center()
1062                .child(
1063                    Label::new(message)
1064                        .size(LabelSize::Small)
1065                        .color(Color::Muted),
1066                )
1067                .into_any_element()
1068        } else {
1069            v_flex()
1070                .flex_1()
1071                .overflow_hidden()
1072                .child(
1073                    list(
1074                        self.list_state.clone(),
1075                        cx.processor(Self::render_list_entry),
1076                    )
1077                    .flex_1()
1078                    .size_full(),
1079                )
1080                .custom_scrollbars(
1081                    Scrollbars::new(ScrollAxes::Vertical).tracked_scroll_handle(&self.list_state),
1082                    window,
1083                    cx,
1084                )
1085                .into_any_element()
1086        };
1087
1088        v_flex()
1089            .key_context("ThreadsArchiveView")
1090            .track_focus(&self.focus_handle)
1091            .on_action(cx.listener(Self::select_next))
1092            .on_action(cx.listener(Self::select_previous))
1093            .on_action(cx.listener(Self::editor_move_down))
1094            .on_action(cx.listener(Self::editor_move_up))
1095            .on_action(cx.listener(Self::select_first))
1096            .on_action(cx.listener(Self::select_last))
1097            .on_action(cx.listener(Self::confirm))
1098            .on_action(cx.listener(Self::remove_selected_thread))
1099            .on_action(cx.listener(Self::archive_selected_thread))
1100            .size_full()
1101            .child(self.render_header(window, cx))
1102            .when(!has_query, |this| this.child(self.render_toolbar(cx)))
1103            .child(content)
1104    }
1105}
1106
1107struct ProjectPickerModal {
1108    picker: Entity<Picker<ProjectPickerDelegate>>,
1109    _subscription: Subscription,
1110}
1111
1112impl ProjectPickerModal {
1113    fn new(
1114        thread: ThreadMetadata,
1115        fs: Arc<dyn Fs>,
1116        archive_view: WeakEntity<ThreadsArchiveView>,
1117        current_workspace_id: Option<WorkspaceId>,
1118        sibling_workspace_ids: HashSet<WorkspaceId>,
1119        window: &mut Window,
1120        cx: &mut Context<Self>,
1121    ) -> Self {
1122        let delegate = ProjectPickerDelegate {
1123            thread,
1124            archive_view,
1125            workspaces: Vec::new(),
1126            filtered_entries: Vec::new(),
1127            selected_index: 0,
1128            current_workspace_id,
1129            sibling_workspace_ids,
1130            focus_handle: cx.focus_handle(),
1131        };
1132
1133        let picker = cx.new(|cx| {
1134            Picker::list(delegate, window, cx)
1135                .list_measure_all()
1136                .embedded()
1137        });
1138
1139        let picker_focus_handle = picker.focus_handle(cx);
1140        picker.update(cx, |picker, _| {
1141            picker.delegate.focus_handle = picker_focus_handle;
1142        });
1143
1144        let _subscription =
1145            cx.subscribe(&picker, |_this: &mut Self, _, _event: &DismissEvent, cx| {
1146                cx.emit(DismissEvent);
1147            });
1148
1149        let db = WorkspaceDb::global(cx);
1150        cx.spawn_in(window, async move |this, cx| {
1151            let workspaces = db
1152                .recent_project_workspaces(fs.as_ref())
1153                .await
1154                .log_err()
1155                .unwrap_or_default();
1156            this.update_in(cx, move |this, window, cx| {
1157                this.picker.update(cx, move |picker, cx| {
1158                    picker.delegate.workspaces = workspaces;
1159                    picker.update_matches(picker.query(cx), window, cx)
1160                })
1161            })
1162            .ok();
1163        })
1164        .detach();
1165
1166        picker.focus_handle(cx).focus(window, cx);
1167
1168        Self {
1169            picker,
1170            _subscription,
1171        }
1172    }
1173}
1174
1175impl EventEmitter<DismissEvent> for ProjectPickerModal {}
1176
1177impl Focusable for ProjectPickerModal {
1178    fn focus_handle(&self, cx: &App) -> FocusHandle {
1179        self.picker.focus_handle(cx)
1180    }
1181}
1182
1183impl ModalView for ProjectPickerModal {}
1184
1185impl Render for ProjectPickerModal {
1186    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1187        v_flex()
1188            .key_context("ProjectPickerModal")
1189            .elevation_3(cx)
1190            .on_action(cx.listener(|this, _: &workspace::Open, window, cx| {
1191                this.picker.update(cx, |picker, cx| {
1192                    picker.delegate.open_local_folder(window, cx)
1193                })
1194            }))
1195            .child(self.picker.clone())
1196    }
1197}
1198
1199enum ProjectPickerEntry {
1200    Header(SharedString),
1201    Workspace(StringMatch),
1202}
1203
1204struct ProjectPickerDelegate {
1205    thread: ThreadMetadata,
1206    archive_view: WeakEntity<ThreadsArchiveView>,
1207    current_workspace_id: Option<WorkspaceId>,
1208    sibling_workspace_ids: HashSet<WorkspaceId>,
1209    workspaces: Vec<RecentWorkspace>,
1210    filtered_entries: Vec<ProjectPickerEntry>,
1211    selected_index: usize,
1212    focus_handle: FocusHandle,
1213}
1214
1215impl ProjectPickerDelegate {
1216    fn update_working_directories_and_unarchive(
1217        &mut self,
1218        paths: PathList,
1219        window: &mut Window,
1220        cx: &mut Context<Picker<Self>>,
1221    ) {
1222        self.thread.worktree_paths =
1223            super::thread_metadata_store::WorktreePaths::from_folder_paths(&paths);
1224        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
1225            store.update_working_directories(self.thread.thread_id, paths, cx);
1226        });
1227
1228        self.archive_view
1229            .update(cx, |view, cx| {
1230                view.selection = None;
1231                view.reset_filter_editor_text(window, cx);
1232                cx.emit(ThreadsArchiveViewEvent::Activate {
1233                    thread: self.thread.clone(),
1234                });
1235            })
1236            .log_err();
1237    }
1238
1239    fn is_current_workspace(&self, workspace_id: WorkspaceId) -> bool {
1240        self.current_workspace_id == Some(workspace_id)
1241    }
1242
1243    fn is_sibling_workspace(&self, workspace_id: WorkspaceId) -> bool {
1244        self.sibling_workspace_ids.contains(&workspace_id)
1245            && !self.is_current_workspace(workspace_id)
1246    }
1247
1248    fn selected_match(&self) -> Option<&StringMatch> {
1249        match self.filtered_entries.get(self.selected_index)? {
1250            ProjectPickerEntry::Workspace(hit) => Some(hit),
1251            ProjectPickerEntry::Header(_) => None,
1252        }
1253    }
1254
1255    fn open_local_folder(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1256        let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1257            files: false,
1258            directories: true,
1259            multiple: false,
1260            prompt: None,
1261        });
1262        cx.spawn_in(window, async move |this, cx| {
1263            let Ok(Ok(Some(paths))) = paths_receiver.await else {
1264                return;
1265            };
1266            if paths.is_empty() {
1267                return;
1268            }
1269
1270            let work_dirs = PathList::new(&paths);
1271
1272            this.update_in(cx, |this, window, cx| {
1273                this.delegate
1274                    .update_working_directories_and_unarchive(work_dirs, window, cx);
1275                cx.emit(DismissEvent);
1276            })
1277            .log_err();
1278        })
1279        .detach();
1280    }
1281}
1282
1283impl EventEmitter<DismissEvent> for ProjectPickerDelegate {}
1284
1285impl PickerDelegate for ProjectPickerDelegate {
1286    type ListItem = AnyElement;
1287
1288    fn name() -> &'static str {
1289        "thread archive project picker"
1290    }
1291
1292    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
1293        format!(
1294            "Associate the \"{}\" thread with...",
1295            self.thread
1296                .title
1297                .as_ref()
1298                .map(|t| t.as_ref())
1299                .unwrap_or(DEFAULT_THREAD_TITLE)
1300        )
1301        .into()
1302    }
1303
1304    fn match_count(&self) -> usize {
1305        self.filtered_entries.len()
1306    }
1307
1308    fn selected_index(&self) -> usize {
1309        self.selected_index
1310    }
1311
1312    fn set_selected_index(
1313        &mut self,
1314        ix: usize,
1315        _window: &mut Window,
1316        _cx: &mut Context<Picker<Self>>,
1317    ) {
1318        self.selected_index = ix;
1319    }
1320
1321    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
1322        matches!(
1323            self.filtered_entries.get(ix),
1324            Some(ProjectPickerEntry::Workspace(_))
1325        )
1326    }
1327
1328    fn update_matches(
1329        &mut self,
1330        query: String,
1331        _window: &mut Window,
1332        cx: &mut Context<Picker<Self>>,
1333    ) -> Task<()> {
1334        let query = query.trim_start();
1335        let smart_case = query.chars().any(|c| c.is_uppercase());
1336        let is_empty_query = query.is_empty();
1337
1338        let sibling_candidates: Vec<_> = self
1339            .workspaces
1340            .iter()
1341            .enumerate()
1342            .filter(|(_, workspace)| self.is_sibling_workspace(workspace.workspace_id))
1343            .map(|(id, workspace)| {
1344                let combined_string = workspace
1345                    .identity_paths
1346                    .ordered_paths()
1347                    .map(|path| path.compact().to_string_lossy().into_owned())
1348                    .collect::<Vec<_>>()
1349                    .concat();
1350                StringMatchCandidate::new(id, &combined_string)
1351            })
1352            .collect();
1353
1354        let mut sibling_matches = gpui::block_on(fuzzy::match_strings(
1355            &sibling_candidates,
1356            query,
1357            smart_case,
1358            true,
1359            100,
1360            &Default::default(),
1361            cx.background_executor().clone(),
1362        ));
1363
1364        sibling_matches.sort_unstable_by(|a, b| {
1365            b.score
1366                .partial_cmp(&a.score)
1367                .unwrap_or(std::cmp::Ordering::Equal)
1368                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1369        });
1370
1371        let recent_candidates: Vec<_> = self
1372            .workspaces
1373            .iter()
1374            .enumerate()
1375            .filter(|(_, workspace)| {
1376                !self.is_current_workspace(workspace.workspace_id)
1377                    && !self.is_sibling_workspace(workspace.workspace_id)
1378            })
1379            .map(|(id, workspace)| {
1380                let combined_string = workspace
1381                    .identity_paths
1382                    .ordered_paths()
1383                    .map(|path| path.compact().to_string_lossy().into_owned())
1384                    .collect::<Vec<_>>()
1385                    .concat();
1386                StringMatchCandidate::new(id, &combined_string)
1387            })
1388            .collect();
1389
1390        let mut recent_matches = gpui::block_on(fuzzy::match_strings(
1391            &recent_candidates,
1392            query,
1393            smart_case,
1394            true,
1395            100,
1396            &Default::default(),
1397            cx.background_executor().clone(),
1398        ));
1399
1400        recent_matches.sort_unstable_by(|a, b| {
1401            b.score
1402                .partial_cmp(&a.score)
1403                .unwrap_or(std::cmp::Ordering::Equal)
1404                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1405        });
1406
1407        let mut entries = Vec::new();
1408
1409        let has_siblings_to_show = if is_empty_query {
1410            !sibling_candidates.is_empty()
1411        } else {
1412            !sibling_matches.is_empty()
1413        };
1414
1415        if has_siblings_to_show {
1416            entries.push(ProjectPickerEntry::Header("This Window".into()));
1417
1418            if is_empty_query {
1419                for (id, workspace) in self.workspaces.iter().enumerate() {
1420                    if self.is_sibling_workspace(workspace.workspace_id) {
1421                        entries.push(ProjectPickerEntry::Workspace(StringMatch {
1422                            candidate_id: id,
1423                            score: 0.0,
1424                            positions: Vec::new(),
1425                            string: String::new(),
1426                        }));
1427                    }
1428                }
1429            } else {
1430                for m in sibling_matches {
1431                    entries.push(ProjectPickerEntry::Workspace(m));
1432                }
1433            }
1434        }
1435
1436        let has_recent_to_show = if is_empty_query {
1437            !recent_candidates.is_empty()
1438        } else {
1439            !recent_matches.is_empty()
1440        };
1441
1442        if has_recent_to_show {
1443            entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1444
1445            if is_empty_query {
1446                for (id, workspace) in self.workspaces.iter().enumerate() {
1447                    if !self.is_current_workspace(workspace.workspace_id)
1448                        && !self.is_sibling_workspace(workspace.workspace_id)
1449                    {
1450                        entries.push(ProjectPickerEntry::Workspace(StringMatch {
1451                            candidate_id: id,
1452                            score: 0.0,
1453                            positions: Vec::new(),
1454                            string: String::new(),
1455                        }));
1456                    }
1457                }
1458            } else {
1459                for m in recent_matches {
1460                    entries.push(ProjectPickerEntry::Workspace(m));
1461                }
1462            }
1463        }
1464
1465        self.filtered_entries = entries;
1466
1467        self.selected_index = self
1468            .filtered_entries
1469            .iter()
1470            .position(|e| matches!(e, ProjectPickerEntry::Workspace(_)))
1471            .unwrap_or(0);
1472
1473        Task::ready(())
1474    }
1475
1476    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1477        let candidate_id = match self.filtered_entries.get(self.selected_index) {
1478            Some(ProjectPickerEntry::Workspace(hit)) => hit.candidate_id,
1479            _ => return,
1480        };
1481        let Some(workspace) = self.workspaces.get(candidate_id) else {
1482            return;
1483        };
1484
1485        self.update_working_directories_and_unarchive(workspace.paths.clone(), window, cx);
1486        cx.emit(DismissEvent);
1487    }
1488
1489    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
1490
1491    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1492        let text = if self.workspaces.is_empty() {
1493            "No recent projects found"
1494        } else {
1495            "No matches"
1496        };
1497        Some(text.into())
1498    }
1499
1500    fn render_match(
1501        &self,
1502        ix: usize,
1503        selected: bool,
1504        window: &mut Window,
1505        cx: &mut Context<Picker<Self>>,
1506    ) -> Option<Self::ListItem> {
1507        match self.filtered_entries.get(ix)? {
1508            ProjectPickerEntry::Header(title) => Some(
1509                v_flex()
1510                    .w_full()
1511                    .gap_1()
1512                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1513                    .child(ListSubHeader::new(title.clone()).inset(true))
1514                    .into_any_element(),
1515            ),
1516            ProjectPickerEntry::Workspace(hit) => {
1517                let workspace = self.workspaces.get(hit.candidate_id)?;
1518                let location = &workspace.location;
1519
1520                let ordered_paths: Vec<_> = workspace
1521                    .identity_paths
1522                    .ordered_paths()
1523                    .map(|p| p.compact().to_string_lossy().to_string())
1524                    .collect();
1525
1526                let tooltip_path: SharedString = ordered_paths.join("\n").into();
1527
1528                let mut path_start_offset = 0;
1529                let match_labels: Vec<_> = workspace
1530                    .identity_paths
1531                    .ordered_paths()
1532                    .map(|p| p.compact())
1533                    .map(|path| {
1534                        let path_string = path.to_string_lossy();
1535                        let path_text = path_string.to_string();
1536                        let path_byte_len = path_text.len();
1537
1538                        let path_positions: Vec<usize> = hit
1539                            .positions
1540                            .iter()
1541                            .copied()
1542                            .skip_while(|pos| *pos < path_start_offset)
1543                            .take_while(|pos| *pos < path_start_offset + path_byte_len)
1544                            .map(|pos| pos - path_start_offset)
1545                            .collect();
1546
1547                        let file_name_match = path.file_name().map(|file_name| {
1548                            let file_name_text = file_name.to_string_lossy().into_owned();
1549                            let file_name_start = path_byte_len - file_name_text.len();
1550                            let highlight_positions: Vec<usize> = path_positions
1551                                .iter()
1552                                .copied()
1553                                .skip_while(|pos| *pos < file_name_start)
1554                                .take_while(|pos| *pos < file_name_start + file_name_text.len())
1555                                .map(|pos| pos - file_name_start)
1556                                .collect();
1557                            HighlightedMatch {
1558                                text: file_name_text,
1559                                highlight_positions,
1560                                color: Color::Default,
1561                            }
1562                        });
1563
1564                        path_start_offset += path_byte_len;
1565                        file_name_match
1566                    })
1567                    .collect();
1568
1569                let highlighted_match = HighlightedMatchWithPaths {
1570                    prefix: match location {
1571                        SerializedWorkspaceLocation::Remote(options) => {
1572                            Some(SharedString::from(options.display_name()))
1573                        }
1574                        _ => None,
1575                    },
1576                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1577                    paths: Vec::new(),
1578                    active: false,
1579                };
1580
1581                Some(
1582                    ListItem::new(ix)
1583                        .toggle_state(selected)
1584                        .inset(true)
1585                        .spacing(ListItemSpacing::Sparse)
1586                        .child(
1587                            h_flex()
1588                                .gap_3()
1589                                .flex_grow_1()
1590                                .child(highlighted_match.render(window, cx)),
1591                        )
1592                        .tooltip(Tooltip::text(tooltip_path))
1593                        .into_any_element(),
1594                )
1595            }
1596        }
1597    }
1598
1599    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1600        let has_selection = self.selected_match().is_some();
1601        let focus_handle = self.focus_handle.clone();
1602
1603        Some(
1604            h_flex()
1605                .flex_1()
1606                .p_1p5()
1607                .gap_1()
1608                .justify_end()
1609                .border_t_1()
1610                .border_color(cx.theme().colors().border_variant)
1611                .child(
1612                    Button::new("open_local_folder", "Choose from Local Folders")
1613                        .key_binding(KeyBinding::for_action_in(
1614                            &workspace::Open::default(),
1615                            &focus_handle,
1616                            cx,
1617                        ))
1618                        .on_click(cx.listener(|this, _, window, cx| {
1619                            this.delegate.open_local_folder(window, cx);
1620                        })),
1621                )
1622                .child(
1623                    Button::new("select_project", "Select")
1624                        .disabled(!has_selection)
1625                        .key_binding(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx))
1626                        .on_click(cx.listener(move |picker, _, window, cx| {
1627                            picker.delegate.confirm(false, window, cx);
1628                        })),
1629                )
1630                .into_any(),
1631        )
1632    }
1633}
1634
1635#[cfg(test)]
1636mod tests {
1637    use super::*;
1638
1639    #[test]
1640    fn test_fuzzy_match_positions_returns_byte_indices() {
1641        // "🔥abc" — the fire emoji is 4 bytes, so 'a' starts at byte 4, 'b' at 5, 'c' at 6.
1642        let text = "🔥abc";
1643        let positions = fuzzy_match_positions("ab", text).expect("should match");
1644        assert_eq!(positions, vec![4, 5]);
1645
1646        // Verify positions are valid char boundaries (this is the assertion that
1647        // panicked before the fix).
1648        for &pos in &positions {
1649            assert!(
1650                text.is_char_boundary(pos),
1651                "position {pos} is not a valid UTF-8 boundary in {text:?}"
1652            );
1653        }
1654    }
1655
1656    #[test]
1657    fn test_fuzzy_match_positions_ascii_still_works() {
1658        let positions = fuzzy_match_positions("he", "hello").expect("should match");
1659        assert_eq!(positions, vec![0, 1]);
1660    }
1661
1662    #[test]
1663    fn test_fuzzy_match_positions_case_insensitive() {
1664        let positions = fuzzy_match_positions("HE", "hello").expect("should match");
1665        assert_eq!(positions, vec![0, 1]);
1666    }
1667
1668    #[test]
1669    fn test_fuzzy_match_positions_no_match() {
1670        assert!(fuzzy_match_positions("xyz", "hello").is_none());
1671    }
1672
1673    #[test]
1674    fn test_fuzzy_match_positions_multi_byte_interior() {
1675        // "café" — 'é' is 2 bytes (0xC3 0xA9), so 'f' starts at byte 4, 'é' at byte 5.
1676        let text = "café";
1677        let positions = fuzzy_match_positions("fé", text).expect("should match");
1678        // 'c'=0, 'a'=1, 'f'=2, 'é'=3..4 — wait, let's verify:
1679        // Actually: c=1 byte, a=1 byte, f=1 byte, é=2 bytes
1680        // So byte positions: c=0, a=1, f=2, é=3
1681        assert_eq!(positions, vec![2, 3]);
1682        for &pos in &positions {
1683            assert!(
1684                text.is_char_boundary(pos),
1685                "position {pos} is not a valid UTF-8 boundary in {text:?}"
1686            );
1687        }
1688    }
1689}
1690
Served at tenant.openagents/omega Member data and write actions are omitted.