Skip to main content

gpui_ai/
queue.rs

1//! Message queue: prompts waiting their turn while the agent is busy.
2//!
3//! Agent harnesses let people keep typing while a turn runs; those prompts
4//! wait in a queue the application owns. [`MessageQueue`] renders the queue
5//! above the composer with position, text, optional note, and controls to
6//! reorder, edit, send now, or remove, reporting each as a [`QueueEvent`]
7//! keyed by stable ID.
8
9use crate::control::ControlMetricsExt as _;
10use crate::{
11    ButtonLabelExt as _,
12    handlers::SharedHandler,
13    motion::{ArrivalRoster, MotionTokens},
14    surface::{eyebrow, icon_button, meta},
15    theme::SemanticStyledExt as _,
16};
17use gpui::{
18    AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
19    ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
20    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
21};
22use gpui_component::{
23    ActiveTheme as _, IconName, Sizable as _, StyledExt as _,
24    button::{Button, ButtonVariants as _},
25    h_flex, v_flex,
26};
27use std::rc::Rc;
28
29/// One prompt waiting in the queue.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct QueuedMessage {
32    id: SharedString,
33    text: SharedString,
34    note: Option<SharedString>,
35}
36
37impl QueuedMessage {
38    /// Creates a queued prompt with a stable identifier.
39    pub fn new(id: impl Into<SharedString>, text: impl Into<SharedString>) -> Self {
40        Self {
41            id: id.into(),
42            text: text.into(),
43            note: None,
44        }
45    }
46
47    /// Adds a short note ("after the current step", "steering").
48    pub fn note(mut self, note: impl Into<SharedString>) -> Self {
49        self.note = Some(note.into());
50        self
51    }
52
53    /// Returns the stable identifier.
54    pub fn id(&self) -> &SharedString {
55        &self.id
56    }
57
58    /// Returns the prompt text.
59    pub fn text(&self) -> &SharedString {
60        &self.text
61    }
62
63    /// Returns the note, if any.
64    pub fn note_text(&self) -> Option<&SharedString> {
65        self.note.as_ref()
66    }
67}
68
69/// An interaction emitted by [`MessageQueue`].
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum QueueEvent {
72    /// Remove one queued prompt.
73    Removed {
74        /// Stable message identifier.
75        id: SharedString,
76    },
77    /// Send one queued prompt immediately, ahead of its turn.
78    SentNow {
79        /// Stable message identifier.
80        id: SharedString,
81    },
82    /// Move one queued prompt one place earlier.
83    MovedUp {
84        /// Stable message identifier.
85        id: SharedString,
86    },
87    /// Move one queued prompt one place later.
88    MovedDown {
89        /// Stable message identifier.
90        id: SharedString,
91    },
92    /// Edit one queued prompt before it is sent.
93    EditRequested {
94        /// Stable message identifier.
95        id: SharedString,
96    },
97    /// Drop every queued prompt.
98    Cleared,
99}
100
101/// The queue of waiting prompts.
102///
103/// # Example
104///
105/// ```no_run
106/// # use gpui_ai::prelude::*;
107/// # fn example(queued: Vec<QueuedMessage>) {
108/// MessageQueue::new("queue")
109///     .items(queued.iter().cloned())
110///     .editable(true)
111///     .on_event(|event, _, _| { /* QueueEvent::SentNow { id } … */ });
112/// # }
113/// ```
114#[derive(IntoElement)]
115pub struct MessageQueue {
116    id: SharedString,
117    style: StyleRefinement,
118    items: Vec<QueuedMessage>,
119    editable: bool,
120    on_event: Option<SharedHandler<QueueEvent>>,
121}
122
123impl MessageQueue {
124    /// Creates an empty queue.
125    pub fn new(id: impl Into<SharedString>) -> Self {
126        Self {
127            id: id.into(),
128            style: StyleRefinement::default(),
129            items: Vec::new(),
130            editable: false,
131            on_event: None,
132        }
133    }
134
135    /// Sets the queued prompts, first to send first.
136    pub fn items(mut self, items: impl IntoIterator<Item = QueuedMessage>) -> Self {
137        self.items = items.into_iter().collect();
138        self
139    }
140
141    /// Offers an edit control per prompt.
142    pub fn editable(mut self, editable: bool) -> Self {
143        self.editable = editable;
144        self
145    }
146
147    /// Handles typed interactions. Without a handler the queue is read-only.
148    pub fn on_event(
149        mut self,
150        handler: impl Fn(&QueueEvent, &mut Window, &mut App) + 'static,
151    ) -> Self {
152        self.on_event = Some(Rc::new(handler));
153        self
154    }
155}
156
157impl Styled for MessageQueue {
158    fn style(&mut self) -> &mut StyleRefinement {
159        &mut self.style
160    }
161}
162
163impl RenderOnce for MessageQueue {
164    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
165        let tokens = cx.theme().semantic_tokens();
166        let root_id = ElementId::from(self.id.clone());
167        let debug_id = self.id.to_string();
168        let handler = self.on_event;
169        let count = self.items.len();
170
171        // Rows the queue has already shown stay at rest; stable IDs queued
172        // onto a mounted surface settle in on the capped cascade, and the
173        // restored initial snapshot joins at rest. Reorders exchange no
174        // motion state — Spike D's measured reorder was reverted because a
175        // layout offset paints one frame at the destination first, so a
176        // moved row simply lands where its controls put it.
177        let motion = MotionTokens::read(cx).clone();
178        let roster = window.use_keyed_state((root_id.clone(), "arrivals"), cx, |_, _| {
179            ArrivalRoster::new()
180        });
181        roster.update(cx, |roster, cx| {
182            roster.note(
183                self.items.iter().map(|item| {
184                    ElementId::Name(SharedString::from(format!("queue-arrive-{}", item.id)))
185                }),
186                true,
187                &motion,
188                cx.background_executor().now(),
189            );
190        });
191
192        if count == 0 {
193            return div().id(root_id).hidden().refine_style(&self.style);
194        }
195
196        let mut rows = Vec::with_capacity(count);
197        for (index, item) in self.items.iter().enumerate() {
198            let arrival = roster.update(cx, |roster, cx| {
199                roster.progress(
200                    &ElementId::Name(SharedString::from(format!("queue-arrive-{}", item.id))),
201                    window,
202                    cx,
203                )
204            });
205            rows.push(render_row(
206                &root_id,
207                &debug_id,
208                index,
209                count,
210                item,
211                self.editable,
212                arrival,
213                handler.clone(),
214                window,
215                cx,
216            ));
217        }
218        let clear = handler.clone().map(|handler| {
219            let clear_debug = debug_id.clone();
220            div()
221                .debug_selector(move || format!("queue-clear-{clear_debug}"))
222                .child(
223                    Button::new((root_id.clone(), "clear"))
224                        .ghost()
225                        .xsmall()
226                        .control_metrics(cx)
227                        .accessibility_id(format!("{debug_id}-clear"))
228                        .text_label("Clear queue")
229                        .on_click(move |_: &ClickEvent, window, cx| {
230                            handler(&QueueEvent::Cleared, window, cx)
231                        }),
232                )
233        });
234        let root_debug = debug_id.clone();
235
236        v_flex()
237            .id(root_id)
238            .role(Role::List)
239            .aria_label(format!("Queued messages, {count} waiting"))
240            .debug_selector(move || format!("queue-{root_debug}"))
241            .w_full()
242            .min_w_0()
243            .gap(tokens.spacing.xs)
244            .p(tokens.spacing.sm)
245            .bg(cx.theme().muted.opacity(0.35))
246            .border_1()
247            .border_color(cx.theme().border)
248            .rounded(tokens.radius.lg)
249            .child(
250                h_flex()
251                    .items_center()
252                    .justify_between()
253                    .gap(tokens.spacing.sm)
254                    .child(eyebrow(format!("Queued · {count}"), cx))
255                    .children(clear),
256            )
257            .children(rows)
258            .refine_style(&self.style)
259    }
260}
261
262#[allow(clippy::too_many_arguments)]
263fn render_row(
264    root_id: &ElementId,
265    queue_debug: &str,
266    index: usize,
267    count: usize,
268    item: &QueuedMessage,
269    editable: bool,
270    arrival: Option<f32>,
271    handler: Option<SharedHandler<QueueEvent>>,
272    window: &mut Window,
273    cx: &mut App,
274) -> AnyElement {
275    let tokens = cx.theme().semantic_tokens();
276    let row_id = ElementId::from((root_id.clone(), format!("item-{}", item.id)));
277    let label: SharedString = format!("Queued {} of {count}: {}", index + 1, item.text).into();
278
279    let mut control = |suffix: &'static str,
280                       icon: IconName,
281                       name: String,
282                       enabled: bool,
283                       event: QueueEvent,
284                       cx: &mut App| {
285        let handler = handler.clone();
286        let row_debug = item.id.clone();
287        icon_button((row_id.clone(), suffix), icon, name, window, cx)
288            .disabled(!enabled || handler.is_none())
289            .debug_selector(move || format!("queue-{suffix}-{queue_debug}-{row_debug}"))
290            .on_click(move |_: &ClickEvent, window, cx| {
291                if let Some(handler) = &handler {
292                    handler(&event, window, cx)
293                }
294            })
295    };
296    let id = item.id.clone();
297    let controls = h_flex()
298        .flex_none()
299        .items_center()
300        .gap(tokens.spacing.xxs)
301        .child(control(
302            "up",
303            IconName::ChevronUp,
304            format!("Move earlier: {}", item.text),
305            index > 0,
306            QueueEvent::MovedUp { id: id.clone() },
307            cx,
308        ))
309        .child(control(
310            "down",
311            IconName::ChevronDown,
312            format!("Move later: {}", item.text),
313            index + 1 < count,
314            QueueEvent::MovedDown { id: id.clone() },
315            cx,
316        ))
317        .when(editable, |this| {
318            this.child(control(
319                "edit",
320                IconName::Replace,
321                format!("Edit: {}", item.text),
322                true,
323                QueueEvent::EditRequested { id: id.clone() },
324                cx,
325            ))
326        })
327        .child(control(
328            "send",
329            IconName::ArrowUp,
330            format!("Send now: {}", item.text),
331            true,
332            QueueEvent::SentNow { id: id.clone() },
333            cx,
334        ))
335        .child(control(
336            "remove",
337            IconName::Close,
338            format!("Remove: {}", item.text),
339            true,
340            QueueEvent::Removed { id: id.clone() },
341            cx,
342        ));
343    let row_debug = item.id.clone();
344    // The position number seats on the text's first line while the
345    // action cluster centers itself against the whole row; the note
346    // truncates like the text above it instead of stretching the row.
347    let row = h_flex()
348        .id(row_id.clone())
349        .role(Role::ListItem)
350        .aria_label(label)
351        .debug_selector(move || format!("queue-item-{queue_debug}-{row_debug}"))
352        .items_start()
353        .w_full()
354        .min_w_0()
355        .gap(tokens.spacing.sm)
356        .px(tokens.spacing.sm)
357        .py(tokens.spacing.xs)
358        .rounded(tokens.radius.sm)
359        // The surface step against the container's muted wash separates
360        // the row on its own; a second border inside the framed list was
361        // the double the audit flagged.
362        .bg(tokens.colors.surface)
363        .child(crate::surface::leading_glyph_slot(
364            crate::sizing::slot_md(cx),
365            meta((index + 1).to_string(), cx),
366        ))
367        .child(
368            div()
369                .flex()
370                .flex_col()
371                .min_w_0()
372                .flex_1()
373                .child(
374                    div()
375                        .min_w_0()
376                        .truncate()
377                        .text_token(tokens.typography.sm)
378                        .text_color(cx.theme().foreground)
379                        .child(item.text.clone()),
380                )
381                .when_some(item.note.clone(), |this, note| {
382                    this.child(crate::surface::hint(note, cx).min_w_0().truncate())
383                }),
384        )
385        .child(controls.self_center());
386    let row = match arrival {
387        Some(progress) => row
388            .opacity(progress)
389            .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx)),
390        None => row,
391    };
392    div().w_full().min_w_0().child(row).into_any_element()
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
399
400    struct QueueProbe {
401        count: usize,
402    }
403
404    impl Render for QueueProbe {
405        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
406            div()
407                .w(px(360.))
408                .h(px(320.))
409                .child(
410                    MessageQueue::new("probe-queue").items(
411                        (0..self.count)
412                            .map(|ix| QueuedMessage::new(format!("q-{ix}"), "Queued prompt")),
413                    ),
414                )
415        }
416    }
417
418    fn draw(cx: &mut VisualTestContext) {
419        cx.update(|window, cx| window.draw(cx).clear(cx));
420    }
421
422    #[gpui::test]
423    fn a_restored_queue_rests_and_a_newly_queued_prompt_settles_in(cx: &mut TestAppContext) {
424        cx.update(crate::init);
425        let (probe, cx) = cx.add_window_view(|_, _| QueueProbe { count: 3 });
426        let cx: &mut VisualTestContext = cx;
427        draw(cx);
428        crate::motion::take_reveal_frame_requests();
429        draw(cx);
430        assert_eq!(
431            crate::motion::take_reveal_frame_requests(),
432            0,
433            "a restored queue joins at rest"
434        );
435
436        probe.update(cx, |probe, cx| {
437            probe.count = 4;
438            cx.notify();
439        });
440        draw(cx);
441        assert!(
442            crate::motion::take_reveal_frame_requests() > 0,
443            "a prompt queued onto a mounted surface must settle in"
444        );
445
446        cx.executor()
447            .advance_clock(std::time::Duration::from_secs(2));
448        draw(cx);
449        crate::motion::take_reveal_frame_requests();
450        probe.update(cx, |_, cx| cx.notify());
451        draw(cx);
452        assert_eq!(
453            crate::motion::take_reveal_frame_requests(),
454            0,
455            "a re-render must not replay an acknowledged arrival"
456        );
457    }
458
459    struct SiblingQueues {
460        counts: [usize; 2],
461    }
462
463    impl Render for SiblingQueues {
464        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
465            v_flex().children(self.counts.iter().enumerate().map(|(owner, count)| {
466                MessageQueue::new(format!("queue-{owner}")).items(
467                    (0..*count).map(|item| QueuedMessage::new(format!("item-{item}"), "Prompt")),
468                )
469            }))
470        }
471    }
472
473    #[gpui::test]
474    fn sibling_queues_do_not_share_arrival_clocks(cx: &mut TestAppContext) {
475        cx.update(crate::init);
476        let (probe, cx) = cx.add_window_view(|_, _| SiblingQueues { counts: [1, 1] });
477        draw(cx);
478        probe.update(cx, |probe, cx| {
479            probe.counts[0] = 2;
480            cx.notify();
481        });
482        draw(cx);
483        cx.executor()
484            .advance_clock(std::time::Duration::from_secs(2));
485        draw(cx);
486        crate::motion::take_reveal_frame_requests();
487
488        probe.update(cx, |probe, cx| {
489            probe.counts[1] = 2;
490            cx.notify();
491        });
492        draw(cx);
493        assert!(
494            crate::motion::take_reveal_frame_requests() > 0,
495            "the second queue's arrival must not inherit the first queue's settled clock"
496        );
497    }
498
499    #[test]
500    fn notes_are_optional() {
501        let item = QueuedMessage::new("a", "Compare prices").note("after the current step");
502        assert_eq!(
503            item.note_text().map(|n| n.as_ref()),
504            Some("after the current step")
505        );
506        assert!(QueuedMessage::new("b", "x").note_text().is_none());
507    }
508}