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