1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum StepStatus {
36 #[default]
38 Running,
39 Done,
41}
42
43#[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 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 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
71 self.id = Some(id.into());
72 self
73 }
74
75 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
77 self.detail = Some(detail.into());
78 self
79 }
80
81 pub fn status(mut self, status: StepStatus) -> Self {
83 self.status = status;
84 self
85 }
86
87 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#[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 pub fn new() -> Self {
108 Self::default()
109 }
110
111 pub fn prose(mut self, prose: impl Into<SharedString>) -> Self {
113 self.prose = Some(prose.into());
114 self
115 }
116
117 pub fn steps(mut self, steps: impl IntoIterator<Item = ThinkingStep>) -> Self {
119 self.steps = steps.into_iter().collect();
120 self
121 }
122
123 pub fn thought_for(mut self, duration: Duration) -> Self {
125 self.thought_for = Some(duration);
126 self
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ThinkingEvent {
133 Toggled {
135 id: SharedString,
137 open: bool,
139 },
140}
141
142#[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 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 pub fn open(mut self, open: bool) -> Self {
173 self.open = Some(open);
174 self
175 }
176
177 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 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
198struct LivePreview {
206 scroll: ScrollHandle,
207 revision: Option<u64>,
209 follow: bool,
211 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 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 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 #[cfg(test)]
265 fn arrival_delay(&self, key: &ElementId) -> Option<Duration> {
266 self.arrivals.delay(key)
267 }
268}
269
270fn 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
280fn 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 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 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 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 let body = match &preview {
494 Some((_, scroll, follow)) => {
495 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 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 const TRACE_ID: &str = "trace";
547 const PREVIEW: &str = "thinking-live-preview-trace";
548 const BODY: &str = "thinking-body-trace";
549 const STEPS: usize = 16;
551
552 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 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 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 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 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 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 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 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 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 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 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 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 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 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 cx.executor().advance_clock(Duration::from_secs(2));
860 draw(cx);
861 crate::motion::take_reveal_frame_requests();
862
863 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 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 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 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 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 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}