Skip to main content

gpui_ai/
status.rs

1//! One status vocabulary for every lifecycle the library displays.
2//!
3//! Tool chips, task rows, tool-call cards, and plan steps all describe work
4//! as pending, running, completed, or failed. [`StatusTone`] maps those
5//! meanings onto the theme's semantic colors once, and [`StatusBadge`] is the
6//! single compact pill that renders them, so status looks identical wherever
7//! it appears.
8
9use crate::motion::swap_progress;
10use crate::stream::ProgressState;
11use crate::theme::SemanticStyledExt as _;
12use gpui::{
13    AnyElement, App, Div, ElementId, FontWeight, Hsla, InteractiveElement as _, IntoElement,
14    ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
15    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, rems,
16};
17use gpui_component::{
18    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, spinner::Spinner, v_flex,
19};
20
21/// The meaning a status carries, independent of any one component.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum StatusTone {
24    /// Not started, idle, or informational-neutral.
25    #[default]
26    Neutral,
27    /// Actively in progress.
28    Info,
29    /// Finished successfully.
30    Success,
31    /// Needs attention but is not an error.
32    Warning,
33    /// Failed or destructive.
34    Danger,
35}
36
37impl StatusTone {
38    /// Resolves the tone to the active theme's semantic color.
39    pub fn color(self, cx: &App) -> Hsla {
40        match self {
41            Self::Neutral => cx.theme().muted_foreground,
42            Self::Info => cx.theme().info,
43            Self::Success => cx.theme().success,
44            Self::Warning => cx.theme().warning,
45            Self::Danger => cx.theme().danger,
46        }
47    }
48
49    /// Maps the shared progressive lifecycle onto a tone.
50    pub fn from_progress(state: &ProgressState) -> Self {
51        match state {
52            ProgressState::Pending => Self::Neutral,
53            ProgressState::Running => Self::Info,
54            ProgressState::Complete => Self::Success,
55            ProgressState::Failed(_) => Self::Danger,
56        }
57    }
58}
59
60/// How strongly a chip speaks.
61///
62/// Two strengths are the whole chip vocabulary: a neutral inset pill for
63/// plain values, and the tinted status chip — solid status-colour text on
64/// the same hue at low alpha, borderless — for lifecycle and tags. Each
65/// carries its own padding, because a neutral pill holds a bare word
66/// while a tinted one holds a word beside a glyph.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub(crate) enum ChipStrength {
69    /// Inset pill: secondary surface, muted ink.
70    Neutral,
71    /// Solid status-colour text on the same hue at low alpha. The default.
72    #[default]
73    Tinted,
74}
75
76/// The chip surface all three strengths share: extra-small medium-weight
77/// text, chip paddings, full radius, and no border — a tinted pill inside
78/// a bordered row never doubles its lines.
79pub(crate) fn chip_frame(color: Hsla, strength: ChipStrength, cx: &App) -> Div {
80    let tokens = cx.theme().semantic_tokens();
81    let (background, foreground, padding) = match strength {
82        ChipStrength::Neutral => (
83            cx.theme().secondary,
84            cx.theme().muted_foreground,
85            tokens.spacing.xs,
86        ),
87        ChipStrength::Tinted => (color.opacity(0.12), color, tokens.spacing.sm),
88    };
89    div()
90        .flex_none()
91        .px(padding)
92        .py(tokens.spacing.xxs)
93        .rounded(tokens.radius.full)
94        .bg(background)
95        .text_token(tokens.typography.xs)
96        .font_weight(FontWeight::MEDIUM)
97        .text_color(foreground)
98}
99
100/// One lifecycle glyph for the whole library, seated in a slot from the
101/// size policy: hollow dot pending, spinner running, check complete,
102/// cross failed. Settled faces fade in once through the shared
103/// acknowledgment (the state a surface mounts with is exempt); the
104/// spinner stays untouched, since fading an animation in would stack two
105/// motions on one slot. Glyph weight follows the slot — the medium slot
106/// seats small icons, anything tighter seats extra-small — so the same
107/// lifecycle reads identically across cards, rows, and tables.
108pub(crate) fn progress_glyph(
109    state: &ProgressState,
110    ack_slot: ElementId,
111    slot: Pixels,
112    window: &mut gpui::Window,
113    cx: &mut gpui::App,
114) -> Div {
115    let tokens = cx.theme().semantic_tokens();
116    let ordinal = match state {
117        ProgressState::Pending => 0,
118        ProgressState::Running => 1,
119        ProgressState::Complete => 2,
120        ProgressState::Failed(_) => 3,
121    };
122    let acknowledged = crate::motion::acknowledged_state(ack_slot, ordinal, window, cx);
123    let roomy = slot >= crate::sizing::slot_md(cx);
124    let glyph: AnyElement = match state {
125        ProgressState::Pending => div()
126            .when(roomy, |dot| dot.size_2())
127            .when(!roomy, |dot| dot.size_1p5())
128            .rounded(tokens.radius.full)
129            .border_1()
130            .border_color(cx.theme().muted_foreground)
131            .into_any_element(),
132        ProgressState::Running => {
133            let spinner = Spinner::new().color(cx.theme().info);
134            if roomy {
135                spinner.small()
136            } else {
137                spinner.xsmall()
138            }
139            .into_any_element()
140        }
141        ProgressState::Complete => {
142            let icon = Icon::new(IconName::CircleCheck).text_color(cx.theme().success);
143            if roomy { icon.small() } else { icon.xsmall() }
144                .opacity(acknowledged)
145                .into_any_element()
146        }
147        ProgressState::Failed(_) => {
148            let icon = Icon::new(IconName::CircleX).text_color(cx.theme().danger);
149            if roomy { icon.small() } else { icon.xsmall() }
150                .opacity(acknowledged)
151                .into_any_element()
152        }
153    };
154    crate::surface::leading_glyph_slot(slot, glyph)
155}
156
157/// Human-readable label for a progressive lifecycle state.
158pub fn progress_label(state: &ProgressState) -> &'static str {
159    match state {
160        ProgressState::Pending => "Pending",
161        ProgressState::Running => "Running",
162        ProgressState::Complete => "Completed",
163        ProgressState::Failed(_) => "Failed",
164    }
165}
166
167/// A compact status pill: a tone-colored dot (or spinner while active) and a
168/// short label, exposed as a named status region.
169///
170/// # Example
171///
172/// ```no_run
173/// # use gpui_ai::prelude::*;
174/// # fn example(task: Progressive<()>) {
175/// StatusBadge::for_progress("call-status", task.state());
176/// StatusBadge::new("review", "Needs review").tone(StatusTone::Warning);
177/// # }
178/// ```
179#[derive(IntoElement)]
180pub struct StatusBadge {
181    id: ElementId,
182    style: StyleRefinement,
183    label: SharedString,
184    tone: StatusTone,
185    active: bool,
186    reserve_lifecycle_width: bool,
187}
188
189/// What the badge showed last, held in keyed window state so a change can
190/// stage the outgoing status while the incoming one settles into the slot.
191struct StatusSwap {
192    label: SharedString,
193    tone: StatusTone,
194    active: bool,
195    /// Bumped per change; keys the swap clock so each change plays once.
196    generation: u64,
197    /// The status being faded out, present only while a swap is running.
198    /// An outgoing spinner is frozen to its tone's dot: a settling copy of
199    /// an animation would be a second active-work signal.
200    outgoing: Option<(SharedString, StatusTone)>,
201}
202
203impl StatusBadge {
204    /// Creates a neutral badge.
205    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
206        Self {
207            id: id.into(),
208            style: StyleRefinement::default(),
209            label: label.into(),
210            tone: StatusTone::Neutral,
211            active: false,
212            reserve_lifecycle_width: false,
213        }
214    }
215
216    /// Creates a badge describing a progressive lifecycle state.
217    ///
218    /// The badge is as wide as the word it says, like every other chip in
219    /// the library: a pill padded out to some other label's width leaves
220    /// its own text adrift, which is what the 0.4.0 feel review saw in
221    /// "Failed" sitting in a "Completed"-sized pill. Callers who need the
222    /// lifecycle to hold one width — a badge with layout to its right that
223    /// must not shuffle — ask for it with
224    /// [`reserve_lifecycle_width`](Self::reserve_lifecycle_width).
225    pub fn for_progress(id: impl Into<ElementId>, state: &ProgressState) -> Self {
226        Self::new(id, progress_label(state))
227            .tone(StatusTone::from_progress(state))
228            .active(matches!(state, ProgressState::Running))
229    }
230
231    /// Holds the width of the widest lifecycle label, so
232    /// pending→running→completed/failed never moves what sits beside it.
233    ///
234    /// Only for a badge with layout to its right: a trailing badge grows
235    /// into its own margin and needs no reservation. The label centres in
236    /// the reserved slot rather than hugging its leading edge.
237    pub fn reserve_lifecycle_width(mut self, reserve: bool) -> Self {
238        self.reserve_lifecycle_width = reserve;
239        self
240    }
241
242    /// Sets the badge tone.
243    pub fn tone(mut self, tone: StatusTone) -> Self {
244        self.tone = tone;
245        self
246    }
247
248    /// Shows a spinner instead of the dot while work is in progress.
249    pub fn active(mut self, active: bool) -> Self {
250        self.active = active;
251        self
252    }
253}
254
255impl Styled for StatusBadge {
256    fn style(&mut self) -> &mut StyleRefinement {
257        &mut self.style
258    }
259}
260
261impl RenderOnce for StatusBadge {
262    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
263        let tokens = cx.theme().semantic_tokens();
264        let color = self.tone.color(cx);
265
266        // The swap is staged from keyed state: the first render is not a
267        // transition, and every later change fades the outgoing status away
268        // while the incoming one lifts into the same fixed slot. Semantics
269        // never wait — aria_label and the tone below are the incoming state
270        // from the first frame of the change.
271        let swap =
272            window.use_keyed_state((self.id.clone(), "status-swap"), cx, |_, _| StatusSwap {
273                label: self.label.clone(),
274                tone: self.tone,
275                active: self.active,
276                generation: 0,
277                outgoing: None,
278            });
279        let (generation, outgoing) = swap.update(cx, |swap, _| {
280            if swap.label != self.label || swap.tone != self.tone || swap.active != self.active {
281                // A label or tone change stages the old face; an
282                // active-only flip (spinner to dot on the same status) just
283                // swaps the indicator without staging a ghost label.
284                if swap.label != self.label || swap.tone != self.tone {
285                    swap.outgoing = Some((swap.label.clone(), swap.tone));
286                }
287                swap.generation += 1;
288                swap.label = self.label.clone();
289                swap.tone = self.tone;
290                swap.active = self.active;
291            }
292            (swap.generation, swap.outgoing.clone())
293        });
294        let progress = if generation == 0 {
295            1.0
296        } else {
297            swap_progress(
298                ElementId::NamedInteger(
299                    SharedString::from(format!("{:?}-status-swap", self.id)),
300                    generation,
301                ),
302                window,
303                cx,
304            )
305        };
306        if progress >= 1.0 && outgoing.is_some() {
307            swap.update(cx, |swap, _| swap.outgoing = None);
308        }
309        let exit = tokens.spacing.xxs * progress * crate::motion::travel(cx);
310        let entry = tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx);
311
312        // The indicator slot is fixed — sized for the spinner, centering the
313        // smaller dot — so running↔settled never nudges the label sideways.
314        let indicator_label = self.label.clone();
315        let indicator = div()
316            .debug_selector(move || format!("status-badge-indicator-{indicator_label}"))
317            .flex_none()
318            .size(crate::sizing::slot_xs(cx))
319            .flex()
320            .items_center()
321            .justify_center()
322            .child(if self.active {
323                Spinner::new().xsmall().color(color).into_any_element()
324            } else {
325                div()
326                    .debug_selector({
327                        let dot_label = self.label.clone();
328                        move || format!("status-badge-dot-{dot_label}")
329                    })
330                    .size_1p5()
331                    .rounded(tokens.radius.full)
332                    .bg(color)
333                    .opacity(progress)
334                    .into_any_element()
335            });
336
337        let label_slot = v_flex()
338            .relative()
339            .items_center()
340            .when(self.reserve_lifecycle_width, |slot| {
341                // Zero-height ghosts of every lifecycle label make the slot
342                // as wide as the widest one, in whatever face and rem scale
343                // the theme resolves — so the whole pending→failed journey
344                // fits one width with nothing measured. The pill's
345                // aria_label is the accessible name; these never paint.
346                slot.children(LIFECYCLE_LABELS.iter().map(|label| {
347                    div()
348                        .h(rems(0.))
349                        .overflow_hidden()
350                        .opacity(0.)
351                        .child(*label)
352                }))
353            })
354            .child({
355                let label_name = self.label.clone();
356                div()
357                    .debug_selector(move || format!("status-badge-label-{label_name}"))
358                    .opacity(progress)
359                    .top(entry)
360                    .child(self.label.clone())
361            })
362            .when_some(outgoing, |slot, (label, tone)| {
363                // The outgoing face drifts down as it fades, layered over
364                // the slot so it never contributes width of its own.
365                slot.child(
366                    div()
367                        .absolute()
368                        .left(rems(0.))
369                        .top(exit)
370                        .opacity(1.0 - progress)
371                        .text_color(tone.color(cx))
372                        .child(label),
373                )
374            });
375
376        // The dot rides a slot sized for the spinner that replaces it, so
377        // the slot carries slack the dot does not fill. Left outside that
378        // slack, the chip's own leading padding put visibly more room
379        // before the dot than after the label — the optical asymmetry the
380        // 0.4.0 feel review saw. The leading inset absorbs the slack so
381        // both ends read the same, and the slot still holds the label
382        // still when the spinner takes the dot's place.
383        let slot_slack = (crate::sizing::slot_xs(cx) - INDICATOR_DOT) / 2.;
384        chip_frame(color, ChipStrength::Tinted, cx)
385            .id(self.id)
386            .role(Role::Status)
387            .aria_label(self.label.clone())
388            .flex()
389            .items_center()
390            .pl((tokens.spacing.sm - slot_slack).max(gpui::Pixels::ZERO))
391            .gap(tokens.spacing.xs)
392            .child(indicator)
393            .child(label_slot)
394            .refine_style(&self.style)
395    }
396}
397
398/// Every label [`progress_label`] can produce, for the lifecycle slot's
399/// width reservation. A new lifecycle label joins both or the reservation
400/// silently under-measures.
401const LIFECYCLE_LABELS: [&str; 4] = ["Pending", "Running", "Completed", "Failed"];
402
403/// The settled dot's diameter, which is `size_1p5`. Named because the
404/// badge's leading inset is measured against the slack the dot leaves in
405/// the spinner-sized slot around it.
406const INDICATOR_DOT: gpui::Pixels = gpui::px(6.);
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use gpui::{Context, Entity, Render, TestAppContext, VisualTestContext, px};
412    use gpui_component::h_flex;
413
414    struct BadgeProbe {
415        state: ProgressState,
416    }
417
418    impl Render for BadgeProbe {
419        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
420            div().w(px(320.)).h(px(80.)).child(
421                h_flex()
422                    .debug_selector(|| "badge-hug".into())
423                    .flex_none()
424                    .child(StatusBadge::for_progress("probe-badge", &self.state)),
425            )
426        }
427    }
428
429    fn open(cx: &mut TestAppContext) -> (Entity<BadgeProbe>, &mut VisualTestContext) {
430        cx.update(crate::init);
431        let (probe, cx) = cx.add_window_view(|_, _| BadgeProbe {
432            state: ProgressState::Pending,
433        });
434        cx.update(|window, cx| window.draw(cx).clear(cx));
435        (probe, cx)
436    }
437
438    fn draw(cx: &mut VisualTestContext) {
439        cx.update(|window, cx| window.draw(cx).clear(cx));
440    }
441
442    fn set_state(probe: &Entity<BadgeProbe>, cx: &mut VisualTestContext, state: ProgressState) {
443        probe.update(cx, |probe, cx| {
444            probe.state = state;
445            cx.notify();
446        });
447        draw(cx);
448    }
449
450    fn settle_swap(cx: &mut VisualTestContext) {
451        cx.executor()
452            .advance_clock(crate::motion::MotionTokens::DEFAULT.quick() * 2);
453        draw(cx);
454        draw(cx);
455    }
456
457    #[gpui::test]
458    fn a_status_change_stages_a_swap_and_settles_quiet(cx: &mut TestAppContext) {
459        let (probe, cx) = open(cx);
460        crate::motion::take_reveal_frame_requests();
461
462        set_state(&probe, cx, ProgressState::Complete);
463        assert!(
464            crate::motion::take_reveal_frame_requests() > 0,
465            "a status change must acknowledge itself"
466        );
467
468        settle_swap(cx);
469        crate::motion::take_reveal_frame_requests();
470        draw(cx);
471        assert_eq!(
472            crate::motion::take_reveal_frame_requests(),
473            0,
474            "a settled swap must schedule nothing"
475        );
476    }
477
478    #[gpui::test]
479    fn the_first_render_is_not_a_transition(cx: &mut TestAppContext) {
480        let (_, cx) = open(cx);
481        crate::motion::take_reveal_frame_requests();
482        draw(cx);
483        assert_eq!(
484            crate::motion::take_reveal_frame_requests(),
485            0,
486            "a badge mounts at rest, whatever state it mounts in"
487        );
488    }
489
490    #[gpui::test]
491    fn reduced_motion_swaps_crossfade_and_snap_is_instant(cx: &mut TestAppContext) {
492        // The OS flag resolves to crossfade: the swap's opacity fade runs
493        // at the quick tempo and settles quiet.
494        cx.update(|cx| cx.set_reduce_motion(true));
495        let (probe, cx) = open(cx);
496        crate::motion::take_reveal_frame_requests();
497
498        set_state(&probe, cx, ProgressState::Failed("offline".into()));
499        draw(cx);
500        assert!(
501            crate::motion::take_reveal_frame_requests() > 0,
502            "the crossfade swap runs"
503        );
504        settle_swap(cx);
505        crate::motion::take_reveal_frame_requests();
506        draw(cx);
507        assert_eq!(crate::motion::take_reveal_frame_requests(), 0);
508
509        // The snap preference is the true zero.
510        cx.update(|_, cx| {
511            crate::motion::MotionTokens::default()
512                .with_preference(crate::motion::MotionPreference::Snap)
513                .set(cx)
514        });
515        set_state(&probe, cx, ProgressState::Complete);
516        draw(cx);
517        assert_eq!(
518            crate::motion::take_reveal_frame_requests(),
519            0,
520            "snap resolves the swap instantly"
521        );
522    }
523
524    #[test]
525    fn every_lifecycle_label_is_reserved() {
526        for state in [
527            ProgressState::Pending,
528            ProgressState::Running,
529            ProgressState::Complete,
530            ProgressState::Failed("offline".into()),
531        ] {
532            assert!(
533                LIFECYCLE_LABELS.contains(&progress_label(&state)),
534                "the width reservation must know every label for_progress can show"
535            );
536        }
537    }
538
539    #[test]
540    fn progress_maps_onto_one_tone_and_label_each() {
541        let cases = [
542            (ProgressState::Pending, StatusTone::Neutral, "Pending"),
543            (ProgressState::Running, StatusTone::Info, "Running"),
544            (ProgressState::Complete, StatusTone::Success, "Completed"),
545            (
546                ProgressState::Failed("offline".into()),
547                StatusTone::Danger,
548                "Failed",
549            ),
550        ];
551        for (state, tone, label) in cases {
552            assert_eq!(StatusTone::from_progress(&state), tone);
553            assert_eq!(progress_label(&state), label);
554            let badge = StatusBadge::for_progress("badge", &state);
555            assert_eq!(badge.tone, tone);
556            assert_eq!(badge.label.as_ref(), label);
557            assert_eq!(badge.active, matches!(state, ProgressState::Running));
558        }
559    }
560}