Skip to main content

gpui_ai/
thread_list.rs

1//! Conversation list: new, switch, rename, archive, delete, and search.
2//!
3//! The application owns the threads (grouped into sections such as Today /
4//! Yesterday / Earlier) and the active thread ID; the list owns only its
5//! native search input, the "show archived" toggle, which row holds keyboard
6//! focus, and the flattened snapshot it virtualizes. Every intent — selecting,
7//! creating, renaming, archiving, deleting — is a typed [`ThreadListEvent`]
8//! keyed by stable ID.
9//!
10//! Two constraints shape the rendering. Construction is bounded by the visible
11//! window: section headers and surviving threads are flattened into
12//! `ThreadRow`s once, in the setters that can change them, and `gpui::list`
13//! asks for rows by index — a ten-thousand-thread snapshot builds a screenful.
14//! And rows are uniform: Rename / Archive / Delete live in an upstream popup
15//! menu anchored to the row's ellipsis rather than in a toolbar revealed
16//! inside the row, so opening one never changes a row's height or shifts the
17//! conversations below it, and dismissal plus focus return come from upstream.
18
19use crate::cues::{self, Cue};
20use crate::scrolling::PolicyScrollbarExt as _;
21use crate::{
22    control::composed_button,
23    resolved_layout::ResolvedLayoutKey,
24    surface::{eyebrow, icon_button},
25    theme::SemanticStyledExt as _,
26};
27use gpui::{
28    Anchor, AnyElement, App, AppContext as _, ClickEvent, Context, DismissEvent, ElementId, Entity,
29    EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyDownEvent,
30    ListAlignment, ListOffset, ListState, ParentElement as _, Pixels, Render, RenderOnce, Role,
31    SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity, Window,
32    div, list, prelude::FluentBuilder as _,
33};
34use gpui_base::StyledExt as _;
35use gpui_component::{
36    ActiveTheme as _, IconName, h_flex,
37    input::{Input, InputEvent, InputState},
38    menu::{PopupMenu, PopupMenuItem},
39    popover::Popover,
40    v_flex,
41};
42use std::{rc::Rc, sync::Arc};
43
44/// One conversation with stable identity.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ThreadItem {
47    id: SharedString,
48    title: SharedString,
49    subtitle: Option<SharedString>,
50    archived: bool,
51}
52
53impl ThreadItem {
54    /// Creates a thread with a stable identifier and visible title.
55    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
56        Self {
57            id: id.into(),
58            title: title.into(),
59            subtitle: None,
60            archived: false,
61        }
62    }
63
64    /// Adds secondary text (a timestamp or the last message).
65    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
66        self.subtitle = Some(subtitle.into());
67        self
68    }
69
70    /// Marks the thread archived; archived threads hide until requested.
71    pub fn archived(mut self, archived: bool) -> Self {
72        self.archived = archived;
73        self
74    }
75
76    /// Returns the stable thread identifier.
77    pub fn id(&self) -> &SharedString {
78        &self.id
79    }
80
81    /// Returns the visible title.
82    pub fn title(&self) -> &SharedString {
83        &self.title
84    }
85
86    /// Returns the secondary text, when present.
87    pub fn subtitle_text(&self) -> Option<&SharedString> {
88        self.subtitle.as_ref()
89    }
90
91    /// Whether the thread is archived.
92    pub fn is_archived(&self) -> bool {
93        self.archived
94    }
95
96    fn matches(&self, query: &str) -> bool {
97        if query.is_empty() {
98            return true;
99        }
100        let needle = query.to_lowercase();
101        self.title.to_lowercase().contains(&needle)
102            || self
103                .subtitle
104                .as_ref()
105                .is_some_and(|subtitle| subtitle.to_lowercase().contains(&needle))
106    }
107}
108
109/// A labeled group of threads (for example "Today").
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ThreadSection {
112    id: SharedString,
113    label: SharedString,
114    items: Vec<ThreadItem>,
115}
116
117impl ThreadSection {
118    /// Creates a section with a stable identifier and visible label.
119    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
120        Self {
121            id: id.into(),
122            label: label.into(),
123            items: Vec::new(),
124        }
125    }
126
127    /// Sets the section's threads, in display order.
128    pub fn items(mut self, items: impl IntoIterator<Item = ThreadItem>) -> Self {
129        self.items = items.into_iter().collect();
130        self
131    }
132
133    /// Returns the stable section identifier.
134    pub fn id(&self) -> &SharedString {
135        &self.id
136    }
137
138    /// Returns the visible label.
139    pub fn label(&self) -> &SharedString {
140        &self.label
141    }
142
143    /// Returns the section's threads.
144    pub fn thread_items(&self) -> &[ThreadItem] {
145        &self.items
146    }
147}
148
149/// An interaction emitted by [`ThreadList`].
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ThreadListEvent {
152    /// The user chose a thread.
153    Selected {
154        /// Stable thread identifier.
155        id: SharedString,
156    },
157    /// The user asked for a new conversation.
158    NewRequested,
159    /// The user wants to rename a thread.
160    RenameRequested {
161        /// Stable thread identifier.
162        id: SharedString,
163    },
164    /// The user wants to archive a thread.
165    ArchiveRequested {
166        /// Stable thread identifier.
167        id: SharedString,
168    },
169    /// The user wants to restore an archived thread.
170    UnarchiveRequested {
171        /// Stable thread identifier.
172        id: SharedString,
173    },
174    /// The user wants to delete a thread.
175    DeleteRequested {
176        /// Stable thread identifier.
177        id: SharedString,
178    },
179    /// The search query changed.
180    QueryChanged {
181        /// The complete current query.
182        query: SharedString,
183    },
184}
185
186fn sections_are_well_formed(sections: &[ThreadSection]) -> bool {
187    let mut section_ids = std::collections::HashSet::new();
188    let mut item_ids = std::collections::HashSet::new();
189    sections.iter().all(|section| {
190        section_ids.insert(&section.id)
191            && section.items.iter().all(|item| item_ids.insert(&item.id))
192    })
193}
194
195/// One row of the flattened snapshot the virtual list indexes into.
196///
197/// Sections are flattened rather than nested because a list that virtualizes
198/// sections still builds every thread inside a visible section. Headers travel
199/// as rows of their own so one list owns the whole document and a header
200/// scrolls away with the threads it labels.
201#[derive(Clone, Debug, PartialEq, Eq)]
202enum ThreadRow {
203    /// A section eyebrow.
204    Header {
205        /// Stable section identifier, used to re-find the row after remeasure.
206        section_id: SharedString,
207        /// The section's visible label.
208        label: SharedString,
209    },
210    /// A conversation and its 1-based position among the visible threads.
211    Thread {
212        /// The thread itself; cloning is three refcount bumps and a bool.
213        item: ThreadItem,
214        /// 1-based position across the whole listbox, for `aria-posinset`.
215        position: usize,
216    },
217}
218
219impl ThreadRow {
220    /// The stable thread ID, for rows that are threads.
221    fn thread_id(&self) -> Option<&SharedString> {
222        match self {
223            Self::Header { .. } => None,
224            Self::Thread { item, .. } => Some(&item.id),
225        }
226    }
227
228    /// Identity that survives a rebuild, so a scroll anchor can be re-found.
229    ///
230    /// Section and thread identifiers live in separate namespaces and may
231    /// legitimately collide, so the kind travels with the ID.
232    fn anchor(&self) -> (bool, SharedString) {
233        match self {
234            Self::Header { section_id, .. } => (true, section_id.clone()),
235            Self::Thread { item, .. } => (false, item.id.clone()),
236        }
237    }
238}
239
240/// Row construction counts for one draw.
241///
242/// The whole point of virtualizing is that a draw builds a window, not a
243/// snapshot, so the bound has to be observable. Counting is test-only:
244/// production builds carry neither the field nor the branch.
245#[cfg(test)]
246#[derive(Default, Clone)]
247struct ThreadConstructionCounts {
248    rows: std::rc::Rc<std::cell::Cell<usize>>,
249    threads: std::rc::Rc<std::cell::Cell<usize>>,
250}
251
252#[cfg(test)]
253impl ThreadConstructionCounts {
254    /// Opens a draw. Counts describe one draw, never a running total.
255    fn start_draw(&self) {
256        self.rows.set(0);
257        self.threads.set(0);
258    }
259
260    fn count_row(&self) {
261        self.rows.set(self.rows.get() + 1);
262    }
263
264    fn count_thread(&self) {
265        self.threads.set(self.threads.get() + 1);
266    }
267}
268
269/// Everything a row needs that does not come from the row itself.
270///
271/// The virtual list hands its item renderer an `&mut App`, not this entity's
272/// context, so intents travel back through a weak handle rather than through
273/// `cx.listener`. Values are snapshotted per draw: changing the active or
274/// focused thread notifies, which rebuilds this and re-renders the window.
275#[derive(Clone)]
276struct RowRenderer {
277    component: SharedString,
278    owner: WeakEntity<ThreadList>,
279    /// The listbox's single focus handle; the popup menu returns focus here.
280    list_focus: FocusHandle,
281    active: Option<SharedString>,
282    focused: Option<SharedString>,
283    visible_threads: usize,
284    /// The list's glide-hover state when the gliding highlight is on;
285    /// rows feed it their bounds and hover instead of painting hover.
286    glide: Option<gpui::Entity<crate::glide::GlideHover>>,
287    #[cfg(test)]
288    construction: ThreadConstructionCounts,
289}
290
291/// The inset every rectangle in the list shares — the new-chat control,
292/// the search field, and each row — measured from the panel's edge.
293fn row_gutter(cx: &App) -> gpui::Pixels {
294    cx.theme().semantic_tokens().spacing.sm
295}
296
297/// The inset from a row's own edge to its text. Section labels stand
298/// inside the same gutter and add it too, so headings and row titles
299/// share one left edge.
300fn row_text_inset(cx: &App) -> gpui::Pixels {
301    cx.theme().semantic_tokens().spacing.xs
302}
303
304impl RowRenderer {
305    fn render(&self, row: &ThreadRow, window: &mut Window, cx: &mut App) -> AnyElement {
306        #[cfg(test)]
307        self.construction.count_row();
308        match row {
309            ThreadRow::Header { section_id, label } => self.render_header(section_id, label, cx),
310            ThreadRow::Thread { item, position } => self.render_thread(item, *position, window, cx),
311        }
312    }
313
314    fn render_header(
315        &self,
316        section_id: &SharedString,
317        label: &SharedString,
318        cx: &mut App,
319    ) -> AnyElement {
320        let tokens = cx.theme().semantic_tokens();
321        // A section label sits on the same left edge as the row titles it
322        // names: both stand inside the list's gutter and add the row's own
323        // text inset.
324        let header_key = section_id.clone();
325        let header_label_key = section_id.clone();
326        let section_id =
327            ElementId::from((ElementId::from(self.component.clone()), section_id.clone()));
328        div()
329            .id((section_id, "header"))
330            .debug_selector(move || format!("thread-section-{header_key}"))
331            // Flattening costs the containment a nested group would give, so
332            // the header keeps an accessible node of its own: the boundary is
333            // still announced, it just no longer wraps its options.
334            .role(Role::Label)
335            .aria_label(label.clone())
336            .w_full()
337            .min_w_0()
338            .border_1()
339            .border_color(cx.theme().transparent)
340            .px(row_text_inset(cx))
341            .pt(tokens.spacing.sm)
342            .pb(tokens.spacing.xxs)
343            .child({
344                div()
345                    .debug_selector(move || format!("thread-section-label-{header_label_key}"))
346                    .child(eyebrow(label.clone(), cx))
347            })
348            .into_any_element()
349    }
350
351    fn render_thread(
352        &self,
353        item: &ThreadItem,
354        position: usize,
355        window: &mut Window,
356        cx: &mut App,
357    ) -> AnyElement {
358        #[cfg(test)]
359        self.construction.count_thread();
360
361        let tokens = cx.theme().semantic_tokens();
362        let root_id = ElementId::from(self.component.clone());
363        let item_id = ElementId::from((root_id.clone(), item.id.clone()));
364        let selected = self.active.as_ref() == Some(&item.id);
365        // The marker a mounted list starts with is placed settled; a row the
366        // user activates fades its surface in once at the quick tempo. Only
367        // constructed rows reach here, so offscreen rows spend nothing, and
368        // a row scrolled out and back keeps its acknowledged state.
369        let acknowledged = crate::motion::acknowledged_state(
370            ElementId::from((item_id.clone(), "active-ack")),
371            selected as u64,
372            window,
373            cx,
374        );
375        let focused = self.focused.as_ref() == Some(&item.id);
376        let accessibility_label: SharedString = if item.archived {
377            format!("{}, archived", item.title).into()
378        } else {
379            item.title.clone()
380        };
381        let debug_id = item.id.clone();
382        let select_id = item.id.clone();
383        let owner = self.owner.clone();
384
385        // The whole row — trailing actions included — is one selection
386        // surface: an inset fill at the nesting-rule radius against the
387        // list's panel, hover covering everything inside it, and the
388        // acknowledged fade playing on the fill. The select control keeps
389        // the semantics and the keyboard focus ring; it paints nothing.
390        // With the gliding highlight on, the row paints no hover of its
391        // own and reports its geometry and hover to the shared state.
392        let row_debug_id = item.id.clone();
393        let base = div()
394            .id((item_id.clone(), "row"))
395            .debug_selector(move || format!("thread-row-{row_debug_id}"));
396        crate::surface::selection_surface(
397            base,
398            selected,
399            tokens.radius.lg,
400            tokens.spacing.xs,
401            self.glide.as_ref().map(|glide| (&item.id, glide)),
402            cx,
403        )
404        // The acknowledged fade plays on the fill alone — the row's
405        // content never dims — by re-stating the selected background
406        // at the acknowledgment's alpha over the surface's own.
407        .when(selected, |row| {
408            row.bg(cx.theme().list_active.opacity(acknowledged))
409        })
410        // One rect is the row: selection fills it, the keyboard ring
411        // outlines it, and the hover highlight covers it — trailing
412        // actions included. A ring around the label alone stopped
413        // before the ellipsis and made focus look unlike selection.
414        .border_1()
415        .border_color(if focused {
416            cx.theme().ring
417        } else {
418            cx.theme().transparent
419        })
420        .w_full()
421        .min_w_0()
422        .flex()
423        .items_center()
424        .gap(tokens.spacing.xxs)
425        .mb(tokens.spacing.xxs)
426        .pr(tokens.spacing.xxs)
427        .child(
428            composed_button((item_id.clone(), "select"), accessibility_label)
429                .debug_selector(move || format!("thread-{debug_id}"))
430                .role(Role::ListBoxOption)
431                .selected(selected)
432                .aria_selected(selected)
433                .aria_position_in_set(position)
434                .aria_size_of_set(self.visible_threads)
435                // Keyboard focus stays on the listbox and travels as an
436                // active descendant, so a row scrolling out of the window
437                // cannot take the focus with it.
438                .when(focused, |button| button.aria_active_descendant())
439                .tab_stop(false)
440                .flex_1()
441                .min_w_0()
442                .justify_start()
443                .px(row_text_inset(cx))
444                .py(tokens.spacing.xs)
445                .child(
446                    v_flex()
447                        .w_full()
448                        .min_w_0()
449                        .items_start()
450                        .child({
451                            let title_debug = item.id.clone();
452                            div()
453                                .debug_selector(move || format!("thread-title-{title_debug}"))
454                                .w_full()
455                                .truncate()
456                                .text_token(tokens.typography.sm)
457                                .text_color(cx.theme().foreground)
458                                .child(item.title.clone())
459                        })
460                        .when_some(item.subtitle.clone(), |this, subtitle| {
461                            this.child(crate::surface::hint(subtitle, cx).w_full().truncate())
462                        }),
463                )
464                .on_click(move |_, window, cx| {
465                    let _ = owner.update(cx, |this, cx| {
466                        this.focus_thread(select_id.clone(), window, cx);
467                        cx.emit(ThreadListEvent::Selected {
468                            id: select_id.clone(),
469                        });
470                        cues::emit(cx, Cue::ThreadSelected);
471                    });
472                }),
473        )
474        .child(self.render_actions_menu(item, item_id, window, cx))
475        .into_any_element()
476    }
477
478    /// The row's ellipsis and the popup menu it opens.
479    ///
480    /// Upstream owns the popup: `Popover` owns the open lifecycle, placement,
481    /// and outside dismissal, and `PopupMenu` owns the items, their keyboard
482    /// model, Escape, and the return of focus to the listbox. The row keeps no
483    /// "which row is open" state, so no row can change height.
484    fn render_actions_menu(
485        &self,
486        item: &ThreadItem,
487        item_id: ElementId,
488        window: &mut Window,
489        cx: &mut App,
490    ) -> impl IntoElement {
491        let tokens = cx.theme().semantic_tokens();
492        let more_debug_id = item.id.clone();
493        let list_focus = self.list_focus.clone();
494        let owner = self.owner.clone();
495        let action_id = item.id.clone();
496        let archived = item.archived;
497        // The menu is rebuilt on each open, so the builder clones rather than
498        // moves: it must be able to run again.
499        ThreadActionsMenu {
500            id: ElementId::from((item_id.clone(), "more-menu")),
501            trigger: icon_button(
502                (item_id, "more"),
503                IconName::Ellipsis,
504                format!("More actions for {}", item.title),
505                window,
506                cx,
507            )
508            .debug_selector(move || format!("thread-more-{more_debug_id}"))
509            .rounded(tokens.radius.sm),
510            build: Rc::new(move |menu, _, _| {
511                let rename_id = action_id.clone();
512                let archive_id = action_id.clone();
513                let delete_id = action_id.clone();
514                let rename_owner = owner.clone();
515                let archive_owner = owner.clone();
516                let delete_owner = owner.clone();
517                menu.action_context(list_focus.clone())
518                    .item(PopupMenuItem::new("Rename").on_click(move |_, _, cx| {
519                        let _ = rename_owner.update(cx, |_, cx| {
520                            cx.emit(ThreadListEvent::RenameRequested {
521                                id: rename_id.clone(),
522                            });
523                        });
524                    }))
525                    .item(
526                        PopupMenuItem::new(if archived { "Unarchive" } else { "Archive" })
527                            .on_click(move |_, _, cx| {
528                                let _ = archive_owner.update(cx, |_, cx| {
529                                    cx.emit(if archived {
530                                        ThreadListEvent::UnarchiveRequested {
531                                            id: archive_id.clone(),
532                                        }
533                                    } else {
534                                        ThreadListEvent::ArchiveRequested {
535                                            id: archive_id.clone(),
536                                        }
537                                    });
538                                });
539                            }),
540                    )
541                    .item(PopupMenuItem::new("Delete").on_click(move |_, _, cx| {
542                        let _ = delete_owner.update(cx, |_, cx| {
543                            cx.emit(ThreadListEvent::DeleteRequested {
544                                id: delete_id.clone(),
545                            });
546                        });
547                    }))
548            }),
549        }
550    }
551}
552
553/// The menu entity a row keeps between the frame that opens it and the frame
554/// that dismisses it.
555#[derive(Default)]
556struct ThreadActionsMenuState {
557    open: bool,
558    menu: Option<Entity<PopupMenu>>,
559}
560
561/// Fills a freshly built menu with a row's actions.
562///
563/// Called on every open rather than once, so an item's label and handler are
564/// always the ones the current snapshot implies — Archive and Unarchive are
565/// the same item under two names.
566type ThreadMenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
567
568/// An ellipsis trigger and the popup menu it opens.
569///
570/// This composes upstream `Popover` and `PopupMenu` by hand rather than using
571/// the `DropdownMenu` trait, which is implemented only for upstream's own
572/// `Button` — and that button takes its accessible name from a visible label,
573/// which an icon-only control does not have. Everything else, including
574/// rebuilding the menu on each open so item state is never stale, follows
575/// `DropdownMenuPopover`.
576#[derive(IntoElement)]
577struct ThreadActionsMenu {
578    id: ElementId,
579    trigger: gpui_base::Button,
580    build: Rc<ThreadMenuBuilder>,
581}
582
583impl RenderOnce for ThreadActionsMenu {
584    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
585        let build = self.build.clone();
586        let state = window.use_keyed_state((self.id.clone(), "menu"), cx, |_, _| {
587            ThreadActionsMenuState::default()
588        });
589        let open = state.read(cx).open;
590        let trigger_state = state.clone();
591        let trigger = self.trigger.on_click(move |event, _, cx| {
592            if matches!(event, ClickEvent::Keyboard(_)) {
593                trigger_state.update(cx, |state, cx| {
594                    state.open = !state.open;
595                    if !state.open {
596                        state.menu = None;
597                    }
598                    cx.notify();
599                });
600            }
601        });
602        let open_state = state.clone();
603
604        Popover::new(self.id)
605            // The menu paints its own surface, and dismisses its own outside
606            // presses, so the popover must add neither.
607            .appearance(false)
608            .overlay_closable(false)
609            // The menu's top-right corner meets the trigger's, so it drops
610            // downward flush with the row's trailing edge.
611            .anchor(Anchor::TopRight)
612            .open(open)
613            .on_open_change(move |open, _, cx| {
614                open_state.update(cx, |state, cx| {
615                    state.open = *open;
616                    if !open {
617                        state.menu = None;
618                    }
619                    cx.notify();
620                });
621            })
622            .trigger(trigger)
623            .content(move |_, window, cx| {
624                if let Some(menu) = state.read(cx).menu.clone() {
625                    return div()
626                        .debug_selector(|| "thread-actions-menu".into())
627                        .child(menu);
628                }
629                let build = build.clone();
630                let menu =
631                    PopupMenu::build(window, cx, move |menu, window, cx| build(menu, window, cx));
632                state.update(cx, |this, _| this.menu = Some(menu.clone()));
633                menu.focus_handle(cx).focus(window, cx);
634
635                window
636                    .subscribe(&menu, cx, {
637                        let state = state.clone();
638                        move |_, _: &DismissEvent, _, cx| {
639                            state.update(cx, |state, cx| {
640                                state.open = false;
641                                state.menu = None;
642                                cx.notify();
643                            });
644                        }
645                    })
646                    .detach();
647                div()
648                    .debug_selector(|| "thread-actions-menu".into())
649                    .child(menu)
650            })
651    }
652}
653
654/// A controlled conversation list with native search.
655///
656/// # Example
657///
658/// ```no_run
659/// # use gpui_ai::prelude::*;
660/// # use gpui::AppContext;
661/// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
662/// let threads = cx.new(|cx| ThreadList::new("threads", window, cx));
663/// threads.update(cx, |threads, cx| {
664///     threads.set_sections(
665///         [ThreadSection::new("today", "Today").items([
666///             ThreadItem::new("t-1", "Supplier pricing").subtitle("2 min ago"),
667///         ])],
668///         cx,
669///     );
670///     threads.set_active(Some("t-1"), cx);
671/// });
672/// # }
673/// ```
674pub struct ThreadList {
675    /// Styles the caller put on this component, applied to its own frame.
676    ///
677    /// Last, so a caller outranks the component's defaults - the same rule the
678    /// builder components follow. A wrapper `div` cannot stand in for this:
679    /// a background, a border, or an ink set on a wrapper paints around the
680    /// component rather than on it.
681    style: gpui::StyleRefinement,
682    id: SharedString,
683    sections: Arc<[ThreadSection]>,
684    active: Option<SharedString>,
685    query: SharedString,
686    input: Entity<InputState>,
687    show_archived: bool,
688    /// The flattened visible snapshot; rebuilt in setters, never in `Render`.
689    rows: Arc<[ThreadRow]>,
690    /// Threads in `rows`, for `aria-setsize`.
691    visible_threads: usize,
692    /// Threads that survive the archive filter, whatever the query — the
693    /// difference between "no conversations yet" and "nothing matches".
694    candidates: usize,
695    /// The row holding keyboard focus, by stable ID. Distinct from `active`,
696    /// which the application owns: moving focus does not select.
697    focused_thread: Option<SharedString>,
698    /// The listbox's single tab stop; options are not focusable themselves.
699    list_focus: FocusHandle,
700    list_state: ListState,
701    /// One highlight glides between rows instead of per-row hover fills.
702    /// Default on; [`Self::set_hover_glide`] restores per-row hover.
703    hover_glide: bool,
704    /// Rem size the retained row heights were measured against.
705    resolved_layout: ResolvedLayoutKey,
706    #[cfg(test)]
707    construction: ThreadConstructionCounts,
708    _input_subscription: Subscription,
709}
710
711impl ThreadList {
712    /// Chooses between the gliding hover highlight (the default) and
713    /// classic per-row hover fills.
714    pub fn set_hover_glide(&mut self, enabled: bool, cx: &mut Context<Self>) {
715        if self.hover_glide != enabled {
716            self.hover_glide = enabled;
717            cx.notify();
718        }
719    }
720
721    /// Creates an empty list around a native search input.
722    pub fn new(id: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
723        let input = cx.new(|cx| InputState::new(window, cx).placeholder("Search conversations"));
724        let subscription =
725            cx.subscribe_in(&input, window, |this, input, event: &InputEvent, _, cx| {
726                if let InputEvent::Change = event {
727                    let query: SharedString = input.read(cx).value().to_string().into();
728                    this.apply_query(query, cx);
729                }
730            });
731        Self {
732            style: gpui::StyleRefinement::default(),
733            id: id.into(),
734            sections: Arc::from([]),
735            active: None,
736            query: "".into(),
737            input,
738            show_archived: false,
739            rows: Arc::from([]),
740            visible_threads: 0,
741            candidates: 0,
742            focused_thread: None,
743            list_focus: cx.focus_handle(),
744            list_state: ListState::new(0, ListAlignment::Top, Pixels::ZERO),
745            hover_glide: true,
746            resolved_layout: ResolvedLayoutKey::default(),
747            #[cfg(test)]
748            construction: ThreadConstructionCounts::default(),
749            _input_subscription: subscription,
750        }
751    }
752
753    /// Replaces the controlled sections. Snapshots with duplicate section or
754    /// thread IDs are ignored so identities can never alias.
755    pub fn set_sections(
756        &mut self,
757        sections: impl IntoIterator<Item = ThreadSection>,
758        cx: &mut Context<Self>,
759    ) {
760        let sections: Arc<[ThreadSection]> = sections.into_iter().collect();
761        if !sections_are_well_formed(&sections) || self.sections == sections {
762            return;
763        }
764        self.sections = sections;
765        self.rebuild_rows();
766        cx.notify();
767    }
768
769    /// Sets the active thread by stable ID (or none).
770    pub fn set_active(&mut self, id: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
771        let id = id.map(Into::into);
772        if self.active != id {
773            self.active = id;
774            // Selection repaints a row; it never changes its height, so the
775            // measured snapshot and the reader's scroll position both stand.
776            cx.notify();
777        }
778    }
779
780    /// Returns the active thread ID.
781    pub fn active_id(&self) -> Option<&SharedString> {
782        self.active.as_ref()
783    }
784
785    /// Shows or hides archived threads.
786    pub fn set_show_archived(&mut self, show: bool, cx: &mut Context<Self>) {
787        if self.show_archived != show {
788            self.show_archived = show;
789            self.rebuild_rows();
790            cx.notify();
791        }
792    }
793
794    /// Whether archived threads are shown.
795    pub fn shows_archived(&self) -> bool {
796        self.show_archived
797    }
798
799    /// Sets the search query programmatically.
800    pub fn set_query(
801        &mut self,
802        query: impl Into<SharedString>,
803        window: &mut Window,
804        cx: &mut Context<Self>,
805    ) {
806        let query = query.into();
807        if self.query == query {
808            return;
809        }
810        self.input.update(cx, |input, cx| {
811            input.set_value(query.to_string(), window, cx)
812        });
813        // InputState suppresses Change for programmatic values.
814        self.apply_query(query, cx);
815    }
816
817    /// Returns the current search query.
818    pub fn query(&self) -> &SharedString {
819        &self.query
820    }
821
822    /// Moves keyboard focus into the search input.
823    pub fn focus_search(&self, window: &mut Window, cx: &mut App) {
824        self.input.read(cx).focus_handle(cx).focus(window, cx);
825    }
826
827    /// Scrolls the list to its end (the oldest visible thread).
828    pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
829        self.list_state.scroll_to_end();
830        cx.notify();
831    }
832
833    fn apply_query(&mut self, query: SharedString, cx: &mut Context<Self>) {
834        if self.query == query {
835            return;
836        }
837        self.query = query.clone();
838        self.rebuild_rows();
839        cx.emit(ThreadListEvent::QueryChanged { query });
840        cx.notify();
841    }
842
843    /// Rebuilds the flattened row snapshot the virtual list indexes into.
844    ///
845    /// Called from every setter that can change what is visible, and never
846    /// from `Render`: the list asks for rows by index during layout, so a
847    /// snapshot rebuilt per frame would cost exactly what virtualizing saves.
848    fn rebuild_rows(&mut self) {
849        let sections = self.sections.clone();
850        let mut rows: Vec<ThreadRow> = Vec::new();
851        let mut visible_threads = 0usize;
852        let mut candidates = 0usize;
853        for section in sections.iter() {
854            let mut header_written = false;
855            for item in section.items.iter() {
856                if !self.show_archived && item.archived {
857                    continue;
858                }
859                candidates += 1;
860                if !item.matches(&self.query) {
861                    continue;
862                }
863                if !header_written {
864                    rows.push(ThreadRow::Header {
865                        section_id: section.id.clone(),
866                        label: section.label.clone(),
867                    });
868                    header_written = true;
869                }
870                visible_threads += 1;
871                rows.push(ThreadRow::Thread {
872                    item: item.clone(),
873                    position: visible_threads,
874                });
875            }
876        }
877        self.visible_threads = visible_threads;
878        self.candidates = candidates;
879        // Keyboard focus is an ID, not an index: it survives a reorder or a
880        // narrowing query as long as the thread it names is still visible.
881        if !self
882            .focused_thread
883            .as_ref()
884            .is_some_and(|id| rows.iter().any(|row| row.thread_id() == Some(id)))
885        {
886            self.focused_thread = None;
887        }
888        self.rows = rows.into();
889        self.list_state.reset(self.rows.len());
890    }
891
892    /// Re-measures the rows after the window's rem size changed.
893    ///
894    /// Row heights cache text laid out at the previous rem, and neither a
895    /// snapshot nor a filter reports a zoom change. The row that was first on
896    /// screen stays first.
897    fn resolve_layout(&mut self, rem_size: Pixels, cx: &mut Context<Self>) {
898        if !self.resolved_layout.observe(rem_size) {
899            return;
900        }
901        let offset = self.list_state.logical_scroll_top();
902        let anchor = self
903            .rows
904            .get(offset.item_ix)
905            .map(|row| (row.anchor(), offset.offset_in_item));
906
907        self.list_state.remeasure();
908        if let Some((anchor, offset_in_item)) = anchor
909            && let Some(item_ix) = self.rows.iter().position(|row| row.anchor() == anchor)
910        {
911            self.list_state.scroll_to(ListOffset {
912                item_ix,
913                offset_in_item,
914            });
915        }
916        cx.notify();
917    }
918
919    /// Flattened index of the row for `id`, when it is visible.
920    fn row_index_of(&self, id: &SharedString) -> Option<usize> {
921        self.rows.iter().position(|row| row.thread_id() == Some(id))
922    }
923
924    /// Moves the roving focus onto `id` and keeps that row on screen.
925    fn focus_thread(&mut self, id: SharedString, window: &mut Window, cx: &mut Context<Self>) {
926        if let Some(index) = self.row_index_of(&id) {
927            self.list_state.scroll_to_reveal_item(index);
928        }
929        self.focused_thread = Some(id);
930        self.list_focus.focus(window, cx);
931        cx.notify();
932    }
933
934    /// The stable IDs of the visible threads, in display order.
935    fn focusable_ids(&self) -> Vec<SharedString> {
936        self.rows
937            .iter()
938            .filter_map(|row| row.thread_id().cloned())
939            .collect()
940    }
941
942    /// Moves the roving focus by `delta` options, clamped at both ends.
943    ///
944    /// Section headers and threads hidden by the archive filter or the query
945    /// are not options, so they are never landed on: only rows that are in the
946    /// snapshot can hold focus. Reaching an end stays there rather than
947    /// wrapping, so Home and End remain the only way to jump the list.
948    fn move_focus(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
949        let ids = self.focusable_ids();
950        if ids.is_empty() {
951            return;
952        }
953        let last = ids.len() - 1;
954        let next = match self
955            .focused_thread
956            .as_ref()
957            .and_then(|id| ids.iter().position(|candidate| candidate == id))
958        {
959            Some(current) => current.saturating_add_signed(delta).min(last),
960            None if delta < 0 => last,
961            None => 0,
962        };
963        let id = ids[next].clone();
964        self.focus_thread(id, window, cx);
965    }
966
967    /// Moves the roving focus to the first or last option.
968    fn focus_bound(&mut self, last: bool, window: &mut Window, cx: &mut Context<Self>) {
969        let ids = self.focusable_ids();
970        let Some(id) = (if last { ids.last() } else { ids.first() }) else {
971            return;
972        };
973        let id = id.clone();
974        self.focus_thread(id, window, cx);
975    }
976
977    /// Selects the focused option, the way clicking it would.
978    fn activate_focused(&mut self, cx: &mut Context<Self>) {
979        let Some(id) = self.focused_thread.clone() else {
980            return;
981        };
982        cx.emit(ThreadListEvent::Selected { id });
983        cues::emit(cx, Cue::ThreadSelected);
984    }
985
986    /// The listbox's keyboard model, on stable IDs.
987    ///
988    /// Only reaches here when focus is inside the listbox, so the search
989    /// input's own caret keys are untouched. Escape is deliberately absent:
990    /// while a row's menu is open the menu holds focus and closes itself
991    /// first, and with no menu open Escape belongs to the application.
992    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
993        match event.keystroke.key.as_str() {
994            "up" => self.move_focus(-1, window, cx),
995            "down" => self.move_focus(1, window, cx),
996            "home" => self.focus_bound(false, window, cx),
997            "end" => self.focus_bound(true, window, cx),
998            "enter" | "space" => self.activate_focused(cx),
999            _ => return,
1000        }
1001        cx.stop_propagation();
1002    }
1003}
1004
1005impl EventEmitter<ThreadListEvent> for ThreadList {}
1006
1007impl Focusable for ThreadList {
1008    fn focus_handle(&self, cx: &App) -> FocusHandle {
1009        self.input.read(cx).focus_handle(cx)
1010    }
1011}
1012
1013impl gpui::Styled for ThreadList {
1014    fn style(&mut self) -> &mut gpui::StyleRefinement {
1015        &mut self.style
1016    }
1017}
1018
1019impl Render for ThreadList {
1020    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1021        // Measured row heights are only valid for the rem they were laid out
1022        // at. Reading it here mutates nothing; the reaction is deferred so
1023        // that render never notifies.
1024        let rem_size = window.rem_size();
1025        if !self.resolved_layout.matches(rem_size) {
1026            cx.defer_in(window, move |this, _, cx| {
1027                this.resolve_layout(rem_size, cx);
1028            });
1029        }
1030        #[cfg(test)]
1031        self.construction.start_draw();
1032
1033        let tokens = cx.theme().semantic_tokens();
1034        let root_id = ElementId::from(self.id.clone());
1035        let show_archived = self.show_archived;
1036        let empty_message: Option<SharedString> = if self.candidates == 0 {
1037            Some("No conversations yet".into())
1038        } else if self.rows.is_empty() {
1039            Some(format!("No conversations match “{}”", self.query).into())
1040        } else {
1041            None
1042        };
1043        let rows = self.rows.clone();
1044        let glide = self
1045            .hover_glide
1046            .then(|| crate::glide::glide_hover_state(root_id.clone(), window, cx));
1047        let renderer = RowRenderer {
1048            component: self.id.clone(),
1049            owner: cx.weak_entity(),
1050            list_focus: self.list_focus.clone(),
1051            active: self.active.clone(),
1052            focused: self.focused_thread.clone(),
1053            visible_threads: self.visible_threads,
1054            glide: glide.clone(),
1055            #[cfg(test)]
1056            construction: self.construction.clone(),
1057        };
1058        let list_state = self.list_state.clone();
1059
1060        v_flex()
1061            .id(root_id.clone())
1062            .role(Role::Group)
1063            .aria_label("Conversations")
1064            .size_full()
1065            .min_h_0()
1066            .min_w_0()
1067            .gap(tokens.spacing.sm)
1068            .child(
1069                h_flex()
1070                    .w_full()
1071                    .items_center()
1072                    .px(row_gutter(cx))
1073                    .gap(tokens.spacing.xs)
1074                    .child(
1075                        div().debug_selector(|| "thread-list-new".into()).child(
1076                            crate::control::outlined_control_with_icon(
1077                                (root_id.clone(), "new"),
1078                                "New chat",
1079                                IconName::Plus,
1080                                "New chat",
1081                                window,
1082                                cx,
1083                            )
1084                            .accessibility_id(format!("{}-new", self.id))
1085                            .on_click(cx.listener(|_, _, _, cx| {
1086                                cx.emit(ThreadListEvent::NewRequested);
1087                            })),
1088                        ),
1089                    )
1090                    .child(div().flex_1())
1091                    .child(
1092                        icon_button(
1093                            (root_id.clone(), "archived"),
1094                            IconName::Inbox,
1095                            if show_archived {
1096                                "Hide archived conversations"
1097                            } else {
1098                                "Show archived conversations"
1099                            },
1100                            window,
1101                            cx,
1102                        )
1103                        .debug_selector(|| "thread-list-archived-toggle".into())
1104                        .selected(show_archived)
1105                        .aria_expanded(show_archived)
1106                        .on_click(cx.listener(|this, _, _, cx| {
1107                            let show = !this.show_archived;
1108                            this.set_show_archived(show, cx);
1109                        })),
1110                    ),
1111            )
1112            .child(
1113                h_flex()
1114                    .debug_selector(|| "thread-list-search".into())
1115                    .w_full()
1116                    .items_center()
1117                    .px(row_gutter(cx))
1118                    .child(
1119                        div()
1120                            .debug_selector(|| "thread-list-field".into())
1121                            .flex_1()
1122                            .min_w_0()
1123                            .child(Input::new(&self.input).prefix(IconName::Search)),
1124                    ),
1125            )
1126            .child(
1127                // The outer frame owns the flex constraint; the virtual list
1128                // fills it, so a long snapshot scrolls a window of rows
1129                // instead of growing the pane or building the whole document.
1130                {
1131                    let frame = div()
1132                        .id((root_id.clone(), "list-frame"))
1133                        .debug_selector(|| "thread-list-scroll".into())
1134                        .relative()
1135                        .flex_1()
1136                        .min_h_0()
1137                        .w_full()
1138                        .overflow_hidden()
1139                        .policy_vertical_scrollbar(&list_state, cx);
1140                    // The gliding highlight paints beneath the rows; the
1141                    // frame's bounds anchor its coordinates.
1142                    match &glide {
1143                        Some(glide) => crate::glide::glide_frame(frame, glide).child(
1144                            crate::glide::glide_highlight(
1145                                root_id.clone(),
1146                                glide,
1147                                crate::surface::nested_radius(
1148                                    tokens.radius.lg,
1149                                    tokens.spacing.xs,
1150                                    tokens.radius.sm,
1151                                ),
1152                                "thread-list-glide",
1153                                window,
1154                                cx,
1155                            ),
1156                        ),
1157                        None => frame,
1158                    }
1159                }
1160                .child(
1161                    div()
1162                        .id((root_id.clone(), "list"))
1163                        .role(Role::ListBox)
1164                        .aria_label("Conversation list")
1165                        // The gutter every row stands on. A virtual list
1166                        // positions its items itself, so the inset is the
1167                        // scroll region's, not each row's.
1168                        .pl(row_gutter(cx))
1169                        // And the trailing side keeps the overlaid bar's lane
1170                        // clear, because these rows carry a menu at that edge
1171                        // and a bar drawn over it is a target nobody can hit.
1172                        .pr(row_gutter(cx).max(crate::scrolling::control_lane(cx)))
1173                        .track_focus(&self.list_focus)
1174                        .tab_stop(true)
1175                        .on_key_down(cx.listener(Self::on_key_down))
1176                        .size_full()
1177                        .min_h_0()
1178                        .when(!rows.is_empty(), |this| {
1179                            this.child(
1180                                list(list_state, move |index, window, cx| {
1181                                    rows.get(index)
1182                                        .map(|row| renderer.render(row, window, cx))
1183                                        .unwrap_or_else(|| div().hidden().into_any_element())
1184                                })
1185                                .size_full(),
1186                            )
1187                        })
1188                        .when_some(empty_message, |this, message| {
1189                            this.child(
1190                                div()
1191                                    .id((root_id, "empty"))
1192                                    .debug_selector(|| "thread-list-empty".into())
1193                                    .role(Role::Status)
1194                                    .aria_label(message.clone())
1195                                    .child(crate::surface::empty_state(
1196                                        IconName::Inbox,
1197                                        message.clone(),
1198                                        Some("Start a chat to see it here".into()),
1199                                        cx,
1200                                    )),
1201                            )
1202                        }),
1203                ),
1204            )
1205            .refine_style(&self.style)
1206    }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212    use gpui::{
1213        Bounds, KeyUpEvent, Keystroke, Modifiers, Pixels, TestAppContext, VisualTestContext, px,
1214        size,
1215    };
1216    use std::cell::Cell;
1217
1218    struct ThreadActionsMenuProbe {
1219        activations: Rc<Cell<usize>>,
1220        trigger_focus: FocusHandle,
1221    }
1222
1223    impl Render for ThreadActionsMenuProbe {
1224        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1225            let activations = self.activations.clone();
1226            div().tab_group().child(ThreadActionsMenu {
1227                id: "keyboard-actions".into(),
1228                trigger: icon_button(
1229                    "keyboard-actions-trigger",
1230                    IconName::Ellipsis,
1231                    "More actions",
1232                    window,
1233                    cx,
1234                )
1235                .track_focus(&self.trigger_focus)
1236                .debug_selector(|| "keyboard-actions-trigger".into()),
1237                build: Rc::new(move |menu, _, _| {
1238                    let activations = activations.clone();
1239                    menu.item(PopupMenuItem::new("Rename").on_click(move |_, _, _| {
1240                        activations.set(activations.get() + 1);
1241                    }))
1242                }),
1243            })
1244        }
1245    }
1246
1247    #[test]
1248    fn matching_is_case_insensitive_over_title_and_subtitle() {
1249        let item = ThreadItem::new("t", "Supplier pricing").subtitle("Compared Alpenrose");
1250        assert!(item.matches(""));
1251        assert!(item.matches("PRICING"));
1252        assert!(item.matches("alpenrose"));
1253        assert!(!item.matches("delivery"));
1254    }
1255
1256    #[test]
1257    fn malformed_snapshots_are_rejected() {
1258        let duplicate_items = [ThreadSection::new("a", "A")
1259            .items([ThreadItem::new("t", "One"), ThreadItem::new("t", "Two")])];
1260        assert!(!sections_are_well_formed(&duplicate_items));
1261        let duplicate_sections = [
1262            ThreadSection::new("a", "A"),
1263            ThreadSection::new("a", "Again"),
1264        ];
1265        assert!(!sections_are_well_formed(&duplicate_sections));
1266        let fine = [
1267            ThreadSection::new("a", "A").items([ThreadItem::new("t-1", "One")]),
1268            ThreadSection::new("b", "B").items([ThreadItem::new("t-2", "Two")]),
1269        ];
1270        assert!(sections_are_well_formed(&fine));
1271    }
1272
1273    #[gpui::test]
1274    fn row_action_menu_opens_and_activates_from_the_keyboard(cx: &mut TestAppContext) {
1275        cx.update(crate::init);
1276        let activations = Rc::new(Cell::new(0));
1277        let (view, cx) = cx.add_window_view({
1278            let activations = activations.clone();
1279            move |_, cx| ThreadActionsMenuProbe {
1280                activations,
1281                trigger_focus: cx.focus_handle(),
1282            }
1283        });
1284        let cx: &mut VisualTestContext = cx;
1285        cx.update(|window, cx| window.draw(cx).clear(cx));
1286        cx.update(|window, cx| {
1287            let trigger_focus = view.read(cx).trigger_focus.clone();
1288            trigger_focus.focus(window, cx);
1289            window.draw(cx).clear(cx);
1290        });
1291
1292        let enter = Keystroke::parse("enter").expect("Enter is a valid keystroke");
1293        cx.simulate_event(KeyDownEvent {
1294            keystroke: enter.clone(),
1295            is_held: false,
1296            prefer_character_input: false,
1297        });
1298        cx.simulate_event(KeyUpEvent { keystroke: enter });
1299        cx.update(|window, cx| window.draw(cx).clear(cx));
1300        assert!(
1301            cx.debug_bounds("thread-actions-menu").is_some(),
1302            "Enter on the named ellipsis button must open the popup"
1303        );
1304
1305        cx.simulate_keystrokes("down enter");
1306        cx.update(|window, cx| window.draw(cx).clear(cx));
1307        assert_eq!(activations.get(), 1);
1308        assert!(cx.debug_bounds("thread-actions-menu").is_none());
1309    }
1310
1311    /// Rows tall enough that a 480pt viewport holds a couple of dozen, so the
1312    /// window is unambiguously smaller than the snapshot.
1313    const MEASURED_VIEWPORT: (f32, f32) = (320., 480.);
1314
1315    /// Threads per section in the large snapshot; ten sections of a thousand.
1316    const LARGE_SECTION_SIZE: usize = 1_000;
1317    const LARGE_SECTIONS: usize = 10;
1318
1319    #[gpui::test]
1320    fn activating_a_thread_acknowledges_once_and_a_mounted_marker_is_settled(
1321        cx: &mut TestAppContext,
1322    ) {
1323        let (threads, cx) = measured_list(cx);
1324        cx.update(|_, cx| {
1325            threads.update(cx, |threads, cx| {
1326                threads.set_sections(
1327                    [ThreadSection::new("today", "Today")
1328                        .items([ThreadItem::new("t-1", "One"), ThreadItem::new("t-2", "Two")])],
1329                    cx,
1330                );
1331                threads.set_active(Some("t-1"), cx);
1332            });
1333        });
1334        redraw(&threads, cx);
1335        cx.executor()
1336            .advance_clock(std::time::Duration::from_secs(2));
1337        redraw(&threads, cx);
1338        crate::motion::take_reveal_frame_requests();
1339        redraw(&threads, cx);
1340        assert_eq!(
1341            crate::motion::take_reveal_frame_requests(),
1342            0,
1343            "the marker the list mounts with is placed settled"
1344        );
1345
1346        cx.update(|_, cx| {
1347            threads.update(cx, |threads, cx| threads.set_active(Some("t-2"), cx));
1348        });
1349        redraw(&threads, cx);
1350        assert!(
1351            crate::motion::take_reveal_frame_requests() > 0,
1352            "activating a thread must acknowledge the new marker"
1353        );
1354    }
1355
1356    fn large_sections() -> Vec<ThreadSection> {
1357        (0..LARGE_SECTIONS)
1358            .map(|section| {
1359                ThreadSection::new(format!("s-{section}"), format!("Section {section}")).items(
1360                    (0..LARGE_SECTION_SIZE).map(|row| {
1361                        let index = section * LARGE_SECTION_SIZE + row;
1362                        ThreadItem::new(format!("t-{index:05}"), format!("Conversation {index}"))
1363                            .subtitle(format!("{index} min ago"))
1364                    }),
1365                )
1366            })
1367            .collect()
1368    }
1369
1370    /// Every rectangle in the list stands on one left edge, and every
1371    /// label — row titles and the section names above them — stands on
1372    /// one more. The feel review found four edges here instead of two.
1373    #[gpui::test]
1374    fn the_list_stands_on_one_left_edge(cx: &mut TestAppContext) {
1375        let (threads, cx) = measured_list(cx);
1376        cx.update(|_, cx| {
1377            threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1378        });
1379        let _ = redraw(&threads, cx);
1380        let _ = redraw(&threads, cx);
1381
1382        let new_chat = bounds_of(cx, "thread-list-new".to_owned()).expect("new chat renders");
1383        let field = bounds_of(cx, "thread-list-field".to_owned()).expect("search renders");
1384        let row = bounds_of(cx, "thread-row-one".to_owned()).expect("a row renders");
1385        assert_eq!(
1386            (new_chat.left(), row.left()),
1387            (field.left(), field.left()),
1388            "the control, the field, and the rows share one gutter"
1389        );
1390
1391        let header = bounds_of(cx, "thread-section-label-recent".to_owned())
1392            .expect("the section label renders");
1393        let title = bounds_of(cx, "thread-title-one".to_owned()).expect("a row title renders");
1394        assert_eq!(
1395            header.left(),
1396            title.left(),
1397            "a section label and the titles it names share one text edge"
1398        );
1399    }
1400
1401    /// Selection, keyboard focus, and hover all describe the same
1402    /// rectangle. A ring drawn around the label alone stopped short of the
1403    /// row's trailing actions and made focus look unlike selection.
1404    #[gpui::test]
1405    fn selection_focus_and_hover_describe_one_row_rect(cx: &mut TestAppContext) {
1406        let (threads, cx) = measured_list(cx);
1407        cx.update(|_, cx| {
1408            threads.update(cx, |threads, cx| {
1409                threads.set_sections(glide_sections(), cx);
1410                threads.set_active(Some(SharedString::from("one")), cx);
1411            });
1412        });
1413        let _ = redraw(&threads, cx);
1414        let _ = redraw(&threads, cx);
1415
1416        let row = bounds_of(cx, "thread-row-one".to_owned()).expect("the row renders");
1417        let select = bounds_of(cx, "thread-one".to_owned()).expect("the select control renders");
1418        assert!(
1419            row.right() > select.right(),
1420            "the row rect reaches past its label into the trailing actions:              row {row:?}, label {select:?}"
1421        );
1422
1423        cx.simulate_mouse_move(row.center(), None, Modifiers::default());
1424        cx.update(|window, cx| window.draw(cx).clear(cx));
1425        let highlight =
1426            bounds_of(cx, "thread-list-glide".to_owned()).expect("hovering highlights the row");
1427        assert_eq!(
1428            highlight.size.width,
1429            row.size.width - px(2.),
1430            "the hover highlight covers the row inside its own border"
1431        );
1432    }
1433
1434    fn glide_sections() -> Vec<ThreadSection> {
1435        vec![ThreadSection::new("recent", "Recent").items([
1436            ThreadItem::new("one", "One"),
1437            ThreadItem::new("two", "Two"),
1438            ThreadItem::new("three", "Three"),
1439        ])]
1440    }
1441
1442    /// A row's trailing control never sits under the scrollbar.
1443    ///
1444    /// The bar is sixteen pixels and overlaid by default, taking no layout
1445    /// space - which is right for prose, and wrong for the menu at a row's
1446    /// trailing edge. The row's gutter was eight, so the bar reached half its
1447    /// width into the row and covered a target nobody could then hit. On a
1448    /// list whose bar is always shown, that is the resting state.
1449    ///
1450    /// Measured against the scroll frame rather than the row, because the bar
1451    /// is positioned from the frame's trailing edge and that is the only edge
1452    /// the arithmetic can be anchored to.
1453    #[gpui::test]
1454    fn a_row_keeps_its_controls_clear_of_the_scrollbar(cx: &mut TestAppContext) {
1455        let (threads, cx) = measured_list(cx);
1456        cx.update(|_, cx| {
1457            threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1458        });
1459        let _ = redraw(&threads, cx);
1460        let _ = redraw(&threads, cx);
1461
1462        let frame = bounds_of(cx, "thread-list-scroll".to_owned()).expect("the list scrolls");
1463        let row = bounds_of(cx, "thread-row-one".to_owned()).expect("a row renders");
1464        let bar = gpui_base::Scrollbar::width();
1465        let gutter = cx.update(|_, cx| super::row_gutter(cx));
1466
1467        assert!(
1468            frame.right() - row.right() >= bar,
1469            "the bar covers this row's trailing edge: row ends {:?} from the frame, bar is {bar:?}",
1470            frame.right() - row.right()
1471        );
1472        // And the reading column keeps what it had: the lane comes off the
1473        // trailing side only.
1474        assert_eq!(
1475            row.left() - frame.left(),
1476            gutter,
1477            "the leading gutter is unchanged"
1478        );
1479    }
1480
1481    /// The lag probe: a fresh hover seats the highlight on its row with no
1482    /// glide from stale geometry, the next hover departs from the previous
1483    /// row and travels between the two, and the highlight arrives exactly
1484    /// on the hovered row once the glide tempo has elapsed.
1485    #[gpui::test]
1486    fn glide_highlight_appears_on_hover_and_chases_the_next_row(cx: &mut TestAppContext) {
1487        let (threads, cx) = measured_list(cx);
1488        cx.update(|_, cx| {
1489            threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1490        });
1491        let _ = redraw(&threads, cx);
1492        let _ = redraw(&threads, cx);
1493
1494        assert!(
1495            bounds_of(cx, "thread-list-glide".to_owned()).is_none(),
1496            "no highlight before anything is hovered"
1497        );
1498
1499        let row_one = bounds_of(cx, "thread-row-one".to_owned()).expect("row one renders");
1500        cx.simulate_mouse_move(row_one.center(), None, Modifiers::default());
1501        cx.update(|window, cx| window.draw(cx).clear(cx));
1502        let highlight = bounds_of(cx, "thread-list-glide".to_owned())
1503            .expect("hovering a row mounts the highlight");
1504        assert_eq!(
1505            highlight.size.height,
1506            row_one.size.height - px(2.),
1507            "a fresh hover seats the highlight on its row, inside its border"
1508        );
1509        assert_eq!(highlight.origin.y, row_one.origin.y + px(1.));
1510
1511        let row_three = bounds_of(cx, "thread-row-three".to_owned()).expect("row three renders");
1512        cx.simulate_mouse_move(row_three.center(), None, Modifiers::default());
1513        cx.update(|window, cx| window.draw(cx).clear(cx));
1514        let start = bounds_of(cx, "thread-list-glide".to_owned()).expect("highlight persists");
1515        assert_eq!(
1516            start.origin.y,
1517            row_one.origin.y + px(1.),
1518            "the glide departs from the previously hovered row"
1519        );
1520
1521        cx.executor()
1522            .advance_clock(std::time::Duration::from_millis(10));
1523        cx.update(|window, cx| window.draw(cx).clear(cx));
1524        let moving = bounds_of(cx, "thread-list-glide".to_owned()).expect("highlight persists");
1525        assert!(
1526            moving.top() > row_one.top() && moving.top() < row_three.top(),
1527            "mid-glide the highlight sits between the rows: {moving:?}"
1528        );
1529
1530        cx.executor()
1531            .advance_clock(std::time::Duration::from_secs(1));
1532        cx.update(|window, cx| window.draw(cx).clear(cx));
1533        let settled = bounds_of(cx, "thread-list-glide".to_owned()).expect("highlight persists");
1534        assert_eq!(
1535            settled.origin.y,
1536            row_three.origin.y + px(1.),
1537            "the highlight arrives on the hovered row"
1538        );
1539
1540        // Settled means settled: nothing moves on further draws.
1541        cx.update(|window, cx| window.draw(cx).clear(cx));
1542        assert_eq!(
1543            bounds_of(cx, "thread-list-glide".to_owned()),
1544            Some(settled),
1545            "a settled highlight holds its row"
1546        );
1547    }
1548
1549    /// A hovered row that leaves the list takes its highlight with it.
1550    ///
1551    /// Hover is cleared by the row's own listener, and an unmounted row has
1552    /// no listener to fire: filtering the list under a stationary pointer
1553    /// used to leave the highlight painting the departed row's last
1554    /// geometry over whatever moved into that space.
1555    #[gpui::test]
1556    fn a_filtered_away_row_takes_its_highlight_with_it(cx: &mut TestAppContext) {
1557        let (threads, cx) = measured_list(cx);
1558        cx.update(|_, cx| {
1559            threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1560        });
1561        let _ = redraw(&threads, cx);
1562        let _ = redraw(&threads, cx);
1563
1564        let row = bounds_of(cx, "thread-row-three".to_owned()).expect("row three renders");
1565        cx.simulate_mouse_move(row.center(), None, Modifiers::default());
1566        cx.update(|window, cx| window.draw(cx).clear(cx));
1567        assert!(
1568            bounds_of(cx, "thread-list-glide".to_owned()).is_some(),
1569            "the hovered row is highlighted"
1570        );
1571
1572        // Filter it away without moving the pointer.
1573        cx.update(|window, cx| {
1574            threads.update(cx, |threads, cx| threads.set_query("One", window, cx));
1575            window.draw(cx).clear(cx);
1576        });
1577        cx.update(|window, cx| window.draw(cx).clear(cx));
1578        assert!(
1579            bounds_of(cx, "thread-row-three".to_owned()).is_none(),
1580            "the filter really removed the row"
1581        );
1582        assert!(
1583            bounds_of(cx, "thread-list-glide".to_owned()).is_none(),
1584            "and its highlight left with it, rather than painting over what took its place"
1585        );
1586    }
1587
1588    /// Any reduced preference snaps the highlight between rows: the move
1589    /// lands on the very next draw with no clock advanced.
1590    #[gpui::test]
1591    fn glide_snaps_between_rows_under_a_reduced_preference(cx: &mut TestAppContext) {
1592        cx.update(|cx| {
1593            crate::motion::MotionTokens::default()
1594                .with_preference(crate::motion::MotionPreference::Crossfade)
1595                .set(cx);
1596            crate::init(cx);
1597        });
1598        let (threads, cx) =
1599            cx.add_window_view(|window, cx| ThreadList::new("measured", window, cx));
1600        let cx: &mut VisualTestContext = cx;
1601        cx.simulate_resize(size(px(MEASURED_VIEWPORT.0), px(MEASURED_VIEWPORT.1)));
1602        cx.update(|_, cx| {
1603            threads.update(cx, |threads, cx| threads.set_sections(glide_sections(), cx));
1604        });
1605        let _ = redraw(&threads, cx);
1606        let _ = redraw(&threads, cx);
1607
1608        let row_one = bounds_of(cx, "thread-row-one".to_owned()).expect("row one renders");
1609        cx.simulate_mouse_move(row_one.center(), None, Modifiers::default());
1610        cx.update(|window, cx| window.draw(cx).clear(cx));
1611        let row_three = bounds_of(cx, "thread-row-three".to_owned()).expect("row three renders");
1612        cx.simulate_mouse_move(row_three.center(), None, Modifiers::default());
1613        cx.update(|window, cx| window.draw(cx).clear(cx));
1614        let snapped = bounds_of(cx, "thread-list-glide".to_owned())
1615            .expect("a reduced preference still highlights the hovered row");
1616        assert_eq!(
1617            snapped.origin.y,
1618            row_three.origin.y + px(1.),
1619            "a reduced preference snaps the highlight to the hovered row"
1620        );
1621    }
1622
1623    /// Per-row hover is the builder escape: with the glide disabled the
1624    /// highlight never mounts.
1625    #[gpui::test]
1626    fn disabling_the_glide_restores_per_row_hover(cx: &mut TestAppContext) {
1627        let (threads, cx) = measured_list(cx);
1628        cx.update(|_, cx| {
1629            threads.update(cx, |threads, cx| {
1630                threads.set_sections(glide_sections(), cx);
1631                threads.set_hover_glide(false, cx);
1632            });
1633        });
1634        let _ = redraw(&threads, cx);
1635        let row_one = bounds_of(cx, "thread-row-one".to_owned()).expect("row one renders");
1636        cx.simulate_mouse_move(row_one.center(), None, Modifiers::default());
1637        cx.update(|window, cx| window.draw(cx).clear(cx));
1638        assert!(
1639            bounds_of(cx, "thread-list-glide".to_owned()).is_none(),
1640            "no gliding highlight when the builder disables it"
1641        );
1642    }
1643
1644    fn measured_list(cx: &mut TestAppContext) -> (Entity<ThreadList>, &mut VisualTestContext) {
1645        cx.update(crate::init);
1646        let (threads, cx) =
1647            cx.add_window_view(|window, cx| ThreadList::new("measured", window, cx));
1648        let cx: &mut VisualTestContext = cx;
1649        cx.simulate_resize(size(px(MEASURED_VIEWPORT.0), px(MEASURED_VIEWPORT.1)));
1650        (threads, cx)
1651    }
1652
1653    /// Redraws and reports `(rows, threads)` built by that one draw.
1654    fn redraw(threads: &Entity<ThreadList>, cx: &mut VisualTestContext) -> (usize, usize) {
1655        cx.update(|window, cx| {
1656            threads.update(cx, |_, cx| cx.notify());
1657            window.draw(cx).clear(cx);
1658        });
1659        threads.read_with(cx, |threads, _| {
1660            (
1661                threads.construction.rows.get(),
1662                threads.construction.threads.get(),
1663            )
1664        })
1665    }
1666
1667    fn bounds_of(cx: &mut VisualTestContext, selector: String) -> Option<Bounds<Pixels>> {
1668        cx.debug_bounds(Box::leak(selector.into_boxed_str()))
1669    }
1670
1671    /// The bound is only a bound if construction tracks the window rather than
1672    /// the snapshot. Ten thousand conversations must build a screenful on the
1673    /// first draw and a screenful again after scrolling a page, and the rows
1674    /// that were not built must be absent from the frame rather than merely
1675    /// invisible.
1676    #[gpui::test]
1677    fn ten_thousand_threads_build_only_the_visible_window(cx: &mut TestAppContext) {
1678        let (threads, cx) = measured_list(cx);
1679        cx.update(|_, cx| {
1680            threads.update(cx, |threads, cx| threads.set_sections(large_sections(), cx));
1681        });
1682
1683        let total = LARGE_SECTIONS * LARGE_SECTION_SIZE;
1684        let (rows, built) = redraw(&threads, cx);
1685        assert!(built > 0, "the first screen must build some threads");
1686        assert!(
1687            rows <= 32,
1688            "a {total}-thread snapshot built {rows} rows on the first draw"
1689        );
1690        assert!(
1691            bounds_of(cx, "thread-t-00000".to_owned()).is_some(),
1692            "the first thread should be on screen"
1693        );
1694        for far in ["thread-t-00500", "thread-t-05000", "thread-t-09999"] {
1695            assert!(
1696                bounds_of(cx, far.to_owned()).is_none(),
1697                "{far} is far outside the window and must not be built"
1698            );
1699        }
1700
1701        // One page down: still a window, and a different one.
1702        let page = threads.read_with(cx, |threads, _| {
1703            threads.list_state.logical_scroll_top().item_ix + rows
1704        });
1705        cx.update(|_, cx| {
1706            threads.update(cx, |threads, _| {
1707                threads.list_state.scroll_to(ListOffset {
1708                    item_ix: page,
1709                    offset_in_item: Pixels::ZERO,
1710                });
1711            });
1712        });
1713        let (scrolled_rows, scrolled_threads) = redraw(&threads, cx);
1714        assert!(scrolled_threads > 0, "the second page must build threads");
1715        assert!(
1716            scrolled_rows <= 32,
1717            "one page down built {scrolled_rows} rows of {total}"
1718        );
1719        assert!(
1720            bounds_of(cx, "thread-t-00000".to_owned()).is_none(),
1721            "the first thread scrolled out and must no longer be built"
1722        );
1723
1724        // Counts describe one draw, never a running total.
1725        let (again, _) = redraw(&threads, cx);
1726        assert_eq!(again, scrolled_rows, "construction must not accumulate");
1727    }
1728
1729    /// The snapshot is derived once per change, so the flattening itself is
1730    /// the contract: headers only for sections that kept a thread, positions
1731    /// numbered across the whole listbox, and no row for a hidden thread.
1732    #[gpui::test]
1733    fn the_flattened_snapshot_carries_headers_and_listbox_positions(cx: &mut TestAppContext) {
1734        let (threads, cx) = measured_list(cx);
1735        cx.update(|_, cx| {
1736            threads.update(cx, |threads, cx| {
1737                threads.set_sections(
1738                    [
1739                        ThreadSection::new("today", "Today").items([
1740                            ThreadItem::new("a", "Alpha"),
1741                            ThreadItem::new("b", "Beta").archived(true),
1742                        ]),
1743                        ThreadSection::new("earlier", "Earlier")
1744                            .items([ThreadItem::new("c", "Gamma")]),
1745                        ThreadSection::new("empty", "Empty")
1746                            .items([ThreadItem::new("d", "Delta").archived(true)]),
1747                    ],
1748                    cx,
1749                );
1750            });
1751        });
1752        threads.read_with(cx, |threads, _| {
1753            assert_eq!(
1754                threads.rows.as_ref(),
1755                &[
1756                    ThreadRow::Header {
1757                        section_id: "today".into(),
1758                        label: "Today".into()
1759                    },
1760                    ThreadRow::Thread {
1761                        item: ThreadItem::new("a", "Alpha"),
1762                        position: 1
1763                    },
1764                    ThreadRow::Header {
1765                        section_id: "earlier".into(),
1766                        label: "Earlier".into()
1767                    },
1768                    ThreadRow::Thread {
1769                        item: ThreadItem::new("c", "Gamma"),
1770                        position: 2
1771                    },
1772                ],
1773                "a section whose every thread is archived contributes no header"
1774            );
1775            assert_eq!(threads.visible_threads, 2);
1776            assert_eq!(threads.candidates, 2);
1777        });
1778    }
1779
1780    /// Zoom invalidates measured heights, and the row that was first on screen
1781    /// must still be first afterwards.
1782    #[gpui::test]
1783    fn a_changed_rem_remeasures_and_keeps_the_first_visible_row(cx: &mut TestAppContext) {
1784        let (threads, cx) = measured_list(cx);
1785        cx.update(|_, cx| {
1786            threads.update(cx, |threads, cx| threads.set_sections(large_sections(), cx));
1787        });
1788        redraw(&threads, cx);
1789        cx.update(|_, cx| {
1790            threads.update(cx, |threads, _| {
1791                threads.list_state.scroll_to(ListOffset {
1792                    item_ix: 24,
1793                    offset_in_item: Pixels::ZERO,
1794                });
1795            });
1796        });
1797        redraw(&threads, cx);
1798        let before = threads.read_with(cx, |threads, _| {
1799            threads.rows[threads.list_state.logical_scroll_top().item_ix].anchor()
1800        });
1801
1802        cx.update(|window, cx| {
1803            window.set_rem_size(px(24.));
1804            window.draw(cx).clear(cx);
1805        });
1806        cx.update(|window, cx| window.draw(cx).clear(cx));
1807
1808        let after = threads.read_with(cx, |threads, _| {
1809            threads.rows[threads.list_state.logical_scroll_top().item_ix].anchor()
1810        });
1811        assert_eq!(before, after, "zooming must not move the reader");
1812        assert!(
1813            threads.read_with(cx, |threads, _| threads.resolved_layout.matches(px(24.))),
1814            "the new rem must be recorded so the next draw does not remeasure again"
1815        );
1816    }
1817
1818    /// Clicking a row takes the roving focus with it, so the keyboard picks up
1819    /// where the pointer left off.
1820    #[gpui::test]
1821    fn clicking_a_row_moves_the_roving_focus(cx: &mut TestAppContext) {
1822        let (threads, cx) = measured_list(cx);
1823        cx.update(|_, cx| {
1824            threads.update(cx, |threads, cx| {
1825                threads.set_sections(
1826                    [ThreadSection::new("today", "Today")
1827                        .items([ThreadItem::new("a", "Alpha"), ThreadItem::new("b", "Beta")])],
1828                    cx,
1829                );
1830            });
1831        });
1832        redraw(&threads, cx);
1833        let row = bounds_of(cx, "thread-b".to_owned()).expect("the row should render");
1834        cx.simulate_click(row.center(), Modifiers::default());
1835        cx.update(|window, cx| window.draw(cx).clear(cx));
1836        threads.read_with(cx, |threads, _| {
1837            assert_eq!(threads.focused_thread.as_deref(), Some("b"));
1838        });
1839    }
1840}