Skip to main content

gpui_ai/
task.rs

1//! Live status rows for progressive agent tasks.
2
3use crate::stream::{ProgressState, Progressive};
4use crate::theme::SemanticStyledExt as _;
5use gpui::{
6    App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
7    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
8    prelude::FluentBuilder as _,
9};
10use gpui_component::{ActiveTheme as _, StyledExt as _, h_flex};
11use std::time::Duration;
12
13/// The typed content rendered by a [`TaskRow`].
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TaskSnapshot {
16    id: SharedString,
17    title: SharedString,
18    detail: Option<SharedString>,
19    elapsed: Option<Duration>,
20}
21
22impl TaskSnapshot {
23    /// Creates a task snapshot with a stable application-level identifier.
24    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
25        Self {
26            id: id.into(),
27            title: title.into(),
28            detail: None,
29            elapsed: None,
30        }
31    }
32
33    /// Adds a muted trailing detail such as a count or target.
34    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
35        self.detail = Some(detail.into());
36        self
37    }
38
39    /// Adds caller-owned elapsed time; the component never owns a clock.
40    pub fn elapsed(mut self, elapsed: Duration) -> Self {
41        self.elapsed = Some(elapsed);
42        self
43    }
44
45    /// Returns the stable task identifier.
46    pub fn id(&self) -> &str {
47        self.id.as_ref()
48    }
49}
50
51/// One row in a list of agent tasks, driven by the shared progressive model.
52///
53/// # Example
54///
55/// ```
56/// # use gpui_ai::prelude::*;
57/// # use std::time::Duration;
58/// let task = Progressive::running(
59///     TaskSnapshot::new("index", "Index repository")
60///         .detail("3,214 files")
61///         .elapsed(Duration::from_secs(12)),
62/// );
63/// TaskRow::new(&task);
64/// ```
65#[derive(IntoElement)]
66pub struct TaskRow {
67    style: StyleRefinement,
68    task: TaskSnapshot,
69    state: ProgressState,
70}
71
72impl TaskRow {
73    /// Creates a row from a progressive task snapshot.
74    pub fn new(task: &Progressive<TaskSnapshot>) -> Self {
75        Self {
76            style: StyleRefinement::default(),
77            task: task.content().clone(),
78            state: task.state().clone(),
79        }
80    }
81}
82
83impl Styled for TaskRow {
84    fn style(&mut self) -> &mut StyleRefinement {
85        &mut self.style
86    }
87}
88
89impl RenderOnce for TaskRow {
90    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
91        let tokens = cx.theme().semantic_tokens();
92        // The shared lifecycle glyph: durable marks settle in once through
93        // its acknowledgment, never on a re-render, and never for the
94        // state the row mounts with.
95        let indicator = crate::status::progress_glyph(
96            &self.state,
97            ElementId::from((ElementId::from(self.task.id.clone()), "task-glyph")),
98            crate::sizing::slot_md(cx),
99            window,
100            cx,
101        )
102        .into_any_element();
103        let failed_reason = match &self.state {
104            ProgressState::Failed(reason) => Some(reason.clone()),
105            _ => None,
106        };
107        let state_label = match &self.state {
108            ProgressState::Pending => "pending",
109            ProgressState::Running => "in progress",
110            ProgressState::Complete => "complete",
111            ProgressState::Failed(_) => "failed",
112        };
113        let accessibility_label = format!("{}, {state_label}", self.task.title);
114        let accessibility_description = failed_reason.clone().or_else(|| self.task.detail.clone());
115
116        h_flex()
117            .id(self.task.id.clone())
118            .role(Role::ListItem)
119            .aria_label(accessibility_label)
120            .when_some(accessibility_description, |this, description| {
121                this.aria_description(description)
122            })
123            .w_full()
124            .items_center()
125            .gap(tokens.spacing.sm)
126            .py(tokens.spacing.xs)
127            .text_token(tokens.typography.sm)
128            // The lifecycle glyph arrives already seated in its slot, so a
129            // state change never nudges the title sideways.
130            .child(indicator)
131            .child(
132                div()
133                    .flex_1()
134                    .truncate()
135                    .text_color(match self.state {
136                        ProgressState::Pending => cx.theme().muted_foreground,
137                        _ => cx.theme().foreground,
138                    })
139                    .child(self.task.title),
140            )
141            .when_some(self.task.elapsed, |this, elapsed| {
142                this.child(
143                    div()
144                        .flex_none()
145                        .text_token(tokens.typography.xs)
146                        .font_family(cx.theme().mono_font_family.clone())
147                        .text_color(cx.theme().muted_foreground)
148                        .child(format_elapsed(elapsed)),
149                )
150            })
151            .when_some(failed_reason.or(self.task.detail), |this, detail| {
152                // The trailing detail shrinks and truncates under pressure
153                // instead of pushing the row wide; the title keeps its room.
154                this.child(
155                    div()
156                        .min_w_0()
157                        .truncate()
158                        .text_token(tokens.typography.xs)
159                        .text_color(match self.state {
160                            ProgressState::Failed(_) => cx.theme().danger,
161                            _ => cx.theme().muted_foreground,
162                        })
163                        .child(detail),
164                )
165            })
166            .refine_style(&self.style)
167    }
168}
169
170fn format_elapsed(elapsed: Duration) -> String {
171    let secs = elapsed.as_secs_f64();
172    if secs < 10.0 {
173        format!("{secs:.1}s")
174    } else if secs < 60.0 {
175        format!("{secs:.0}s")
176    } else {
177        let minutes = (secs / 60.0).floor() as u64;
178        let rest = secs as u64 % 60;
179        format!("{minutes}m {rest:02}s")
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use gpui::{Context, Render, TestAppContext};
187
188    struct LifecycleProbe {
189        state: ProgressState,
190    }
191
192    impl Render for LifecycleProbe {
193        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
194            TaskRow {
195                task: TaskSnapshot::new("task", "Index repository"),
196                state: self.state.clone(),
197                style: StyleRefinement::default(),
198            }
199        }
200    }
201
202    #[gpui::test]
203    fn running_to_complete_acknowledges_the_controlled_change(cx: &mut TestAppContext) {
204        cx.update(crate::init);
205        let (probe, cx) = cx.add_window_view(|_, _| LifecycleProbe {
206            state: ProgressState::Running,
207        });
208        cx.update(|window, cx| window.draw(cx).clear(cx));
209        crate::motion::take_reveal_frame_requests();
210
211        probe.update(cx, |probe, cx| {
212            probe.state = ProgressState::Complete;
213            cx.notify();
214        });
215        cx.update(|window, cx| window.draw(cx).clear(cx));
216        assert!(
217            crate::motion::take_reveal_frame_requests() > 0,
218            "completion is a state change, not the glyph's first mount"
219        );
220    }
221
222    #[test]
223    fn elapsed_formatting() {
224        assert_eq!(format_elapsed(Duration::from_millis(400)), "0.4s");
225        assert_eq!(format_elapsed(Duration::from_secs(12)), "12s");
226        assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
227    }
228
229    #[test]
230    fn row_maps_the_shared_lifecycle() {
231        let task = TaskSnapshot::new("index", "Index repository");
232        for (progress, state) in [
233            (Progressive::pending(task.clone()), ProgressState::Pending),
234            (Progressive::running(task.clone()), ProgressState::Running),
235            (Progressive::complete(task.clone()), ProgressState::Complete),
236            (
237                Progressive::failed(task.clone(), "disk unavailable"),
238                ProgressState::Failed("disk unavailable".into()),
239            ),
240        ] {
241            let row = TaskRow::new(&progress);
242            assert_eq!(row.state, state);
243            assert_eq!(row.task.id(), "index");
244        }
245    }
246}