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 markdown::stabilize_markdown,
25 model::{
26 ChatMessage, ChatRole, ChatState, MessageId, MessageMeta, MessageState, NewMessage,
27 SourceRef,
28 },
29};
30
31#[derive(Clone, Debug)]
33pub struct EmptyState {
34 pub icon: IconName,
36 pub title: SharedString,
38 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#[derive(Clone, Debug, PartialEq)]
54pub enum ChatViewEvent {
55 Retry(MessageId),
57 SourceClicked(SourceRef),
59 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
91pub struct ChatView {
97 state: Entity<ChatState>,
98 texts: Entity<MessageTexts>,
99 scroller: Entity<MessageScrollerState>,
100 empty: EmptyState,
101}
102
103impl ChatView {
104 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 pub fn empty_state(mut self, empty: EmptyState) -> Self {
119 self.empty = empty;
120 self
121 }
122
123 pub fn set_empty_state(&mut self, empty: EmptyState, cx: &mut Context<Self>) {
125 self.empty = empty;
126 cx.notify();
127 }
128
129 pub fn state(&self) -> &Entity<ChatState> {
131 &self.state
132 }
133
134 pub fn len(&self, cx: &App) -> usize {
136 self.state.read(cx).len()
137 }
138
139 pub fn is_empty(&self, cx: &App) -> bool {
141 self.state.read(cx).is_empty()
142 }
143
144 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 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 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 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 pub fn begin_assistant(&mut self, cx: &mut Context<Self>) -> MessageId {
203 self.push_message(NewMessage::assistant("").streaming(), cx)
204 }
205
206 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 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 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 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 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 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 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 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 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 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 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 pub fn retry(&mut self, id: MessageId, cx: &mut Context<Self>) {
356 cx.emit(ChatViewEvent::Retry(id));
357 }
358
359 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 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}