1use std::cell::RefCell;
28use std::collections::HashMap;
29use std::rc::Rc;
30
31use gpui::{
32 AnyElement, App, Global, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
33 div, prelude::FluentBuilder, px,
34};
35use gpui_kit_semantics::{NodeSpec, Role, Semantic};
36use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, Surface, Theme, TypeScale};
37use web_time::Instant;
38
39use crate::content::markdown::{Markdown, MarkdownEvent};
40use crate::controls::button::Button;
41use crate::data::list::{List, ListItem, scroll_to_row};
42use crate::display::avatar::Avatar;
43use crate::display::badge::Tone;
44use crate::display::status::StatusDot;
45use crate::display::timeline::EntryTime;
46use crate::foundation::{Ident, Sizable, StyledExt};
47use crate::motion::keyed;
48use crate::strings::{ActiveStrings, StringKey, Strings};
49
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub enum DeliveryState {
57 Sending,
59 #[default]
61 Sent,
62 Delivered,
64 Read,
66 Failed { reason: SharedString },
68}
69
70impl DeliveryState {
71 pub fn name(&self) -> &'static str {
74 match self {
75 Self::Sending => "sending",
76 Self::Sent => "sent",
77 Self::Delivered => "delivered",
78 Self::Read => "read",
79 Self::Failed { .. } => "failed",
80 }
81 }
82
83 fn label(&self, strings: &Strings) -> SharedString {
84 match self {
85 Self::Sending => strings.text(StringKey::MessageSending),
86 Self::Sent => strings.text(StringKey::MessageSent),
87 Self::Delivered => strings.text(StringKey::MessageDelivered),
88 Self::Read => strings.text(StringKey::MessageRead),
89 Self::Failed { reason } => reason.clone(),
90 }
91 }
92
93 fn tone(&self) -> Tone {
94 match self {
95 Self::Sending => Tone::Neutral,
96 Self::Sent => Tone::Info,
97 Self::Delivered => Tone::Accent,
98 Self::Read => Tone::Success,
99 Self::Failed { .. } => Tone::Danger,
100 }
101 }
102
103 pub fn failed(&self) -> bool {
104 matches!(self, Self::Failed { .. })
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum MessageBody {
111 Text(SharedString),
113 Markdown(SharedString),
116}
117
118impl MessageBody {
119 pub fn source(&self) -> &SharedString {
121 match self {
122 Self::Text(text) | Self::Markdown(text) => text,
123 }
124 }
125}
126
127impl From<&'static str> for MessageBody {
128 fn from(value: &'static str) -> Self {
129 Self::Text(SharedString::new_static(value))
130 }
131}
132
133impl From<String> for MessageBody {
134 fn from(value: String) -> Self {
135 Self::Text(SharedString::from(value))
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Attachment {
143 id: SharedString,
144 name: SharedString,
145 detail: Option<SharedString>,
146}
147
148impl Attachment {
149 pub fn new(id: impl Into<SharedString>, name: impl Into<SharedString>) -> Self {
150 Self {
151 id: id.into(),
152 name: name.into(),
153 detail: None,
154 }
155 }
156
157 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
159 self.detail = Some(detail.into());
160 self
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Reaction {
167 key: SharedString,
168 label: SharedString,
169 count: usize,
170}
171
172impl Reaction {
173 pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>, count: usize) -> Self {
174 Self {
175 key: key.into(),
176 label: label.into(),
177 count,
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Message {
185 id: SharedString,
186 author: Option<SharedString>,
187 time: EntryTime,
188 body: MessageBody,
189 delivery: DeliveryState,
190 streaming: bool,
191 attachments: Vec<Attachment>,
192 reactions: Vec<Reaction>,
193}
194
195impl Message {
196 pub fn new(id: impl Into<SharedString>, body: impl Into<MessageBody>) -> Self {
200 Self {
201 id: id.into(),
202 author: None,
203 time: EntryTime::Unknown,
204 body: body.into(),
205 delivery: DeliveryState::default(),
206 streaming: false,
207 attachments: Vec::new(),
208 reactions: Vec::new(),
209 }
210 }
211
212 pub fn markdown(id: impl Into<SharedString>, source: impl Into<SharedString>) -> Self {
213 Self::new(id, MessageBody::Markdown(source.into()))
214 }
215
216 pub fn author(mut self, author: impl Into<SharedString>) -> Self {
217 self.author = Some(author.into());
218 self
219 }
220
221 pub fn time(mut self, time: impl Into<EntryTime>) -> Self {
223 self.time = time.into();
224 self
225 }
226
227 pub fn delivery(mut self, delivery: DeliveryState) -> Self {
228 self.delivery = delivery;
229 self
230 }
231
232 pub fn failed(self, reason: impl Into<SharedString>) -> Self {
233 self.delivery(DeliveryState::Failed {
234 reason: reason.into(),
235 })
236 }
237
238 pub fn streaming(mut self, streaming: bool) -> Self {
241 self.streaming = streaming;
242 self
243 }
244
245 pub fn attachment(mut self, attachment: Attachment) -> Self {
246 self.attachments.push(attachment);
247 self
248 }
249
250 pub fn reaction(mut self, reaction: Reaction) -> Self {
251 self.reactions.push(reaction);
252 self
253 }
254
255 pub fn id(&self) -> &SharedString {
256 &self.id
257 }
258}
259
260type RetryHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
261type MarkdownHandler = Rc<dyn Fn(SharedString, &MarkdownEvent, &mut Window, &mut App)>;
262
263#[derive(IntoElement)]
265pub struct MessageList {
266 ident: Ident,
267 messages: Vec<Message>,
268 visible_rows: Option<usize>,
269 body_lines: usize,
270 group_consecutive: bool,
271 on_retry: Option<RetryHandler>,
272 on_markdown: Option<MarkdownHandler>,
273}
274
275impl std::fmt::Debug for MessageList {
276 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 formatter
278 .debug_struct("MessageList")
279 .field("ident", &self.ident)
280 .field("messages", &self.messages.len())
281 .field("visible_rows", &self.visible_rows)
282 .field("group_consecutive", &self.group_consecutive)
283 .finish()
284 }
285}
286
287impl MessageList {
288 pub fn new(ident: impl Into<Ident>, messages: impl IntoIterator<Item = Message>) -> Self {
289 Self {
290 ident: ident.into(),
291 messages: messages.into_iter().collect(),
292 visible_rows: None,
293 body_lines: 3,
294 group_consecutive: false,
295 on_retry: None,
296 on_markdown: None,
297 }
298 }
299
300 pub fn visible_rows(mut self, rows: usize) -> Self {
303 self.visible_rows = Some(rows);
304 self
305 }
306
307 pub fn body_lines(mut self, lines: usize) -> Self {
309 self.body_lines = lines.max(1);
310 self
311 }
312
313 pub fn group_consecutive(mut self, group: bool) -> Self {
321 self.group_consecutive = group;
322 self
323 }
324
325 pub fn on_retry(
328 mut self,
329 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
330 ) -> Self {
331 self.on_retry = Some(Rc::new(handler));
332 self
333 }
334
335 pub fn on_markdown(
338 mut self,
339 handler: impl Fn(SharedString, &MarkdownEvent, &mut Window, &mut App) + 'static,
340 ) -> Self {
341 self.on_markdown = Some(Rc::new(handler));
342 self
343 }
344
345 fn row_height(&self, theme: &Theme) -> f32 {
348 theme.space(Space::Sm) * 2.0
349 + theme.typography.caption.line_height
350 + theme.space(Space::Xs) * 2.0
351 + theme.typography.body.line_height * self.body_lines as f32
352 + theme.control.get(ControlSize::Sm).height
353 }
354}
355
356impl RenderOnce for MessageList {
357 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
358 let theme = cx.theme().clone();
359 let ident = self.ident.clone();
360 let count = self.messages.len();
361 let row_height = self.row_height(&theme);
362 let body_lines = self.body_lines;
363 let group = self.group_consecutive;
364 let on_retry = self.on_retry.clone();
365 let on_markdown = self.on_markdown.clone();
366
367 let follow = self.follow(count, cx);
368 if follow.stick {
369 scroll_to_row(&ident, count.saturating_sub(1), cx);
370 }
371 let pending = self.pending(&follow, &theme, cx);
372
373 let messages = Rc::new(self.messages);
374 let seen = follow.cell.clone();
375 let list_ident = ident.clone();
376 let rows = List::new(ident.clone(), count, move |index, window, cx| {
377 if let Some(highest) = seen.borrow_mut().current.as_mut() {
378 *highest = (*highest).max(index);
379 } else {
380 seen.borrow_mut().current = Some(index);
381 }
382 let Some(message) = messages.get(index) else {
383 return ListItem::new("unknown", div());
384 };
385 let continues = group
386 && index > 0
387 && messages
388 .get(index - 1)
389 .is_some_and(|previous| previous.author == message.author);
390 ListItem::new(
391 message.id.clone(),
392 row(
393 &list_ident,
394 message,
395 continues,
396 body_lines,
397 on_retry.as_ref(),
398 on_markdown.as_ref(),
399 window,
400 cx,
401 ),
402 )
403 .text(shown_author(message.author.as_ref()))
404 })
405 .row_height(row_height)
406 .when_some(self.visible_rows, List::visible_rows);
407
408 div()
409 .column()
410 .w_full()
411 .relative()
412 .child(rows)
413 .children(pending)
414 }
415}
416
417#[derive(Debug, Default)]
419struct Following {
420 started: bool,
423 count: usize,
425 current: Option<usize>,
427 previous: Option<usize>,
430 ever: Option<usize>,
432 arrived: usize,
434}
435
436struct Follow {
438 cell: Rc<RefCell<Following>>,
439 stick: bool,
441 below: usize,
443 arrived: usize,
445}
446
447impl std::fmt::Debug for Follow {
448 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 formatter
450 .debug_struct("Follow")
451 .field("stick", &self.stick)
452 .field("below", &self.below)
453 .field("arrived", &self.arrived)
454 .finish()
455 }
456}
457
458impl MessageList {
459 fn follow(&self, count: usize, cx: &mut App) -> Follow {
468 let cell = keyed::slot::<Following>(&self.ident.child("following").semantic_id(), cx);
469 let mut following = cell.borrow_mut();
470 following.previous = following.current.take();
471 if let Some(previous) = following.previous {
472 following.ever = Some(following.ever.map_or(previous, |ever| ever.max(previous)));
473 }
474
475 let reach = |highest: Option<usize>| match highest {
479 Some(highest) => highest + 1,
480 None => self.visible_rows.unwrap_or(count),
481 };
482 let at_bottom = reach(following.previous) >= following.count.max(1);
483
484 let mut stick = false;
485 if !following.started {
486 following.started = true;
487 } else if count > following.count {
488 if at_bottom {
489 stick = true;
490 } else {
491 following.arrived += count - following.count;
492 }
493 }
494 following.count = count;
495
496 let mut below = count.saturating_sub(reach(following.ever));
497 if below == 0 {
498 following.arrived = 0;
499 }
500 let arrived = following.arrived.min(below);
501 if stick {
504 below = 0;
505 }
506 drop(following);
507
508 Follow {
509 cell,
510 stick,
511 below,
512 arrived,
513 }
514 }
515
516 fn pending(&self, follow: &Follow, theme: &Theme, cx: &mut App) -> Option<AnyElement> {
518 if follow.below == 0 {
519 return None;
520 }
521 let counted = if follow.arrived > 0 {
525 follow.arrived
526 } else {
527 follow.below
528 };
529 let strings = cx.strings();
530 let label = match (follow.arrived, counted) {
531 (0, 1) => strings.text(StringKey::MessageMoreOne),
532 (0, more) => strings.format(StringKey::MessageMoreMany, &[&more.to_string()]),
533 (_, 1) => strings.text(StringKey::MessageNewOne),
534 (_, new) => strings.format(StringKey::MessageNewMany, &[&new.to_string()]),
535 };
536 let ident = self.ident.child("pending");
537 let list = self.ident.clone();
538 let last = self.messages.len().saturating_sub(1);
539 let cell = follow.cell.clone();
540
541 Some(
542 div()
543 .absolute()
544 .bottom(px(theme.space(Space::Sm)))
545 .right(px(theme.space(Space::Sm)))
546 .child(
547 Button::new(ident.child("follow"))
548 .label(label.clone())
549 .secondary()
550 .control_size(ControlSize::Sm)
551 .on_click(move |window, cx| {
552 cell.borrow_mut().arrived = 0;
553 scroll_to_row(&list, last, cx);
554 window.refresh();
555 }),
556 )
557 .semantic_in(
558 cx,
559 NodeSpec::new(ident.semantic_id(), Role::Status)
560 .parent(self.ident.semantic_id())
561 .text(label)
562 .value(counted.to_string()),
563 )
564 .into_any_element(),
565 )
566 }
567}
568
569#[derive(Debug, Default)]
571struct Streams(RefCell<HashMap<SharedString, Instant>>);
572
573impl Global for Streams {}
574
575pub fn streaming_since(list: &Ident, message: &str, cx: &App) -> Option<Instant> {
583 cx.try_global::<Streams>()
584 .and_then(|streams| streams.0.borrow().get(&stream_key(list, message)).copied())
585}
586
587fn stream_key(list: &Ident, message: &str) -> SharedString {
588 list.child(message).child("streaming").semantic_id()
589}
590
591fn stream_began(list: &Ident, message: &str, streaming: bool, cx: &mut App) -> Option<Instant> {
593 if !cx.has_global::<Streams>() {
594 cx.set_global(Streams::default());
595 }
596 let key = stream_key(list, message);
597 let streams = cx.global::<Streams>();
598 let mut clocks = streams.0.borrow_mut();
599 if !streaming {
600 clocks.remove(&key);
601 return None;
602 }
603 let now = cx.background_executor().now();
604 Some(*clocks.entry(key).or_insert(now))
605}
606
607fn shown_author(author: Option<&SharedString>) -> SharedString {
608 match author {
612 Some(author) if !author.trim().is_empty() => author.clone(),
613 _ => SharedString::new_static("unknown"),
614 }
615}
616
617#[allow(clippy::too_many_arguments)]
618fn row(
619 list: &Ident,
620 message: &Message,
621 continues: bool,
622 body_lines: usize,
623 on_retry: Option<&RetryHandler>,
624 on_markdown: Option<&MarkdownHandler>,
625 window: &mut Window,
626 cx: &mut App,
627) -> AnyElement {
628 let theme = cx.theme().clone();
629 let ident = list.child(message.id.as_ref());
630 let author = shown_author(message.author.as_ref());
631 let began = stream_began(list, message.id.as_ref(), message.streaming, cx);
632
633 let header = div()
634 .row()
635 .w_full()
636 .gap_token(&theme, Space::Sm)
637 .type_scale(&theme, TypeScale::Caption)
638 .h(px(theme.typography.caption.line_height))
639 .when(!continues, |element| {
642 element
643 .child(Avatar::new(author.clone()).size(theme.typography.caption.line_height))
644 .child(
645 div()
646 .text_color(theme.colors.text)
647 .child(author.clone())
648 .semantic_in(
649 cx,
650 NodeSpec::new(ident.child("author").semantic_id(), Role::Text)
651 .parent(ident.semantic_id())
652 .text(author.clone()),
653 ),
654 )
655 })
656 .child(
657 div()
658 .text_color(if message.time == EntryTime::Unknown {
659 theme.colors.warning
660 } else {
661 theme.colors.text_faint
662 })
663 .child(message.time.shown(cx))
664 .semantic_in(
665 cx,
666 NodeSpec::new(ident.child("time").semantic_id(), Role::Text)
667 .parent(ident.semantic_id())
668 .text(message.time.shown(cx))
669 .value(match &message.time {
670 EntryTime::At(time) => time.clone(),
671 EntryTime::Unknown => SharedString::new_static("time unknown"),
672 }),
673 ),
674 )
675 .children(began.map(|_| streaming_mark(&ident, &theme, cx)));
676
677 let body = body_element(&ident, message, body_lines, on_markdown, &theme, cx);
678
679 let mut footer = div()
680 .row()
681 .w_full()
682 .gap_token(&theme, Space::Sm)
683 .h(px(theme.control.get(ControlSize::Sm).height))
684 .child(delivery_mark(&ident, &message.delivery, &theme, cx));
685
686 for attachment in &message.attachments {
687 footer = footer.child(attachment_chip(&ident, attachment, &theme, cx));
688 }
689 for reaction in &message.reactions {
690 footer = footer.child(reaction_chip(&ident, reaction, &theme, cx));
691 }
692
693 if let (DeliveryState::Failed { .. }, Some(handler)) = (&message.delivery, on_retry) {
697 let id = message.id.clone();
698 let handler = Rc::clone(handler);
699 footer = footer.child(
700 Button::new(ident.child("retry"))
701 .label(cx.strings().text(StringKey::TryAgain))
702 .secondary()
703 .control_size(ControlSize::Xs)
704 .on_click(move |window, cx| handler(id.clone(), window, cx)),
705 );
706 }
707
708 let _ = window;
709 div()
710 .column()
711 .w_full()
712 .gap_token(&theme, Space::Xs)
713 .py_token(&theme, Space::Sm)
714 .child(header)
715 .child(body)
716 .child(footer)
717 .into_any_element()
718}
719
720fn body_element(
721 ident: &Ident,
722 message: &Message,
723 body_lines: usize,
724 on_markdown: Option<&MarkdownHandler>,
725 theme: &Theme,
726 cx: &mut App,
727) -> AnyElement {
728 let height = theme.typography.body.line_height * body_lines as f32;
729 match &message.body {
730 MessageBody::Markdown(source) => {
731 let mut markdown =
732 Markdown::new(ident.child("body"), source.clone()).max_lines(body_lines);
733 if let Some(handler) = on_markdown {
734 let handler = Rc::clone(handler);
735 let id = message.id.clone();
736 markdown = markdown
737 .on_event(move |event, window, cx| handler(id.clone(), event, window, cx));
738 }
739 div()
740 .w_full()
741 .h(px(height))
742 .overflow_hidden()
743 .child(markdown)
744 .into_any_element()
745 }
746 MessageBody::Text(text) => {
747 let lines: Vec<&str> = text.lines().collect();
748 let hidden = lines.len().saturating_sub(body_lines);
749 let shown = lines
750 .iter()
751 .take(body_lines)
752 .copied()
753 .collect::<Vec<_>>()
754 .join("\n");
755 div()
756 .column()
757 .w_full()
758 .h(px(height))
759 .overflow_hidden()
760 .type_scale(theme, TypeScale::Body)
761 .text_color(theme.colors.text)
762 .child(SharedString::from(shown))
763 .children((hidden > 0).then(|| {
764 let label = if hidden == 1 {
765 cx.strings().text(StringKey::MessageShowMoreOne)
766 } else {
767 cx.strings()
768 .format(StringKey::MessageShowMoreMany, &[&hidden.to_string()])
769 };
770 div()
771 .type_scale(theme, TypeScale::Caption)
772 .text_color(theme.colors.text_faint)
773 .child(label.clone())
774 .semantic_in(
775 cx,
776 NodeSpec::new(ident.child("truncated").semantic_id(), Role::Status)
777 .parent(ident.semantic_id())
778 .text(label)
779 .value(hidden.to_string()),
780 )
781 }))
782 .into_any_element()
783 }
784 }
785}
786
787fn streaming_mark(ident: &Ident, theme: &Theme, cx: &mut App) -> AnyElement {
788 let label = cx.strings().text(StringKey::MessageStreaming);
789 div()
790 .row()
791 .gap(px(4.0))
792 .text_color(theme.colors.accent)
793 .child(StatusDot::new(Tone::Accent).busy(ident.child("streaming.mark")))
796 .child(label.clone())
797 .semantic_in(
798 cx,
799 NodeSpec::new(ident.child("streaming").semantic_id(), Role::Status)
800 .parent(ident.semantic_id())
801 .text(label)
802 .value("streaming")
803 .busy(true),
804 )
805 .into_any_element()
806}
807
808fn delivery_mark(ident: &Ident, state: &DeliveryState, theme: &Theme, cx: &mut App) -> AnyElement {
809 let label = state.label(cx.strings());
810 let tone = state.tone();
811 div()
812 .row()
813 .gap(px(4.0))
814 .min_w_0()
815 .type_scale(theme, TypeScale::Caption)
816 .text_color(tone.color(theme))
817 .child(StatusDot::new(tone))
818 .child(div().min_w_0().child(label.clone()))
819 .semantic_in(
820 cx,
821 NodeSpec::new(ident.child("delivery").semantic_id(), Role::Status)
822 .parent(ident.semantic_id())
823 .text(label)
824 .value(state.name())
825 .invalid(state.failed())
826 .busy(matches!(state, DeliveryState::Sending)),
827 )
828 .into_any_element()
829}
830
831fn attachment_chip(
832 ident: &Ident,
833 attachment: &Attachment,
834 theme: &Theme,
835 cx: &mut App,
836) -> AnyElement {
837 let text = match &attachment.detail {
838 Some(detail) => SharedString::from(format!("{} — {detail}", attachment.name)),
839 None => attachment.name.clone(),
840 };
841 div()
842 .px_token(theme, Space::Xs)
843 .radius(theme, Radius::Small)
844 .surface(theme, Surface::Raised)
845 .type_scale(theme, TypeScale::Caption)
846 .text_color(theme.colors.text_muted)
847 .child(text.clone())
848 .semantic_in(
849 cx,
850 NodeSpec::new(
851 ident
852 .child("attachment")
853 .child(attachment.id.as_ref())
854 .semantic_id(),
855 Role::Text,
856 )
857 .parent(ident.semantic_id())
858 .text(text),
859 )
860 .into_any_element()
861}
862
863fn reaction_chip(ident: &Ident, reaction: &Reaction, theme: &Theme, cx: &mut App) -> AnyElement {
864 let text = SharedString::from(format!("{} {}", reaction.label, reaction.count));
865 div()
866 .px_token(theme, Space::Xs)
867 .radius(theme, Radius::Pill)
868 .bg(theme.colors.raised)
869 .type_scale(theme, TypeScale::Caption)
870 .text_color(theme.colors.text_muted)
871 .child(text.clone())
872 .semantic_in(
873 cx,
874 NodeSpec::new(
875 ident
876 .child("reaction")
877 .child(reaction.key.as_ref())
878 .semantic_id(),
879 Role::Status,
880 )
881 .parent(ident.semantic_id())
882 .text(text)
883 .value(reaction.count.to_string()),
884 )
885 .into_any_element()
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 #[test]
893 fn every_delivery_state_has_its_own_name() {
894 let states = [
895 DeliveryState::Sending,
896 DeliveryState::Sent,
897 DeliveryState::Delivered,
898 DeliveryState::Read,
899 DeliveryState::Failed {
900 reason: "The host refused it.".into(),
901 },
902 ];
903 let mut names: Vec<&str> = states.iter().map(DeliveryState::name).collect();
904 names.sort_unstable();
905 names.dedup();
906 assert_eq!(names.len(), states.len());
907 }
908
909 #[test]
910 fn a_failure_states_the_hosts_reason_rather_than_a_word_of_its_own() {
911 let state = DeliveryState::Failed {
912 reason: "The workspace is read only.".into(),
913 };
914 assert_eq!(
919 state.label(&Strings::new()).as_ref(),
920 "The workspace is read only."
921 );
922 assert!(state.failed());
923 }
924
925 #[test]
926 fn an_unnamed_author_is_named_unknown() {
927 assert_eq!(shown_author(None).as_ref(), "unknown");
928 assert_eq!(shown_author(Some(&" ".into())).as_ref(), "unknown");
929 assert_eq!(shown_author(Some(&"Ada".into())).as_ref(), "Ada");
930 }
931}