Skip to main content

ui/components/ai/
thinking_block.rs

1use std::sync::Arc;
2
3use gpui::{ClickEvent, Entity};
4use markdown::Markdown;
5
6use crate::prelude::*;
7use crate::{AgentMarkdown, Disclosure};
8
9/// Collapsible "Thinking…" block: a chevron + label header, and (when
10/// expanded) the thought body rendered as markdown.
11///
12/// Expansion is entirely caller-owned — mirrors `AgentThreadView`'s
13/// `sticky_to_bottom` pattern — so the caller can auto-expand while a turn
14/// is still streaming and collapse (or leave user-collapsed) once it
15/// finishes, without this component tracking any state itself.
16#[derive(IntoElement, RegisterComponent)]
17pub struct ThinkingBlock {
18    id: ElementId,
19    markdown: Entity<Markdown>,
20    expanded: bool,
21    on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
22}
23
24impl ThinkingBlock {
25    pub fn new(id: impl Into<ElementId>, markdown: Entity<Markdown>) -> Self {
26        Self {
27            id: id.into(),
28            markdown,
29            expanded: false,
30            on_toggle_expanded: None,
31        }
32    }
33
34    pub fn expanded(mut self, expanded: bool) -> Self {
35        self.expanded = expanded;
36        self
37    }
38
39    pub fn on_toggle_expanded(
40        mut self,
41        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
42    ) -> Self {
43        self.on_toggle_expanded = Some(Arc::new(handler));
44        self
45    }
46}
47
48impl RenderOnce for ThinkingBlock {
49    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
50        let expanded = self.expanded;
51        let disclosure_id = format!("{}-disclosure", self.id);
52        let body_id = format!("{}-md", self.id);
53
54        let header = h_flex()
55            .id(self.id.clone())
56            .gap_1()
57            .child(
58                Icon::new(IconName::ToolThink)
59                    .size(IconSize::XSmall)
60                    .color(Color::Muted),
61            )
62            .child(
63                Label::new("Thinking…")
64                    .size(LabelSize::Small)
65                    .color(Color::Muted),
66            )
67            .child(
68                Disclosure::new(disclosure_id, expanded)
69                    .on_toggle_expanded(self.on_toggle_expanded),
70            );
71
72        v_flex()
73            .w_full()
74            .gap_1()
75            .child(header)
76            .when(expanded, |this| {
77                this.child(
78                    div()
79                        .opacity(0.7)
80                        .pl_5()
81                        .child(AgentMarkdown::new(body_id, self.markdown)),
82                )
83            })
84    }
85}
86
87impl Component for ThinkingBlock {
88    fn scope() -> ComponentScope {
89        ComponentScope::Agent
90    }
91
92    fn description() -> Option<&'static str> {
93        Some(
94            "A collapsible 'Thinking…' block; expansion is caller-owned so it \
95             can auto-expand while streaming and stay put once a turn finishes.",
96        )
97    }
98
99    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
100        let entity = crate::agent_markdown_entity(
101            "Checking the test suite before making changes to `agent_message.rs`.",
102            cx,
103        );
104
105        Some(
106            v_flex()
107                .w_96()
108                .gap_4()
109                .child(single_example(
110                    "Expanded",
111                    ThinkingBlock::new("thinking-preview-1", entity)
112                        .expanded(true)
113                        .into_any_element(),
114                ))
115                .into_any_element(),
116        )
117    }
118}