1use 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#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct QueuedMessage {
32 id: SharedString,
33 text: SharedString,
34 note: Option<SharedString>,
35}
36
37impl QueuedMessage {
38 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 pub fn note(mut self, note: impl Into<SharedString>) -> Self {
49 self.note = Some(note.into());
50 self
51 }
52
53 pub fn id(&self) -> &SharedString {
55 &self.id
56 }
57
58 pub fn text(&self) -> &SharedString {
60 &self.text
61 }
62
63 pub fn note_text(&self) -> Option<&SharedString> {
65 self.note.as_ref()
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum QueueEvent {
72 Removed {
74 id: SharedString,
76 },
77 SentNow {
79 id: SharedString,
81 },
82 MovedUp {
84 id: SharedString,
86 },
87 MovedDown {
89 id: SharedString,
91 },
92 EditRequested {
94 id: SharedString,
96 },
97 Cleared,
99}
100
101#[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 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 pub fn items(mut self, items: impl IntoIterator<Item = QueuedMessage>) -> Self {
137 self.items = items.into_iter().collect();
138 self
139 }
140
141 pub fn editable(mut self, editable: bool) -> Self {
143 self.editable = editable;
144 self
145 }
146
147 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 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 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 .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}