Skip to main content

gpui_ai/
thinking.rs

1//! Expandable progressive reasoning traces.
2//!
3//! While reasoning runs the disclosure opens on its own, its title shimmers,
4//! and a live preview follows the newest steps — until the reader scrolls
5//! back through earlier ones, which holds their position until they return to
6//! the tail. Once the trace settles it collapses to "Thought for Ns" and the
7//! full trace stays one click (or Enter) away. Applications may override the
8//! automatic policy with [`Thinking::open`].
9
10use crate::control::QuietSurfaceExt as _;
11use crate::{
12    control::composed_button,
13    handlers::Handler,
14    motion::{ArrivalRoster, MotionTokens, Shimmer, disclosure_progress, reveal},
15    stream::{ProgressState, Progressive},
16    theme::SemanticStyledExt as _,
17};
18use gpui::{
19    App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
20    RenderOnce, Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement,
21    Styled, Window, div, prelude::FluentBuilder as _,
22};
23use gpui_component::{
24    ActiveTheme as _, Sizable as _, StyledExt as _, h_flex, spinner::Spinner, text::TextView,
25    v_flex,
26};
27use std::{
28    hash::{DefaultHasher, Hash as _, Hasher as _},
29    mem::discriminant,
30    time::Duration,
31};
32
33/// Status of one step inside a [`ThinkingTrace`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum StepStatus {
36    /// The step is in progress; it shows a spinner.
37    #[default]
38    Running,
39    /// The step completed.
40    Done,
41}
42
43/// One step of a reasoning trace: a title plus optional detail.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ThinkingStep {
46    id: Option<SharedString>,
47    title: SharedString,
48    detail: Option<SharedString>,
49    status: StepStatus,
50}
51
52impl ThinkingStep {
53    /// Creates a step with a short title.
54    pub fn new(title: impl Into<SharedString>) -> Self {
55        Self {
56            id: None,
57            title: title.into(),
58            detail: None,
59            status: StepStatus::default(),
60        }
61    }
62
63    /// Names the step's stable identity.
64    ///
65    /// Motion is keyed by this ID, so a step keeps its completion
66    /// acknowledgment and its arrival across insertion and reordering.
67    /// Without one the step falls back to its position, which is stable only
68    /// while nothing is inserted above it — a legacy snapshot renders fine,
69    /// but only identified steps may ever animate a reorder.
70    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
71        self.id = Some(id.into());
72        self
73    }
74
75    /// Adds detail rendered as muted markdown under the title.
76    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
77        self.detail = Some(detail.into());
78        self
79    }
80
81    /// Sets the step status.
82    pub fn status(mut self, status: StepStatus) -> Self {
83        self.status = status;
84        self
85    }
86
87    /// The key this step's motion is stable under: its declared identity, or
88    /// its position for legacy snapshots.
89    fn motion_key(&self, trace_id: &SharedString, channel: &str, ix: usize) -> ElementId {
90        match &self.id {
91            Some(id) => ElementId::Name(format!("{trace_id}-step-{id}-{channel}").into()),
92            None => ElementId::NamedInteger(format!("{trace_id}-step-{channel}").into(), ix as u64),
93        }
94    }
95}
96
97/// Typed content of a progressive thinking trace.
98#[derive(Debug, Clone, PartialEq, Eq, Default)]
99pub struct ThinkingTrace {
100    prose: Option<SharedString>,
101    steps: Vec<ThinkingStep>,
102    thought_for: Option<Duration>,
103}
104
105impl ThinkingTrace {
106    /// Creates an empty trace.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Sets free-form reasoning rendered before structured steps.
112    pub fn prose(mut self, prose: impl Into<SharedString>) -> Self {
113        self.prose = Some(prose.into());
114        self
115    }
116
117    /// Sets structured trace steps.
118    pub fn steps(mut self, steps: impl IntoIterator<Item = ThinkingStep>) -> Self {
119        self.steps = steps.into_iter().collect();
120        self
121    }
122
123    /// Sets the caller-measured thinking duration.
124    pub fn thought_for(mut self, duration: Duration) -> Self {
125        self.thought_for = Some(duration);
126        self
127    }
128}
129
130/// An interaction emitted by [`Thinking`].
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ThinkingEvent {
133    /// Requests a controlled expansion-state change.
134    Toggled {
135        /// Stable trace identifier.
136        id: SharedString,
137        /// Proposed expansion state.
138        open: bool,
139    },
140}
141
142/// An accessible reasoning disclosure with an automatic streaming policy.
143#[derive(IntoElement)]
144pub struct Thinking {
145    id: SharedString,
146    style: StyleRefinement,
147    open: Option<bool>,
148    state: ProgressState,
149    trace: ThinkingTrace,
150    revision: u64,
151    on_event: Option<Handler<ThinkingEvent>>,
152}
153
154impl Thinking {
155    /// Creates a trace from a progressive snapshot.
156    ///
157    /// Without an explicit [`Self::open`] the trace is expanded while
158    /// reasoning runs and collapsed once it settles.
159    pub fn new(id: impl Into<SharedString>, trace: &Progressive<ThinkingTrace>) -> Self {
160        Self {
161            id: id.into(),
162            style: StyleRefinement::default(),
163            open: None,
164            state: trace.state().clone(),
165            trace: trace.content().clone(),
166            revision: trace.revision(),
167            on_event: None,
168        }
169    }
170
171    /// Sets the expansion state explicitly, replacing the automatic policy.
172    pub fn open(mut self, open: bool) -> Self {
173        self.open = Some(open);
174        self
175    }
176
177    /// Handles typed trace interactions.
178    pub fn on_event(
179        mut self,
180        handler: impl Fn(&ThinkingEvent, &mut Window, &mut App) + 'static,
181    ) -> Self {
182        self.on_event = Some(Box::new(handler));
183        self
184    }
185
186    /// Whether the body is shown: the explicit value, or "open while running".
187    pub fn is_open(&self) -> bool {
188        self.open.unwrap_or(self.state == ProgressState::Running)
189    }
190}
191
192impl Styled for Thinking {
193    fn style(&mut self) -> &mut StyleRefinement {
194        &mut self.style
195    }
196}
197
198/// Live-preview state for one trace, held in keyed window state.
199///
200/// A [`RenderOnce`] trace remembers nothing across frames, so without this it
201/// cannot tell newly streamed reasoning from a re-render and drags the
202/// preview back to the tail on every frame. The key carries the trace's
203/// stable ID, so a different trace starts with a fresh scroll position and a
204/// fresh follow decision.
205struct LivePreview {
206    scroll: ScrollHandle,
207    /// Digest of the content the preview last showed.
208    revision: Option<u64>,
209    /// Whether the user was at the tail when the preview last rendered.
210    follow: bool,
211    /// Which step identities the preview has shown, and what arrival delay
212    /// each fresh one carries. Keyed by the step's motion key, so an
213    /// identified step stays seen wherever it moves, while an unidentified
214    /// one is only as stable as its position — the documented fallback
215    /// limit.
216    arrivals: ArrivalRoster,
217}
218
219impl LivePreview {
220    fn new() -> Self {
221        Self {
222            scroll: ScrollHandle::new(),
223            revision: None,
224            follow: true,
225            arrivals: ArrivalRoster::new(),
226        }
227    }
228
229    /// Records `revision` and the position the user left the preview in, and
230    /// answers whether this render should land on the tail. Content the
231    /// preview has already shown never scrolls, and neither does new content
232    /// once the user has scrolled away to read earlier reasoning.
233    fn observe(&mut self, revision: u64, slack: Pixels) -> bool {
234        self.follow = follows_tail(self.scroll.offset().y, self.scroll.max_offset().y, slack);
235        let arrived = self.revision != Some(revision);
236        self.revision = Some(revision);
237        arrived && self.follow
238    }
239
240    /// Takes the roll call of this render's step identities and assigns one
241    /// decelerating cascade to the identities not seen before.
242    ///
243    /// The first roll call is history and joins at rest. Later batches
244    /// cascade only when `assign` and the reader is following: the caller
245    /// passes `assign` only while the disclosure rests fully open, because
246    /// during a body fade-in the fade owns the acknowledgment — one dominant
247    /// signal, never two. Reasoning that streams in behind a reader who
248    /// scrolled away likewise appears at rest when they return. Either way
249    /// the identity is marked seen, so nothing retro-animates.
250    fn note_steps(
251        &mut self,
252        keys: impl Iterator<Item = ElementId>,
253        assign_arrivals: bool,
254        tokens: &MotionTokens,
255        now: crate::motion::Instant,
256    ) {
257        let follow = self.follow;
258        self.arrivals
259            .note(keys, assign_arrivals && follow, tokens, now);
260    }
261
262    /// The arrival delay this step identity was assigned, or `None` for one
263    /// that appears at rest.
264    #[cfg(test)]
265    fn arrival_delay(&self, key: &ElementId) -> Option<Duration> {
266        self.arrivals.delay(key)
267    }
268}
269
270/// Whether a preview resting at `offset_y` still counts as following the tail.
271///
272/// GPUI scroll offsets are zero at the top of the content and `-max_offset` at
273/// the bottom, so `offset_y + max_offset_y` is the distance left to the tail.
274/// `slack` keeps a preview that stopped a fraction short of the edge — wheel
275/// and trackpad gestures rarely land exactly on it — still following.
276fn follows_tail(offset_y: Pixels, max_offset_y: Pixels, slack: Pixels) -> bool {
277    max_offset_y <= Pixels::ZERO || offset_y + max_offset_y <= slack
278}
279
280/// Digest of everything the live preview shows.
281///
282/// Applications rebuild their [`Progressive`] snapshot every frame, so a trace
283/// that grew and one that merely re-rendered both report revision zero. The
284/// digest folds the trace itself in, which is what makes appended reasoning
285/// distinguishable from a repaint.
286fn content_revision(revision: u64, trace: &ThinkingTrace) -> u64 {
287    let mut hasher = DefaultHasher::new();
288    revision.hash(&mut hasher);
289    trace.prose.hash(&mut hasher);
290    trace.thought_for.hash(&mut hasher);
291    for step in &trace.steps {
292        step.id.hash(&mut hasher);
293        step.title.hash(&mut hasher);
294        step.detail.hash(&mut hasher);
295        discriminant(&step.status).hash(&mut hasher);
296    }
297    hasher.finish()
298}
299
300impl RenderOnce for Thinking {
301    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
302        let tokens = cx.theme().semantic_tokens();
303        let live = self.state == ProgressState::Running;
304        let open = self.is_open();
305        let title: SharedString = match (&self.state, self.trace.thought_for) {
306            (ProgressState::Running, _) => "Thinking…".into(),
307            (ProgressState::Failed(_), _) => "Thinking stopped".into(),
308            (_, Some(duration)) => format!("Thought for {:.0}s", duration.as_secs_f64()).into(),
309            _ => "Thoughts".into(),
310        };
311        let event = ThinkingEvent::Toggled {
312            id: self.id.clone(),
313            open: !open,
314        };
315        let failed = match &self.state {
316            ProgressState::Failed(reason) => Some(reason.clone()),
317            _ => None,
318        };
319        let trace_id = self.id.clone();
320        let root_id = ElementId::from(self.id.clone());
321        let motion = MotionTokens::read(cx).clone();
322
323        let disclosure = disclosure_progress((root_id.clone(), "disclosure"), open, window, cx);
324        let disclosure_opacity =
325            crate::motion::disclosure_fade((root_id.clone(), "disclosure"), open, window, cx);
326        let showing = open;
327
328        // Observed before the steps render, so a batch that arrived this
329        // frame has its cascade assigned by the time each row asks for its
330        // delay. A collapsed trace has no preview to follow, so it keeps no
331        // follow state either: reopening starts again on the newest
332        // reasoning.
333        let preview = (live && showing).then(|| {
334            let revision = content_revision(self.revision, &self.trace);
335            let preview = window.use_keyed_state((root_id.clone(), "live-follow"), cx, |_, _| {
336                LivePreview::new()
337            });
338            let (scroll, follow) = preview.update(cx, |state, cx| {
339                let follow = state.observe(revision, tokens.typography.sm.line_height);
340                state.note_steps(
341                    self.trace
342                        .steps
343                        .iter()
344                        .enumerate()
345                        .map(|(ix, step)| step.motion_key(&trace_id, "arrive", ix)),
346                    disclosure >= 1.0,
347                    &motion,
348                    cx.background_executor().now(),
349                );
350                (state.scroll.clone(), follow)
351            });
352            (preview, scroll, follow)
353        });
354
355        let interactive = self.on_event.is_some();
356        let header = h_flex()
357            .items_center()
358            .gap(tokens.spacing.sm)
359            .text_token(tokens.typography.sm)
360            .text_color(cx.theme().muted_foreground)
361            .when(interactive, |this| {
362                this.child(crate::surface::disclosure_chevron(disclosure))
363            })
364            .child(
365                Shimmer::new((root_id.clone(), "title"), title.clone())
366                    .active(live)
367                    .text_token(tokens.typography.sm),
368            );
369        let toggle = match self.on_event {
370            Some(handler) => composed_button(format!("{}-toggle", self.id), title.clone())
371                .aria_expanded(open)
372                .px(tokens.spacing.xs)
373                .py(tokens.spacing.xxs)
374                .quiet_press_surface(
375                    ElementId::from((root_id.clone(), "toggle-press")),
376                    tokens.radius.sm,
377                    window,
378                    cx,
379                )
380                .child(header)
381                .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
382                .into_any_element(),
383            None => header.into_any_element(),
384        };
385
386        let body = v_flex()
387            .gap(tokens.spacing.sm)
388            .pl(tokens.spacing.md)
389            .ml(tokens.spacing.xs)
390            .border_l_1()
391            .border_color(cx.theme().border)
392            .when_some(self.trace.prose, |this, prose| {
393                this.child(
394                    div()
395                        .text_token(tokens.typography.sm)
396                        .text_color(cx.theme().muted_foreground)
397                        .child(TextView::markdown("prose", prose).selectable(true)),
398                )
399            })
400            .children(self.trace.steps.into_iter().enumerate().map(|(ix, step)| {
401                let accessibility_label: SharedString = format!(
402                    "{}, {}",
403                    step.title,
404                    match step.status {
405                        StepStatus::Running => "in progress",
406                        StepStatus::Done => "complete",
407                    }
408                )
409                .into();
410                let indicator = match step.status {
411                    StepStatus::Running => Spinner::new()
412                        .xsmall()
413                        .color(cx.theme().info)
414                        .into_any_element(),
415                    // Completion settles into the fixed indicator slot with a
416                    // one-shot reveal, keyed by the step's declared identity
417                    // where it has one. The positional fallback is stable
418                    // only while nothing is inserted above the step — an
419                    // insertion shifts every later index and would replay
420                    // acknowledgments on the wrong rows — which is why
421                    // unidentified steps never animate reordering.
422                    StepStatus::Done => reveal(
423                        div()
424                            .size_1p5()
425                            .rounded(tokens.radius.full)
426                            .bg(cx.theme().success),
427                        step.motion_key(&trace_id, "done", ix),
428                        window,
429                        cx,
430                    )
431                    .into_any_element(),
432                };
433                // Freshly streamed steps settle in on the batch's assigned
434                // cascade; history and steps that arrived behind a scrolled
435                // reader carry no delay entry and render at rest.
436                let arrival_key = step.motion_key(&trace_id, "arrive", ix);
437                let arrival = preview.as_ref().and_then(|(state, ..)| {
438                    state.update(cx, |state, cx| {
439                        state.arrivals.progress(&arrival_key, window, cx)
440                    })
441                });
442                v_flex()
443                    .id((trace_id.clone(), ix))
444                    .role(Role::ListItem)
445                    .aria_label(accessibility_label)
446                    .gap(tokens.spacing.xxs)
447                    .child(
448                        h_flex()
449                            .items_center()
450                            .gap(tokens.spacing.sm)
451                            .text_token(tokens.typography.sm)
452                            .text_color(cx.theme().foreground)
453                            .child(
454                                div()
455                                    .flex_none()
456                                    .w(tokens.spacing.sm)
457                                    .flex()
458                                    .justify_center()
459                                    .child(indicator),
460                            )
461                            .child(step.title),
462                    )
463                    .when_some(step.detail, |this, detail| {
464                        this.child(
465                            div()
466                                .pl(tokens.spacing.md)
467                                .text_token(tokens.typography.sm)
468                                .text_color(cx.theme().muted_foreground)
469                                .child(
470                                    TextView::markdown(("step-detail", ix), detail)
471                                        .selectable(true),
472                                ),
473                        )
474                    })
475                    .when_some(arrival, |this, progress| {
476                        this.opacity(progress)
477                            .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx))
478                    })
479            }))
480            .when_some(failed.clone(), |this, reason| {
481                this.child(
482                    div()
483                        .text_token(tokens.typography.sm)
484                        .text_color(cx.theme().danger)
485                        .child(reason),
486                )
487            })
488            .debug_selector(|| format!("thinking-body-{trace_id}"));
489
490        // While reasoning streams, the body is a bounded live preview that
491        // follows its newest content; it still scrolls, and the full trace
492        // renders unbounded once the state settles.
493        let body = match &preview {
494            Some((_, scroll, follow)) => {
495                // GPUI applies the request during this preview's own prepaint,
496                // so it has to be made while the tree is built — a prepaint or
497                // next-frame hook lands a frame late. It is never
498                // unconditional: it takes content this preview has not shown
499                // yet plus a user who has not scrolled away from the tail.
500                if *follow {
501                    scroll.scroll_to_bottom();
502                }
503                div()
504                    .id((root_id.clone(), "live-preview"))
505                    .debug_selector(|| format!("thinking-live-preview-{trace_id}"))
506                    .max_h(tokens.spacing.xxl * 4.0)
507                    .overflow_y_scroll()
508                    .track_scroll(scroll)
509                    .child(body)
510                    .into_any_element()
511            }
512            None => body.into_any_element(),
513        };
514
515        v_flex()
516            .id(self.id.clone())
517            .role(Role::Group)
518            .aria_label(title)
519            .when_some(failed, |this, reason| this.aria_description(reason))
520            .gap(tokens.spacing.xs)
521            .child(toggle)
522            .when(showing, |this| {
523                // Opening content fades in; closed content leaves the tree
524                // immediately, including its selectable text and controls.
525                this.child(
526                    div()
527                        .opacity(disclosure_opacity)
528                        .top(tokens.spacing.xxs * (1.0 - disclosure) * crate::motion::travel(cx))
529                        .child(body),
530                )
531            })
532            .refine_style(&self.style)
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use gpui::{
540        Context, Entity, Render, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext,
541        point, px,
542    };
543
544    /// Stable trace ID for the window tests. `debug_bounds` takes a literal,
545    /// so the selectors below have to spell the ID out.
546    const TRACE_ID: &str = "trace";
547    const PREVIEW: &str = "thinking-live-preview-trace";
548    const BODY: &str = "thinking-body-trace";
549    /// Steps enough to overflow the preview's four-`xxl` cap several times.
550    const STEPS: usize = 16;
551
552    /// A trace mounted in a host taller than the live preview, so overflow is
553    /// the preview's own bound rather than the window's.
554    struct TraceProbe {
555        state: ProgressState,
556        steps: usize,
557        open: bool,
558    }
559
560    impl TraceProbe {
561        fn running() -> Self {
562            Self {
563                state: ProgressState::Running,
564                steps: STEPS,
565                open: true,
566            }
567        }
568
569        /// Rebuilt per frame, exactly as applications rebuild theirs — every
570        /// snapshot therefore reports revision zero.
571        fn progress(&self) -> Progressive<ThinkingTrace> {
572            let trace = ThinkingTrace::new()
573                .steps((0..self.steps).map(|ix| ThinkingStep::new(format!("Reasoning step {ix}"))));
574            match &self.state {
575                ProgressState::Pending => Progressive::pending(trace),
576                ProgressState::Running => Progressive::running(trace),
577                ProgressState::Complete => Progressive::complete(trace),
578                ProgressState::Failed(reason) => Progressive::failed(trace, reason.clone()),
579            }
580        }
581    }
582
583    impl Render for TraceProbe {
584        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
585            div()
586                .w(px(320.))
587                .h(px(480.))
588                .child(Thinking::new(TRACE_ID, &self.progress()).open(self.open))
589        }
590    }
591
592    fn draw(cx: &mut VisualTestContext) {
593        cx.update(|window, cx| window.draw(cx).clear(cx));
594    }
595
596    /// Where the trace body sits inside its live preview: zero at the top of
597    /// the content, negative once the preview has scrolled down.
598    fn scroll_offset(cx: &mut VisualTestContext) -> Pixels {
599        let preview = cx.debug_bounds(PREVIEW).expect("the preview should render");
600        let body = cx.debug_bounds(BODY).expect("the trace body should render");
601        body.top() - preview.top()
602    }
603
604    /// How far the end of the content sits below the preview: zero while the
605    /// preview is pinned to the tail.
606    fn distance_past_tail(cx: &mut VisualTestContext) -> f32 {
607        let preview = cx.debug_bounds(PREVIEW).expect("the preview should render");
608        let body = cx.debug_bounds(BODY).expect("the trace body should render");
609        (body.bottom() - preview.bottom()).as_f32()
610    }
611
612    fn assert_pinned_to_tail(cx: &mut VisualTestContext, expectation: &str) {
613        let overflow = {
614            let preview = cx.debug_bounds(PREVIEW).expect("the preview should render");
615            let body = cx.debug_bounds(BODY).expect("the trace body should render");
616            (body.size.height - preview.size.height).as_f32()
617        };
618        assert!(
619            overflow > 0.0,
620            "the trace must outgrow the preview for this to mean anything"
621        );
622        assert!(
623            distance_past_tail(cx).abs() < 1.0,
624            "{expectation} (content ends {} past the preview)",
625            distance_past_tail(cx)
626        );
627    }
628
629    fn append_step(probe: &Entity<TraceProbe>, cx: &mut VisualTestContext) {
630        probe.update(cx, |probe, cx| {
631            probe.steps += 1;
632            cx.notify();
633        });
634        draw(cx);
635    }
636
637    fn set_open(probe: &Entity<TraceProbe>, cx: &mut VisualTestContext, open: bool) {
638        probe.update(cx, |probe, cx| {
639            probe.open = open;
640            cx.notify();
641        });
642        draw(cx);
643    }
644
645    /// Advances past the disclosure cross-fade and draws the settled frame.
646    fn settle_disclosure(cx: &mut VisualTestContext) {
647        cx.executor()
648            .advance_clock(MotionTokens::DEFAULT.standard() * 2);
649        draw(cx);
650        draw(cx);
651    }
652
653    /// Wheels the preview back toward earlier reasoning; positive GPUI offsets
654    /// move toward the top of the content.
655    fn scroll_up(cx: &mut VisualTestContext, distance: f32) {
656        let preview = cx.debug_bounds(PREVIEW).expect("the preview should render");
657        cx.simulate_event(ScrollWheelEvent {
658            position: preview.center(),
659            delta: ScrollDelta::Pixels(point(px(0.), px(distance))),
660            ..Default::default()
661        });
662        draw(cx);
663    }
664
665    #[gpui::test]
666    fn streamed_reasoning_stays_pinned_to_the_tail_while_following(cx: &mut TestAppContext) {
667        cx.update(crate::init);
668        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
669        let cx: &mut VisualTestContext = cx;
670        draw(cx);
671
672        assert_pinned_to_tail(cx, "a live preview opens on the newest reasoning");
673        append_step(&probe, cx);
674        assert_pinned_to_tail(
675            cx,
676            "streamed reasoning keeps a followed preview at the tail",
677        );
678    }
679
680    #[gpui::test]
681    fn streamed_reasoning_after_a_scroll_up_keeps_the_users_position(cx: &mut TestAppContext) {
682        cx.update(crate::init);
683        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
684        let cx: &mut VisualTestContext = cx;
685        draw(cx);
686        let tail = scroll_offset(cx);
687
688        scroll_up(cx, 80.);
689        let inspecting = scroll_offset(cx);
690        assert!(
691            inspecting > tail,
692            "the wheel should have moved the preview off the tail"
693        );
694
695        append_step(&probe, cx);
696        assert_eq!(
697            scroll_offset(cx),
698            inspecting,
699            "streamed reasoning must not steal the position the user chose"
700        );
701        append_step(&probe, cx);
702        assert_eq!(
703            scroll_offset(cx),
704            inspecting,
705            "and it must not creep back over repeated updates"
706        );
707    }
708
709    #[gpui::test]
710    fn collapsing_and_reopening_returns_to_the_newest_reasoning(cx: &mut TestAppContext) {
711        cx.update(crate::init);
712        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
713        let cx: &mut VisualTestContext = cx;
714        draw(cx);
715        scroll_up(cx, 80.);
716        assert!(distance_past_tail(cx) > 1.0, "the preview is off the tail");
717
718        set_open(&probe, cx, false);
719        assert!(
720            cx.debug_bounds(PREVIEW).is_none(),
721            "a collapsed trace immediately removes its live preview"
722        );
723
724        // Reasoning keeps streaming behind the collapsed disclosure.
725        probe.update(cx, |probe, cx| {
726            probe.steps += 1;
727            cx.notify();
728        });
729        set_open(&probe, cx, true);
730        assert_pinned_to_tail(cx, "reopening a live trace lands on the newest reasoning");
731    }
732
733    #[gpui::test]
734    fn ten_rapid_toggles_keep_the_body_consistent_with_expansion(cx: &mut TestAppContext) {
735        cx.update(crate::init);
736        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
737        let cx: &mut VisualTestContext = cx;
738        draw(cx);
739
740        // Faster than the header transition can finish: closed content must
741        // still leave the semantic and interaction tree on every toggle.
742        for toggle in 0..10 {
743            probe.update(cx, |probe, cx| {
744                probe.open = !probe.open;
745                cx.notify();
746            });
747            cx.executor().advance_clock(Duration::from_millis(70));
748            draw(cx);
749            assert_eq!(
750                cx.debug_bounds(BODY).is_some(),
751                toggle % 2 == 1,
752                "toggle {toggle} must match the expanded state"
753            );
754        }
755
756        // Ten flips from open land back on open; close deliberately, and
757        // once the channel settles the body is gone for real.
758        set_open(&probe, cx, false);
759        settle_disclosure(cx);
760        assert!(
761            cx.debug_bounds(BODY).is_none(),
762            "a settled closed disclosure unmounts its body"
763        );
764    }
765
766    #[gpui::test]
767    fn streamed_reasoning_does_not_replay_the_open_transition(cx: &mut TestAppContext) {
768        cx.update(crate::init);
769        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
770        let cx: &mut VisualTestContext = cx;
771        draw(cx);
772        settle_disclosure(cx);
773        // The preview box, not the body: the body scrolls inside it as the
774        // tail is followed, while the box only moves if the disclosure lift
775        // re-runs — which is exactly what must not happen.
776        let settled_top = cx
777            .debug_bounds(PREVIEW)
778            .expect("the settled preview should render")
779            .top();
780
781        append_step(&probe, cx);
782        draw(cx);
783        assert_eq!(
784            cx.debug_bounds(PREVIEW)
785                .expect("the streaming preview should render")
786                .top(),
787            settled_top,
788            "appended reasoning must not re-run the disclosure lift"
789        );
790    }
791
792    #[gpui::test]
793    fn fresh_steps_animate_and_history_does_not(cx: &mut TestAppContext) {
794        cx.update(crate::init);
795        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
796        let cx: &mut VisualTestContext = cx;
797        draw(cx);
798        settle_disclosure(cx);
799        crate::motion::take_reveal_frame_requests();
800
801        // History shown at mount stays at rest: no reveal demand.
802        draw(cx);
803        assert_eq!(
804            crate::motion::take_reveal_frame_requests(),
805            0,
806            "steps the preview opened with must not animate arrival"
807        );
808
809        // A step streamed while following the tail settles in.
810        append_step(&probe, cx);
811        assert!(
812            crate::motion::take_reveal_frame_requests() > 0,
813            "a freshly streamed step must acknowledge its arrival"
814        );
815
816        // Let it finish, then stream one in behind a scrolled-away reader:
817        // choreography is bounded to the live tail.
818        settle_disclosure(cx);
819        scroll_up(cx, 80.);
820        crate::motion::take_reveal_frame_requests();
821        append_step(&probe, cx);
822        assert_eq!(
823            crate::motion::take_reveal_frame_requests(),
824            0,
825            "reasoning that streams in behind the reader appears at rest"
826        );
827    }
828
829    #[gpui::test]
830    fn an_identified_step_keeps_its_acknowledgment_across_insertion(cx: &mut TestAppContext) {
831        struct IdentityProbe {
832            leading: usize,
833        }
834
835        impl Render for IdentityProbe {
836            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
837                let steps = (0..self.leading)
838                    .map(|ix| ThinkingStep::new(format!("Inserted {ix}")))
839                    .chain([ThinkingStep::new("Anchored")
840                        .id("anchored")
841                        .status(StepStatus::Done)]);
842                // Settled, so the arrival machinery is out of the picture and
843                // the only reveal in play is the completion acknowledgment
844                // whose keying is under test.
845                let trace = Progressive::complete(ThinkingTrace::new().steps(steps));
846                div()
847                    .w(px(320.))
848                    .h(px(480.))
849                    .child(Thinking::new(TRACE_ID, &trace).open(true))
850            }
851        }
852
853        cx.update(crate::init);
854        let (probe, cx) = cx.add_window_view(|_, _| IdentityProbe { leading: 0 });
855        let cx: &mut VisualTestContext = cx;
856        draw(cx);
857        settle_disclosure(cx);
858        // Let the completion reveal play out entirely.
859        cx.executor().advance_clock(Duration::from_secs(2));
860        draw(cx);
861        crate::motion::take_reveal_frame_requests();
862
863        // Insert a step above: the identified step's index shifts, but its
864        // declared identity keys the acknowledgment, so nothing replays.
865        // (The positional fallback would shift keys here and replay the
866        // reveal on the wrong row — the failure the ID exists to prevent.)
867        probe.update(cx, |probe, cx| {
868            probe.leading = 1;
869            cx.notify();
870        });
871        draw(cx);
872        draw(cx);
873        assert_eq!(
874            crate::motion::take_reveal_frame_requests(),
875            0,
876            "an identified step must not replay completion when its index shifts"
877        );
878    }
879
880    #[gpui::test]
881    fn reduced_motion_opens_and_closes_without_travel(cx: &mut TestAppContext) {
882        cx.update(|cx| {
883            crate::init(cx);
884            cx.set_reduce_motion(true);
885        });
886        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
887        let cx: &mut VisualTestContext = cx;
888        draw(cx);
889        assert!(
890            cx.debug_bounds(BODY).is_some(),
891            "reduced motion still opens the body — immediately"
892        );
893
894        set_open(&probe, cx, false);
895        assert!(
896            cx.debug_bounds(BODY).is_none(),
897            "reduced motion closes without a fade to wait out"
898        );
899    }
900
901    #[gpui::test]
902    fn a_settled_trace_stops_adjusting_the_scroll_position(cx: &mut TestAppContext) {
903        cx.update(crate::init);
904        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
905        let cx: &mut VisualTestContext = cx;
906        draw(cx);
907        scroll_up(cx, 80.);
908
909        probe.update(cx, |probe, cx| {
910            probe.state = ProgressState::Failed("reasoning interrupted".into());
911            cx.notify();
912        });
913        draw(cx);
914        assert!(
915            cx.debug_bounds(PREVIEW).is_none(),
916            "a settled trace renders the full trace, not a bounded preview"
917        );
918        let settled = cx.debug_bounds(BODY).expect("the trace body should render");
919
920        append_step(&probe, cx);
921        let after = cx.debug_bounds(BODY).expect("the trace body should render");
922        assert_eq!(
923            after.top(),
924            settled.top(),
925            "a settled trace never moves its content"
926        );
927        assert!(
928            after.size.height > settled.size.height,
929            "the settled trace grows in place instead of scrolling"
930        );
931    }
932
933    #[gpui::test]
934    fn reduced_motion_still_lands_on_the_tail(cx: &mut TestAppContext) {
935        cx.update(crate::init);
936        cx.update(|cx| cx.set_reduce_motion(true));
937        let (probe, cx) = cx.add_window_view(|_, _| TraceProbe::running());
938        let cx: &mut VisualTestContext = cx;
939        draw(cx);
940
941        assert_pinned_to_tail(cx, "a reduced-motion preview opens at its resting frame");
942        append_step(&probe, cx);
943        assert_pinned_to_tail(cx, "reduced motion settles on the tail without animating");
944    }
945
946    #[gpui::test]
947    fn a_constrained_live_preview_keeps_every_step_reachable(cx: &mut TestAppContext) {
948        cx.update(crate::init);
949        let (_, cx) = cx.add_window_view(|_, _| TraceProbe::running());
950        let cx: &mut VisualTestContext = cx;
951        draw(cx);
952
953        assert_pinned_to_tail(cx, "the end of the trace is on screen while following");
954        scroll_up(cx, 4000.);
955        assert!(
956            scroll_offset(cx).as_f32().abs() < 1.0,
957            "scrolling back must reach the first step"
958        );
959    }
960
961    #[test]
962    fn a_preview_with_nothing_to_scroll_follows_the_tail() {
963        assert!(follows_tail(Pixels::ZERO, Pixels::ZERO, px(20.)));
964    }
965
966    #[test]
967    fn following_tolerates_stopping_just_short_of_the_bottom() {
968        assert!(follows_tail(px(-200.), px(200.), px(20.)), "at the tail");
969        assert!(
970            follows_tail(px(-190.), px(200.), px(20.)),
971            "ten short of it"
972        );
973        assert!(
974            !follows_tail(px(-160.), px(200.), px(20.)),
975            "forty short of it is deliberate"
976        );
977        assert!(
978            !follows_tail(Pixels::ZERO, px(200.), px(20.)),
979            "the top of the trace is not the tail"
980        );
981    }
982
983    #[test]
984    fn a_live_preview_follows_only_content_it_has_not_shown() {
985        let slack = px(20.);
986        let mut preview = LivePreview::new();
987        assert!(
988            preview.observe(7, slack),
989            "the first content a preview shows lands on the tail"
990        );
991        assert!(
992            !preview.observe(7, slack),
993            "a re-render of the same content never scrolls"
994        );
995        assert!(preview.observe(8, slack), "appended reasoning follows");
996    }
997
998    #[test]
999    fn arrival_delays_belong_to_fresh_followed_identities_only() {
1000        let tokens = MotionTokens::DEFAULT;
1001        let now = crate::motion::Instant::now();
1002        let key = |name: &str| ElementId::Name(format!("step-{name}").into());
1003        let keys = |names: &[&str]| names.iter().map(|name| key(name)).collect::<Vec<_>>();
1004        let mut preview = LivePreview::new();
1005
1006        // The first roll call is history: seen, at rest.
1007        preview.note_steps(keys(&["a", "b"]).into_iter(), true, &tokens, now);
1008        assert_eq!(preview.arrival_delay(&key("a")), None);
1009        assert_eq!(preview.arrival_delay(&key("b")), None);
1010
1011        // One appended identity is a batch of one — acknowledged, no cascade.
1012        preview.note_steps(keys(&["a", "b", "c"]).into_iter(), true, &tokens, now);
1013        assert_eq!(preview.arrival_delay(&key("c")), Some(Duration::ZERO));
1014
1015        // A three-identity batch cascades, decelerating, and the assignment
1016        // is frozen: a later roll call must not re-space it.
1017        preview.note_steps(
1018            keys(&["a", "b", "c", "d", "e", "f"]).into_iter(),
1019            true,
1020            &tokens,
1021            now,
1022        );
1023        let batch: Vec<_> = ["d", "e", "f"]
1024            .iter()
1025            .map(|name| preview.arrival_delay(&key(name)))
1026            .collect();
1027        assert_eq!(batch[0], Some(Duration::ZERO));
1028        assert!(batch[1] < batch[2], "the cascade is ordered");
1029        preview.note_steps(
1030            keys(&["a", "b", "c", "d", "e", "f", "g"]).into_iter(),
1031            true,
1032            &tokens,
1033            now,
1034        );
1035        assert_eq!(
1036            ["d", "e", "f"]
1037                .iter()
1038                .map(|name| preview.arrival_delay(&key(name)))
1039                .collect::<Vec<_>>(),
1040            batch,
1041            "an earlier batch keeps the delays it was assigned"
1042        );
1043
1044        // An identity that arrives while the reader is away from the tail
1045        // appears at rest when they return.
1046        preview.follow = false;
1047        preview.note_steps(
1048            keys(&["a", "b", "c", "d", "e", "f", "g", "h"]).into_iter(),
1049            true,
1050            &tokens,
1051            now,
1052        );
1053        assert_eq!(preview.arrival_delay(&key("h")), None);
1054
1055        // So does one that lands under the body's own fade-in, which owns
1056        // the acknowledgment.
1057        preview.follow = true;
1058        preview.note_steps(
1059            keys(&["a", "b", "c", "d", "e", "f", "g", "h", "i"]).into_iter(),
1060            false,
1061            &tokens,
1062            now,
1063        );
1064        assert_eq!(preview.arrival_delay(&key("i")), None);
1065    }
1066
1067    #[test]
1068    fn the_content_digest_sees_growth_that_the_snapshot_revision_cannot() {
1069        let trace = ThinkingTrace::new().steps([ThinkingStep::new("Reading the schema")]);
1070        let appended = ThinkingTrace::new().steps([
1071            ThinkingStep::new("Reading the schema"),
1072            ThinkingStep::new("Comparing unit prices"),
1073        ]);
1074        let finished = ThinkingTrace::new()
1075            .steps([ThinkingStep::new("Reading the schema").status(StepStatus::Done)]);
1076
1077        assert_eq!(
1078            content_revision(0, &trace),
1079            content_revision(0, &trace.clone())
1080        );
1081        assert_ne!(content_revision(0, &trace), content_revision(0, &appended));
1082        assert_ne!(content_revision(0, &trace), content_revision(0, &finished));
1083        assert_ne!(
1084            content_revision(0, &trace),
1085            content_revision(0, &trace.clone().prose("Considering the schema"))
1086        );
1087    }
1088
1089    #[test]
1090    fn trace_maps_the_shared_lifecycle() {
1091        let trace = ThinkingTrace::new().prose("Considering the schema");
1092        for (progress, state) in [
1093            (Progressive::pending(trace.clone()), ProgressState::Pending),
1094            (Progressive::running(trace.clone()), ProgressState::Running),
1095            (
1096                Progressive::complete(trace.clone()),
1097                ProgressState::Complete,
1098            ),
1099            (
1100                Progressive::failed(trace.clone(), "reasoning interrupted"),
1101                ProgressState::Failed("reasoning interrupted".into()),
1102            ),
1103        ] {
1104            let thinking = Thinking::new("trace", &progress);
1105            assert_eq!(thinking.state, state);
1106            assert_eq!(
1107                thinking.trace.prose.as_deref(),
1108                Some("Considering the schema")
1109            );
1110        }
1111    }
1112
1113    #[test]
1114    fn automatic_policy_opens_while_running_and_collapses_when_settled() {
1115        let trace = ThinkingTrace::new();
1116        assert!(Thinking::new("t", &Progressive::running(trace.clone())).is_open());
1117        assert!(!Thinking::new("t", &Progressive::complete(trace.clone())).is_open());
1118        assert!(!Thinking::new("t", &Progressive::pending(trace.clone())).is_open());
1119        assert!(!Thinking::new("t", &Progressive::failed(trace.clone(), "stopped")).is_open());
1120    }
1121
1122    #[test]
1123    fn explicit_open_overrides_the_automatic_policy() {
1124        let trace = ThinkingTrace::new();
1125        assert!(
1126            !Thinking::new("t", &Progressive::running(trace.clone()))
1127                .open(false)
1128                .is_open()
1129        );
1130        assert!(
1131            Thinking::new("t", &Progressive::complete(trace))
1132                .open(true)
1133                .is_open()
1134        );
1135    }
1136}