Skip to main content

ui/components/ai/
agent_thread_view.rs

1use gpui::{FollowMode, ListAlignment, ListState, SharedString, list};
2
3use crate::prelude::*;
4
5/// Renders a scrollable chat transcript on top of `gpui`'s virtualized
6/// [`list`] element.
7///
8/// This component owns none of the thread state: the caller constructs and
9/// keeps the [`ListState`] (item count, scroll position) alive across
10/// renders, and supplies a `render_item` callback that maps an index to a
11/// message element (typically an [`AgentMessageBubble`](super::AgentMessageBubble)).
12/// `sticky_to_bottom` is likewise caller-computed (e.g. "was the user already
13/// scrolled to the bottom before this render?") and only toggles the list's
14/// [`FollowMode`] here.
15#[derive(IntoElement, RegisterComponent)]
16pub struct AgentThreadView {
17    id: ElementId,
18    list_state: ListState,
19    render_item: Box<dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static>,
20    sticky_to_bottom: bool,
21}
22
23impl AgentThreadView {
24    pub fn new(
25        id: impl Into<ElementId>,
26        list_state: ListState,
27        render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
28    ) -> Self {
29        Self {
30            id: id.into(),
31            list_state,
32            render_item: Box::new(render_item),
33            sticky_to_bottom: false,
34        }
35    }
36
37    /// When `true`, the list auto-scrolls to follow newly appended items
38    /// (e.g. the caller determined the user hasn't scrolled up to read
39    /// history). When `false`, the current scroll position is preserved.
40    pub fn sticky_to_bottom(mut self, sticky: bool) -> Self {
41        self.sticky_to_bottom = sticky;
42        self
43    }
44}
45
46impl RenderOnce for AgentThreadView {
47    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
48        self.list_state.set_follow_mode(if self.sticky_to_bottom {
49            FollowMode::Tail
50        } else {
51            FollowMode::Normal
52        });
53
54        div()
55            .id(self.id)
56            .size_full()
57            .child(list(self.list_state, self.render_item).size_full())
58    }
59}
60
61impl Component for AgentThreadView {
62    fn scope() -> ComponentScope {
63        ComponentScope::Agent
64    }
65
66    fn name() -> &'static str {
67        "AgentThreadView"
68    }
69
70    fn description() -> Option<&'static str> {
71        Some(
72            "A scrollable agent chat transcript backed by gpui's virtualized \
73             list element. The caller owns the ListState and item data; this \
74             component only wires up rendering and sticky-to-bottom follow mode.",
75        )
76    }
77
78    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
79        let messages: Vec<SharedString> = vec![
80            "User: How do I run tests?".into(),
81            "Assistant: Run `cargo test -p boltz-ui`.".into(),
82            "User: Thanks!".into(),
83        ];
84
85        let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(256.));
86        let border_color = cx.theme().colors().border_variant;
87
88        Some(
89            v_flex()
90                .gap_4()
91                .child(single_example(
92                    "Default",
93                    div()
94                        .w_96()
95                        .h(px(160.))
96                        .border_1()
97                        .border_color(border_color)
98                        .child(
99                            AgentThreadView::new("thread-preview", list_state, move |ix, _, _| {
100                                div()
101                                    .px_2()
102                                    .py_1()
103                                    .child(Label::new(messages[ix].clone()))
104                                    .into_any_element()
105                            })
106                            .sticky_to_bottom(true),
107                        )
108                        .into_any_element(),
109                ))
110                .into_any_element(),
111        )
112    }
113}