Skip to main content

ragrig_gpui/
chat_view.rs

1//! The transcript view.
2
3use std::{collections::HashMap, time::Duration};
4
5use gpui_kit::{
6    Animation, AnimationExt as _, AnyElement, App, AppContext as _, ClipboardItem, Context,
7    ElementId, Entity, EventEmitter, Hsla, IntoElement, ParentElement as _, Render, SharedString,
8    Styled as _, WeakEntity, Window,
9    assets::IconName,
10    component::{
11        ActiveTheme as _, Sizable as _, Theme,
12        button::{Button, ButtonVariants as _},
13        h_flex,
14        message_scroller::{MessageScroller, MessageScrollerState},
15        text::{TextView, TextViewState, TextViewStyle},
16        v_flex,
17    },
18    div,
19    prelude::FluentBuilder as _,
20    px,
21};
22
23use crate::{
24    icon::ChatIcon,
25    markdown::stabilize_markdown,
26    model::{
27        ChatMessage, ChatRole, ChatState, MessageId, MessageMeta, MessageState, NewMessage,
28        SourceRef,
29    },
30};
31
32/// The empty-transcript state.
33#[derive(Clone, Debug)]
34pub struct EmptyState {
35    /// Large icon above the title.
36    pub icon: ChatIcon,
37    /// Heading, e.g. `Ask your library`.
38    pub title: SharedString,
39    /// One or two lines explaining what to do.
40    pub description: SharedString,
41}
42
43impl Default for EmptyState {
44    fn default() -> Self {
45        Self {
46            icon: ChatIcon::Named(IconName::Sparkles),
47            title: "Ask your library".into(),
48            description: "Answers cite the documents you attach or index.".into(),
49        }
50    }
51}
52
53/// How the assistant signs its messages.
54#[derive(Clone, Debug)]
55pub struct ChatIdentity {
56    /// Name next to the assistant's mark, e.g. `Chat`.
57    pub name: SharedString,
58    /// Mark shown before the name.
59    pub icon: ChatIcon,
60}
61
62impl ChatIdentity {
63    /// Identity with a name and a mark.
64    pub fn new(name: impl Into<SharedString>, icon: impl Into<ChatIcon>) -> Self {
65        Self {
66            name: name.into(),
67            icon: icon.into(),
68        }
69    }
70}
71
72impl Default for ChatIdentity {
73    fn default() -> Self {
74        Self {
75            name: "Ragrig".into(),
76            icon: ChatIcon::Named(IconName::Bot),
77        }
78    }
79}
80
81/// What the user did inside the transcript.
82#[derive(Clone, Debug, PartialEq)]
83pub enum ChatViewEvent {
84    /// The user asked to retry a failed message.
85    Retry(MessageId),
86    /// The user clicked a source chip.
87    SourceClicked(SourceRef),
88    /// The message body was copied.
89    Copied(MessageId),
90}
91
92#[derive(Default)]
93struct MessageTexts {
94    states: HashMap<MessageId, Entity<TextViewState>>,
95}
96
97impl MessageTexts {
98    fn get(&self, id: MessageId) -> Option<Entity<TextViewState>> {
99        self.states.get(&id).cloned()
100    }
101
102    fn insert(&mut self, id: MessageId, state: Entity<TextViewState>) {
103        self.states.insert(id, state);
104    }
105
106    fn remove(&mut self, id: MessageId) {
107        self.states.remove(&id);
108    }
109
110    fn clear(&mut self) {
111        self.states.clear();
112    }
113
114    #[cfg(test)]
115    fn len(&self) -> usize {
116        self.states.len()
117    }
118}
119
120/// A streamed, Markdown transcript with metadata and source chips.
121///
122/// `ChatView` owns the transcript state and one [`TextViewState`] per message,
123/// so tokens can be appended without rebuilding the whole document. It emits
124/// [`ChatViewEvent`] for actions the host must handle.
125pub struct ChatView {
126    state: Entity<ChatState>,
127    texts: Entity<MessageTexts>,
128    scroller: Entity<MessageScrollerState>,
129    empty: EmptyState,
130    assistant: ChatIdentity,
131}
132
133impl ChatView {
134    /// Create an empty transcript.
135    pub fn new(cx: &mut Context<Self>) -> Self {
136        let state = cx.new(|_| ChatState::default());
137        let texts = cx.new(|_| MessageTexts::default());
138        let scroller = cx.new(|cx| MessageScrollerState::new(0, cx));
139        Self {
140            state,
141            texts,
142            scroller,
143            empty: EmptyState::default(),
144            assistant: ChatIdentity::default(),
145        }
146    }
147
148    /// Replace the empty state.
149    pub fn empty_state(mut self, empty: EmptyState) -> Self {
150        self.empty = empty;
151        self
152    }
153
154    /// Replace the empty state and redraw.
155    pub fn set_empty_state(&mut self, empty: EmptyState, cx: &mut Context<Self>) {
156        self.empty = empty;
157        cx.notify();
158    }
159
160    /// Set how the assistant signs its messages.
161    pub fn assistant(mut self, identity: ChatIdentity) -> Self {
162        self.assistant = identity;
163        self
164    }
165
166    /// Set the assistant identity and redraw.
167    pub fn set_assistant(&mut self, identity: ChatIdentity, cx: &mut Context<Self>) {
168        self.assistant = identity;
169        cx.notify();
170    }
171
172    /// The underlying reducer, for hosts that need read access.
173    pub fn state(&self) -> &Entity<ChatState> {
174        &self.state
175    }
176
177    /// Number of messages.
178    pub fn len(&self, cx: &App) -> usize {
179        self.state.read(cx).len()
180    }
181
182    /// Returns whether the transcript is empty.
183    pub fn is_empty(&self, cx: &App) -> bool {
184        self.state.read(cx).is_empty()
185    }
186
187    /// Append a message and create its render state.
188    pub fn push_message(&mut self, message: NewMessage, cx: &mut Context<Self>) -> MessageId {
189        let text = message.text.clone();
190        let streaming = message.state.is_streaming();
191        let id = self.state.update(cx, |state, _| state.push(message));
192        self.cache_text(id, &text, streaming, cx);
193        self.append_row(cx);
194        cx.notify();
195        id
196    }
197
198    /// Append a user message.
199    pub fn push_user(&mut self, text: impl Into<String>, cx: &mut Context<Self>) -> MessageId {
200        self.push_message(NewMessage::user(text), cx)
201    }
202
203    /// Append a system notice.
204    pub fn push_system(&mut self, text: impl Into<String>, cx: &mut Context<Self>) -> MessageId {
205        self.push_message(NewMessage::system(text), cx)
206    }
207
208    /// Insert a system notice immediately before `before`.
209    ///
210    /// Use this for per-question context notes: the note then appears between
211    /// the question and the assistant placeholder, exactly where the attached
212    /// papers belong. Returns `false` when `before` is unknown.
213    pub fn push_system_before(
214        &mut self,
215        before: MessageId,
216        text: impl Into<String>,
217        cx: &mut Context<Self>,
218    ) -> bool {
219        let text = text.into();
220        let Some(index) = self
221            .state
222            .read(cx)
223            .messages()
224            .iter()
225            .position(|message| message.id() == before)
226        else {
227            return false;
228        };
229        let inserted = self.state.update(cx, |state, _| {
230            state.insert_before(before, NewMessage::system(text.clone()))
231        });
232        let Some(id) = inserted else {
233            return false;
234        };
235        self.cache_text(id, &text, false, cx);
236        let spliced = self
237            .scroller
238            .update(cx, |scroller, cx| scroller.splice(index..index, 1, cx));
239        debug_assert!(spliced, "the transcript and scroller counts diverged");
240        cx.notify();
241        true
242    }
243
244    /// Start an empty streaming assistant message.
245    pub fn begin_assistant(&mut self, cx: &mut Context<Self>) -> MessageId {
246        self.push_message(NewMessage::assistant("").streaming(), cx)
247    }
248
249    /// Append a token batch to a streaming message.
250    pub fn append(&mut self, id: MessageId, delta: &str, cx: &mut Context<Self>) -> bool {
251        if !self.state.update(cx, |state, _| state.append(id, delta)) {
252            return false;
253        }
254        if let Some(text) = self.texts.read(cx).get(id) {
255            text.update(cx, |text, cx| text.push_str(delta, cx));
256        }
257        self.remeasure_last(cx);
258        cx.notify();
259        true
260    }
261
262    /// Finish a streaming message and normalize its final Markdown.
263    pub fn finish(&mut self, id: MessageId, cx: &mut Context<Self>) -> bool {
264        if !self.state.update(cx, |state, _| state.finish(id)) {
265            return false;
266        }
267        self.sync_text(id, cx);
268        self.remeasure_last(cx);
269        cx.notify();
270        true
271    }
272
273    /// Stop a streaming message, keeping the partial body.
274    pub fn stop(&mut self, id: MessageId, cx: &mut Context<Self>) -> bool {
275        if !self.state.update(cx, |state, _| state.stop(id)) {
276            return false;
277        }
278        self.sync_text(id, cx);
279        self.remeasure_last(cx);
280        cx.notify();
281        true
282    }
283
284    /// Mark a message as failed, keeping the partial body.
285    pub fn fail(
286        &mut self,
287        id: MessageId,
288        error: impl Into<String>,
289        cx: &mut Context<Self>,
290    ) -> bool {
291        if !self.state.update(cx, |state, _| state.fail(id, error)) {
292            return false;
293        }
294        self.sync_text(id, cx);
295        self.remeasure_last(cx);
296        cx.notify();
297        true
298    }
299
300    /// Replace a message's body and re-render it as stable Markdown.
301    ///
302    /// The host uses this when it lifts host-specific markup (e.g. a
303    /// Scico replacement block) out of a finished answer before the
304    /// author reads it. Returns whether the message existed.
305    pub fn set_message_text(
306        &mut self,
307        id: MessageId,
308        text: impl Into<String>,
309        cx: &mut Context<Self>,
310    ) -> bool {
311        if !self.state.update(cx, |state, _| state.set_text(id, text)) {
312            return false;
313        }
314        self.sync_text(id, cx);
315        self.remeasure_message(id, cx);
316        cx.notify();
317        true
318    }
319
320    /// Replace a message's footer metadata.
321    pub fn set_meta(&mut self, id: MessageId, meta: MessageMeta, cx: &mut Context<Self>) -> bool {
322        if !self.state.update(cx, |state, _| state.set_meta(id, meta)) {
323            return false;
324        }
325        self.remeasure_message(id, cx);
326        cx.notify();
327        true
328    }
329
330    /// Add one source to a message footer.
331    pub fn add_source(&mut self, id: MessageId, source: SourceRef, cx: &mut Context<Self>) -> bool {
332        if !self
333            .state
334            .update(cx, |state, _| state.add_source(id, source))
335        {
336            return false;
337        }
338        self.remeasure_message(id, cx);
339        cx.notify();
340        true
341    }
342
343    /// Remove every message from the transcript.
344    pub fn clear(&mut self, cx: &mut Context<Self>) {
345        self.state.update(cx, |state, _| state.clear());
346        self.texts.update(cx, |texts, _| texts.clear());
347        self.scroller
348            .update(cx, |scroller, cx| scroller.reset(0, cx));
349        cx.notify();
350    }
351
352    /// Remove one message, e.g. before retrying a failed answer.
353    ///
354    /// Returns whether the message existed.
355    pub fn remove(&mut self, id: MessageId, cx: &mut Context<Self>) -> bool {
356        if !self.state.update(cx, |state, _| state.remove(id)) {
357            return false;
358        }
359        self.texts.update(cx, |texts, _| texts.remove(id));
360        let len = self.state.read(cx).len();
361        self.scroller
362            .update(cx, |scroller, cx| scroller.reset(len, cx));
363        cx.notify();
364        true
365    }
366
367    /// Replace the transcript, for loading a session.
368    pub fn replace(
369        &mut self,
370        messages: impl IntoIterator<Item = NewMessage>,
371        cx: &mut Context<Self>,
372    ) {
373        let ids = self.state.update(cx, |state, _| state.replace(messages));
374        let texts = {
375            let state = self.state.read(cx);
376            ids.iter()
377                .filter_map(|id| {
378                    state.get(*id).map(|message| {
379                        (
380                            *id,
381                            message.text().to_owned(),
382                            message.state().is_streaming(),
383                        )
384                    })
385                })
386                .collect::<Vec<_>>()
387        };
388        self.texts.update(cx, |texts, _| texts.clear());
389        for (id, text, streaming) in texts {
390            self.cache_text(id, &text, streaming, cx);
391        }
392        self.scroller
393            .update(cx, |scroller, cx| scroller.reset(ids.len(), cx));
394        cx.notify();
395    }
396
397    /// Scroll to the newest message and resume tail following.
398    pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
399        self.scroller
400            .update(cx, |scroller, cx| scroller.scroll_to_end(cx));
401    }
402
403    /// Copy a message body to the clipboard.
404    pub fn copy_message(&mut self, id: MessageId, cx: &mut Context<Self>) {
405        let text = self
406            .state
407            .read(cx)
408            .get(id)
409            .map(|message| message.text().to_owned());
410        let Some(text) = text else {
411            return;
412        };
413        cx.write_to_clipboard(ClipboardItem::new_string(text));
414        cx.emit(ChatViewEvent::Copied(id));
415    }
416
417    /// Ask the host to retry a failed message.
418    pub fn retry(&mut self, id: MessageId, cx: &mut Context<Self>) {
419        cx.emit(ChatViewEvent::Retry(id));
420    }
421
422    /// Ask the host to act on a source.
423    pub fn source_clicked(&mut self, source: SourceRef, cx: &mut Context<Self>) {
424        cx.emit(ChatViewEvent::SourceClicked(source));
425    }
426
427    fn cache_text(&mut self, id: MessageId, text: &str, streaming: bool, cx: &mut Context<Self>) {
428        // A streaming body must stay raw: appending after a synthetic closing
429        // fence would put later tokens outside the code block.
430        let display = if streaming {
431            text.to_owned()
432        } else {
433            stabilize_markdown(text).into_owned()
434        };
435        let state = cx.new(|cx| TextViewState::markdown(&display, cx));
436        self.texts.update(cx, |texts, _| texts.insert(id, state));
437    }
438
439    fn sync_text(&mut self, id: MessageId, cx: &mut Context<Self>) {
440        let text = self
441            .state
442            .read(cx)
443            .get(id)
444            .map(|message| message.text().to_owned());
445        let view = self.texts.read(cx).get(id);
446        if let (Some(text), Some(view)) = (text, view) {
447            let display = stabilize_markdown(&text).into_owned();
448            view.update(cx, |view, cx| view.set_text(&display, cx));
449        }
450    }
451
452    fn append_row(&mut self, cx: &mut Context<Self>) {
453        let appended = self
454            .scroller
455            .update(cx, |scroller, cx| scroller.append(1, cx));
456        debug_assert!(appended, "the transcript and scroller counts diverged");
457    }
458
459    fn remeasure_last(&mut self, cx: &mut Context<Self>) {
460        let count = self.state.read(cx).len();
461        if count == 0 {
462            return;
463        }
464        self.scroller.update(cx, |scroller, cx| {
465            scroller.remeasure_items(count - 1..count, cx);
466        });
467    }
468
469    fn remeasure_message(&mut self, id: MessageId, cx: &mut Context<Self>) {
470        let index = self
471            .state
472            .read(cx)
473            .messages()
474            .iter()
475            .position(|message| message.id() == id);
476        if let Some(index) = index {
477            self.scroller.update(cx, |scroller, cx| {
478                scroller.remeasure_items(index..index + 1, cx);
479            });
480        }
481    }
482}
483
484impl Render for ChatView {
485    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
486        let count = self.state.read(cx).len();
487        if count == 0 {
488            return empty_view(&self.empty, cx.theme());
489        }
490
491        let this = cx.entity().downgrade();
492        let view_id = cx.entity_id().as_u64();
493        let state = self.state.clone();
494        let texts = self.texts.clone();
495        let assistant = self.assistant.clone();
496
497        MessageScroller::new(
498            ElementId::View(cx.entity_id()),
499            self.scroller.clone(),
500            move |index, _, cx| {
501                let message = state.read(cx).messages().get(index).cloned();
502                match message {
503                    Some(message) => {
504                        let text = texts.read(cx).get(message.id());
505                        message_row(&message, text, &assistant, &this, view_id, cx)
506                    }
507                    None => div().into_any_element(),
508                }
509            },
510        )
511        .scrollbar(true)
512        .jump_button(true)
513        .with_jump_button_label("Latest")
514        .with_bottom_fade(cx.theme().background)
515        .flex_1()
516        .min_h(px(0.))
517        .into_any_element()
518    }
519}
520
521impl EventEmitter<ChatViewEvent> for ChatView {}
522
523fn empty_view(empty: &EmptyState, theme: &Theme) -> AnyElement {
524    v_flex()
525        .size_full()
526        .flex_1()
527        .items_center()
528        .justify_center()
529        .gap_2()
530        .child(empty.icon.element(Some(px(56.))))
531        .child(
532            div()
533                .text_lg()
534                .font_weight(gpui_kit::FontWeight::SEMIBOLD)
535                .child(empty.title.clone()),
536        )
537        .child(
538            div()
539                .text_sm()
540                .text_color(theme.muted_foreground)
541                .child(empty.description.clone()),
542        )
543        .into_any_element()
544}
545
546fn message_row(
547    message: &ChatMessage,
548    text: Option<Entity<TextViewState>>,
549    assistant: &ChatIdentity,
550    this: &WeakEntity<ChatView>,
551    view_id: u64,
552    cx: &mut App,
553) -> AnyElement {
554    let theme = cx.theme();
555    let style = TextViewStyle {
556        is_dark: theme.is_dark(),
557        ..Default::default()
558    };
559    let body = match text {
560        Some(text) => TextView::new(&text)
561            .style(style.clone())
562            .selectable(true)
563            .into_any_element(),
564        None => TextView::markdown(
565            ElementId::Name(format!("ragrig-message-{view_id}-{}", message.id()).into()),
566            message.text().to_owned(),
567        )
568        .style(style)
569        .selectable(true)
570        .into_any_element(),
571    };
572
573    let streaming = message.state().is_streaming();
574    let error = match message.state() {
575        MessageState::Error { message } => Some(message.as_str()),
576        _ => None,
577    };
578
579    match message.role() {
580        ChatRole::System => h_flex()
581            .w_full()
582            .justify_center()
583            .px_4()
584            .py_2()
585            .child(
586                div()
587                    .text_sm()
588                    .text_color(theme.muted_foreground)
589                    .child(message.text().to_owned()),
590            )
591            .into_any_element(),
592        ChatRole::User => {
593            let card = v_flex()
594                .max_w(px(720.))
595                .min_w(px(0.))
596                .flex_shrink_1()
597                .gap_2()
598                .p_3()
599                .bg(theme.secondary)
600                .rounded(theme.radius)
601                .child(role_header(
602                    &ChatIcon::Named(IconName::CircleUser),
603                    "You",
604                    theme.muted_foreground,
605                    message.id(),
606                    message.state(),
607                    view_id,
608                    theme,
609                ))
610                .child(body)
611                .child(message_footer(message, this, view_id, theme));
612
613            h_flex()
614                .w_full()
615                .justify_end()
616                .px_4()
617                .py_2()
618                .child(card)
619                .into_any_element()
620        }
621        ChatRole::Assistant => {
622            let mut row = v_flex().w_full().gap_2().px_4().py_3().child(role_header(
623                &assistant.icon,
624                assistant.name.clone(),
625                theme.primary,
626                message.id(),
627                message.state(),
628                view_id,
629                theme,
630            ));
631            if message.text().is_empty() && streaming {
632                row = row.child(
633                    div()
634                        .text_sm()
635                        .text_color(theme.muted_foreground)
636                        .child("Thinking…"),
637                );
638            } else {
639                row = row.child(body);
640            }
641            if let Some(error) = error {
642                row = row.child(error_banner(message.id(), error, this, view_id, theme));
643            }
644            row.child(message_footer(message, this, view_id, theme))
645                .into_any_element()
646        }
647    }
648}
649
650fn role_header(
651    icon: &ChatIcon,
652    label: impl Into<SharedString>,
653    color: Hsla,
654    id: MessageId,
655    state: &MessageState,
656    view_id: u64,
657    theme: &Theme,
658) -> AnyElement {
659    let streaming = state.is_streaming();
660    let stopped = matches!(state, MessageState::Stopped);
661    h_flex()
662        .items_center()
663        .gap_2()
664        .child(div().text_color(color).child(icon.element(None)))
665        .child(
666            div()
667                .text_sm()
668                .font_weight(gpui_kit::FontWeight::SEMIBOLD)
669                .child(label.into()),
670        )
671        .when(streaming, |row| {
672            row.child(streaming_dot(view_id, id, theme.primary)).child(
673                div()
674                    .text_xs()
675                    .text_color(theme.muted_foreground)
676                    .child("streaming"),
677            )
678        })
679        .when(stopped, |row| row.child(tag("stopped", theme.warning)))
680        .into_any_element()
681}
682
683fn message_footer(
684    message: &ChatMessage,
685    this: &WeakEntity<ChatView>,
686    view_id: u64,
687    theme: &Theme,
688) -> AnyElement {
689    let mut parts = h_flex().flex_wrap().items_center().gap_2();
690    if let Some(meta) = message.meta() {
691        let summary = meta.summary();
692        if !summary.is_empty() {
693            parts = parts.child(
694                div()
695                    .text_xs()
696                    .text_color(theme.muted_foreground)
697                    .child(summary),
698            );
699        }
700        for (index, source) in meta.sources.iter().enumerate() {
701            parts = parts.child(source_chip(view_id, message.id(), index, source, this));
702        }
703    }
704
705    let mut row = h_flex()
706        .w_full()
707        .justify_between()
708        .items_end()
709        .gap_2()
710        .child(parts);
711    if !message.text().is_empty() {
712        row = row.child(copy_button(view_id, message.id(), this));
713    }
714    row.into_any_element()
715}
716
717fn source_chip(
718    view_id: u64,
719    message: MessageId,
720    index: usize,
721    source: &SourceRef,
722    this: &WeakEntity<ChatView>,
723) -> AnyElement {
724    let mut tooltip = source.title.clone();
725    if let Some(location) = &source.location {
726        tooltip.push_str(&format!("\n{location}"));
727    }
728    if let Some(score) = source.score {
729        tooltip.push_str(&format!("\nscore {score:.2}"));
730    }
731
732    let this = this.clone();
733    let source = source.clone();
734    let id = ElementId::Name(format!("source-{view_id}-{message}-{index}").into());
735    Button::new(id)
736        .label(source.title.clone())
737        .xsmall()
738        .ghost()
739        .tooltip(tooltip)
740        .on_click(move |_, _, cx| {
741            let source = source.clone();
742            let _ = this.update(cx, |view, cx| view.source_clicked(source, cx));
743        })
744        .into_any_element()
745}
746
747fn copy_button(view_id: u64, message: MessageId, this: &WeakEntity<ChatView>) -> AnyElement {
748    let this = this.clone();
749    let id = ElementId::Name(format!("copy-{view_id}-{message}").into());
750    Button::new(id)
751        .icon(IconName::Copy)
752        .xsmall()
753        .ghost()
754        .tooltip("Copy message")
755        .on_click(move |_, _, cx| {
756            let _ = this.update(cx, |view, cx| view.copy_message(message, cx));
757        })
758        .into_any_element()
759}
760
761fn error_banner(
762    message: MessageId,
763    error: &str,
764    this: &WeakEntity<ChatView>,
765    view_id: u64,
766    theme: &Theme,
767) -> AnyElement {
768    let this = this.clone();
769    let id = ElementId::Name(format!("retry-{view_id}-{message}").into());
770    h_flex()
771        .items_center()
772        .gap_2()
773        .p_2()
774        .rounded(px(6.))
775        .bg(theme.danger.opacity(0.08))
776        .border_1()
777        .border_color(theme.danger.opacity(0.25))
778        .child(div().text_color(theme.danger).child(IconName::CircleAlert))
779        .child(div().flex_1().text_sm().child(error.to_owned()))
780        .child(
781            Button::new(id)
782                .label("Retry")
783                .small()
784                .outline()
785                .on_click(move |_, _, cx| {
786                    let _ = this.update(cx, |view, cx| view.retry(message, cx));
787                }),
788        )
789        .into_any_element()
790}
791
792fn streaming_dot(view_id: u64, id: MessageId, color: Hsla) -> AnyElement {
793    let element_id = ElementId::Name(format!("stream-dot-{view_id}-{id}").into());
794    div()
795        .size(px(7.))
796        .rounded(px(9999.))
797        .bg(color)
798        .with_animation(
799            element_id,
800            Animation::new(Duration::from_millis(900)).repeat(),
801            |dot, delta| dot.opacity(0.3 + 0.7 * (1.0 - delta)),
802        )
803        .into_any_element()
804}
805
806fn tag(label: &'static str, color: Hsla) -> AnyElement {
807    div()
808        .px(px(6.))
809        .py(px(1.))
810        .rounded(px(9999.))
811        .bg(color.opacity(0.12))
812        .text_xs()
813        .text_color(color)
814        .child(label)
815        .into_any_element()
816}
817
818#[cfg(test)]
819mod tests {
820    use gpui_kit::TestAppContext;
821
822    use super::*;
823
824    #[gpui_kit::test]
825    fn appending_updates_state_and_text_cache(cx: &mut TestAppContext) {
826        cx.update(crate::init);
827        let view = cx.new(ChatView::new);
828
829        let (user, assistant) = cx.update(|cx| {
830            view.update(cx, |view, cx| {
831                let user = view.push_user("Question", cx);
832                let assistant = view.begin_assistant(cx);
833                assert!(view.append(assistant, "Answer", cx));
834                assert!(view.finish(assistant, cx));
835                (user, assistant)
836            })
837        });
838
839        cx.update(|cx| {
840            let view = view.read(cx);
841            let state = view.state.read(cx);
842            assert_eq!(state.len(), 2);
843            assert_eq!(state.messages()[0].id(), user);
844            assert_eq!(state.messages()[1].id(), assistant);
845            assert_eq!(state.messages()[1].text(), "Answer");
846            assert_eq!(view.texts.read(cx).len(), 2);
847        });
848    }
849
850    #[gpui_kit::test]
851    fn a_context_note_can_be_inserted_before_the_answer(cx: &mut TestAppContext) {
852        cx.update(crate::init);
853        let view = cx.new(ChatView::new);
854
855        cx.update(|cx| {
856            view.update(cx, |view, cx| {
857                view.push_user("Question", cx);
858                let assistant = view.begin_assistant(cx);
859                assert!(view.push_system_before(assistant, "Attached: @Kano2025", cx));
860
861                let state = view.state.read(cx);
862                assert_eq!(state.len(), 3);
863                assert_eq!(
864                    state
865                        .messages()
866                        .iter()
867                        .map(ChatMessage::role)
868                        .collect::<Vec<_>>(),
869                    [ChatRole::User, ChatRole::System, ChatRole::Assistant]
870                );
871                assert_eq!(view.texts.read(cx).len(), 3);
872            });
873        });
874    }
875
876    #[gpui_kit::test]
877    fn copying_puts_the_raw_body_on_the_clipboard(cx: &mut TestAppContext) {
878        cx.update(crate::init);
879        let view = cx.new(ChatView::new);
880        let id = cx.update(|cx| view.update(cx, |view, cx| view.push_user("copy me", cx)));
881
882        cx.update(|cx| view.update(cx, |view, cx| view.copy_message(id, cx)));
883
884        let item = cx.read_from_clipboard().expect("clipboard item");
885        assert_eq!(item.text().as_deref(), Some("copy me"));
886    }
887
888    #[gpui_kit::test]
889    fn every_message_state_draws(cx: &mut TestAppContext) {
890        cx.update(crate::init);
891        let (view, cx) = cx.add_window_view(|_, cx| ChatView::new(cx));
892
893        cx.update(|_, cx| {
894            view.update(cx, |view, cx| {
895                view.push_user("Question", cx);
896                view.push_message(
897                    NewMessage::assistant("## Answer\n\nBody.").meta(MessageMeta {
898                        model: Some("deepseek-chat".to_owned()),
899                        chunks: Some(4),
900                        sources: vec![SourceRef::new("paper", "Paper").location("p. 4")],
901                        tokens: Some(42),
902                        elapsed: Some(Duration::from_millis(1200)),
903                    }),
904                    cx,
905                );
906                let streaming = view.begin_assistant(cx);
907                let _ = view.append(streaming, "Partial", cx);
908                let stopped = view.begin_assistant(cx);
909                let _ = view.append(stopped, "Stopped", cx);
910                let _ = view.stop(stopped, cx);
911                let failed = view.begin_assistant(cx);
912                let _ = view.fail(failed, "network error", cx);
913                view.push_system("A system notice", cx);
914            });
915        });
916
917        cx.run_until_parked();
918        cx.update(|window, cx| {
919            window.draw(cx).clear(cx);
920        });
921    }
922}