1use 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#[derive(Clone, Debug)]
34pub struct EmptyState {
35 pub icon: ChatIcon,
37 pub title: SharedString,
39 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#[derive(Clone, Debug)]
55pub struct ChatIdentity {
56 pub name: SharedString,
58 pub icon: ChatIcon,
60}
61
62impl ChatIdentity {
63 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#[derive(Clone, Debug, PartialEq)]
83pub enum ChatViewEvent {
84 Retry(MessageId),
86 SourceClicked(SourceRef),
88 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
120pub 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 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 pub fn empty_state(mut self, empty: EmptyState) -> Self {
150 self.empty = empty;
151 self
152 }
153
154 pub fn set_empty_state(&mut self, empty: EmptyState, cx: &mut Context<Self>) {
156 self.empty = empty;
157 cx.notify();
158 }
159
160 pub fn assistant(mut self, identity: ChatIdentity) -> Self {
162 self.assistant = identity;
163 self
164 }
165
166 pub fn set_assistant(&mut self, identity: ChatIdentity, cx: &mut Context<Self>) {
168 self.assistant = identity;
169 cx.notify();
170 }
171
172 pub fn state(&self) -> &Entity<ChatState> {
174 &self.state
175 }
176
177 pub fn len(&self, cx: &App) -> usize {
179 self.state.read(cx).len()
180 }
181
182 pub fn is_empty(&self, cx: &App) -> bool {
184 self.state.read(cx).is_empty()
185 }
186
187 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 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 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 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 pub fn begin_assistant(&mut self, cx: &mut Context<Self>) -> MessageId {
246 self.push_message(NewMessage::assistant("").streaming(), cx)
247 }
248
249 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 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 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 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 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 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 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 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 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 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 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 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 pub fn retry(&mut self, id: MessageId, cx: &mut Context<Self>) {
419 cx.emit(ChatViewEvent::Retry(id));
420 }
421
422 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 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}