1mod render;
4#[cfg(test)]
5mod tests;
6mod transcript;
7
8use gpui_base::StyledExt as _;
9use render::{chat_frame, jump_to_latest_button, transcript_frame};
10use transcript::{message_ids_are_unique, structural_splice};
11
12use crate::control::ControlMetricsExt as _;
13use crate::scrolling::PolicyScrollbarExt as _;
14use crate::{
15 attachment::{Attachment, AttachmentEvent, AttachmentStrip},
16 control::{outlined_control, outlined_control_with_label},
17 cues::{self, Cue},
18 motion::{MotionTokens, reveal},
19 orbs::Orbs,
20 prompt_bar::{PromptBar, PromptBarEvent},
21 resolved_layout::ResolvedLayoutKey,
22 scrolling::list_scroll_mask,
23 stream::{ProgressState, StreamedContent},
24 streaming_text::{CitationRef, FollowUp, StreamingText, StreamingTextEvent},
25 suggestions::{Suggestion, Suggestions, SuggestionsEvent},
26 surface::{icon_button, meta},
27 theme::SemanticStyledExt as _,
28};
29use gpui::{
30 AnyElement, App, AppContext as _, ClipboardItem, Context, ElementId, Entity, EventEmitter,
31 FocusHandle, Focusable as _, FollowMode, FontWeight, InteractiveElement as _, IntoElement as _,
32 ListAlignment, ListOffset, ListState, ParentElement as _, Pixels, Render, Role, SharedString,
33 Stateful, StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, div, list,
34 prelude::FluentBuilder as _, px, relative,
35};
36use gpui_base::{
37 Button,
38 motion::{Transition, transition},
39};
40use gpui_component::{
41 ActiveTheme as _, IconName, Sizable as _,
42 button::{Button as LabeledButton, ButtonVariants as _},
43 h_flex,
44 input::{Escape, InputEvent, Textarea, TextareaState},
45 text::TextView,
46 v_flex,
47};
48use std::{
49 collections::{HashMap, HashSet},
50 ops::Range,
51 sync::Arc,
52 time::Duration,
53};
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct BranchPosition {
58 index: usize,
59 count: usize,
60}
61
62impl BranchPosition {
63 pub fn new(index: usize, count: usize) -> Self {
66 let count = count.max(1);
67 Self {
68 index: index.min(count - 1),
69 count,
70 }
71 }
72
73 pub fn index(self) -> usize {
75 self.index
76 }
77
78 pub fn count(self) -> usize {
80 self.count
81 }
82
83 pub fn label(self) -> String {
85 format!("Version {} of {}", self.index + 1, self.count)
86 }
87}
88
89struct EditSession {
91 message_id: SharedString,
92 editor: Entity<TextareaState>,
93 _subscription: Subscription,
94}
95
96const COPIED_FEEDBACK: Duration = Duration::from_secs(2);
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum ChatRole {
102 User,
104 Assistant,
106 System,
108 Tool,
110}
111
112impl ChatRole {
113 fn label(self) -> &'static str {
114 match self {
115 Self::User => "User",
116 Self::Assistant => "Assistant",
117 Self::System => "System",
118 Self::Tool => "Tool",
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ChatMessage {
130 id: SharedString,
131 role: ChatRole,
132 author: Option<SharedString>,
133 content: StreamedContent,
134 citations: Vec<CitationRef>,
135 sources: Vec<SharedString>,
136 follow_ups: Vec<FollowUp>,
137 attachments: Vec<Attachment>,
138 branch: Option<BranchPosition>,
139 retryable: bool,
140 appearance: ChatMessageAppearance,
141 actions: Option<MessageActions>,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct ChatMessageAppearance {
151 alignment: MessageAlignment,
152 bubble: MessageBubble,
153}
154
155impl Default for ChatMessageAppearance {
156 fn default() -> Self {
157 Self {
158 alignment: MessageAlignment::Leading,
159 bubble: MessageBubble::Bordered,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum MessageAlignment {
167 Leading,
169 Trailing,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum MessageBubble {
176 Bordered,
178 Filled,
180 Plain,
182}
183
184impl ChatMessageAppearance {
185 pub fn new(alignment: MessageAlignment, bubble: MessageBubble) -> Self {
187 Self { alignment, bubble }
188 }
189
190 pub fn alignment(&self) -> MessageAlignment {
192 self.alignment
193 }
194
195 pub fn bubble(&self) -> MessageBubble {
197 self.bubble
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
208pub struct MessageActions {
209 copy: bool,
210 regenerate: bool,
211 edit: bool,
212 feedback: bool,
213}
214
215impl MessageActions {
216 pub fn none() -> Self {
218 Self::default()
219 }
220
221 pub fn for_role(role: ChatRole) -> Self {
225 match role {
226 ChatRole::Assistant => Self::none().copy(true).regenerate(true).feedback(true),
227 ChatRole::User => Self::none().copy(true).edit(true),
228 ChatRole::Tool => Self::none().copy(true),
229 ChatRole::System => Self::none(),
230 }
231 }
232
233 pub fn copy(mut self, enabled: bool) -> Self {
235 self.copy = enabled;
236 self
237 }
238
239 pub fn regenerate(mut self, enabled: bool) -> Self {
241 self.regenerate = enabled;
242 self
243 }
244
245 pub fn edit(mut self, enabled: bool) -> Self {
247 self.edit = enabled;
248 self
249 }
250
251 pub fn feedback(mut self, enabled: bool) -> Self {
253 self.feedback = enabled;
254 self
255 }
256
257 pub fn is_empty(&self) -> bool {
259 !(self.copy || self.regenerate || self.edit || self.feedback)
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct ChatWelcome {
267 title: SharedString,
268 description: Option<SharedString>,
269 suggestions: Vec<Suggestion>,
270}
271
272impl ChatWelcome {
273 pub fn new(title: impl Into<SharedString>) -> Self {
275 Self {
276 title: title.into(),
277 description: None,
278 suggestions: Vec::new(),
279 }
280 }
281
282 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
284 self.description = Some(description.into());
285 self
286 }
287
288 pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = Suggestion>) -> Self {
290 self.suggestions = suggestions.into_iter().collect();
291 self
292 }
293}
294
295impl ChatMessage {
296 pub fn new(id: impl Into<SharedString>, role: ChatRole, content: StreamedContent) -> Self {
298 Self {
299 id: id.into(),
300 role,
301 author: None,
302 content,
303 citations: Vec::new(),
304 sources: Vec::new(),
305 follow_ups: Vec::new(),
306 attachments: Vec::new(),
307 branch: None,
308 retryable: false,
309 appearance: ChatMessageAppearance::default(),
310 actions: None,
311 }
312 }
313
314 pub fn actions(mut self, actions: MessageActions) -> Self {
316 self.actions = Some(actions);
317 self
318 }
319
320 pub fn message_actions(&self) -> MessageActions {
322 self.actions
323 .unwrap_or_else(|| MessageActions::for_role(self.role))
324 }
325
326 pub fn with_appearance(mut self, appearance: ChatMessageAppearance) -> Self {
328 self.appearance = appearance;
329 self
330 }
331
332 pub fn appearance(&self) -> &ChatMessageAppearance {
334 &self.appearance
335 }
336
337 pub fn author(mut self, author: impl Into<SharedString>) -> Self {
339 self.author = Some(author.into());
340 self
341 }
342
343 pub fn citations(mut self, citations: impl IntoIterator<Item = CitationRef>) -> Self {
345 self.citations = citations.into_iter().collect();
346 self
347 }
348
349 pub fn sources(mut self, sources: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
351 self.sources = sources.into_iter().map(Into::into).collect();
352 self
353 }
354
355 pub fn follow_ups(mut self, follow_ups: impl IntoIterator<Item = FollowUp>) -> Self {
357 self.follow_ups = follow_ups.into_iter().collect();
358 self
359 }
360
361 pub fn attachments(mut self, attachments: impl IntoIterator<Item = Attachment>) -> Self {
364 self.attachments = attachments.into_iter().collect();
365 self
366 }
367
368 pub fn branch(mut self, position: BranchPosition) -> Self {
371 self.branch = Some(position);
372 self
373 }
374
375 pub fn retryable(mut self, retryable: bool) -> Self {
377 self.retryable = retryable;
378 self
379 }
380
381 pub fn id(&self) -> &SharedString {
383 &self.id
384 }
385
386 pub fn role(&self) -> ChatRole {
388 self.role
389 }
390
391 pub fn author_name(&self) -> Option<&SharedString> {
393 self.author.as_ref()
394 }
395
396 pub fn content(&self) -> &StreamedContent {
398 &self.content
399 }
400
401 pub fn citation_refs(&self) -> &[CitationRef] {
403 &self.citations
404 }
405
406 pub fn source_labels(&self) -> &[SharedString] {
408 &self.sources
409 }
410
411 pub fn follow_up_suggestions(&self) -> &[FollowUp] {
413 &self.follow_ups
414 }
415
416 pub fn attachment_refs(&self) -> &[Attachment] {
418 &self.attachments
419 }
420
421 pub fn branch_position(&self) -> Option<BranchPosition> {
423 self.branch
424 }
425
426 pub fn is_retryable(&self) -> bool {
428 self.retryable
429 }
430
431 fn accessibility_label(&self) -> SharedString {
432 match &self.author {
433 Some(author) => format!("{author}, {} message", self.role.label()).into(),
434 None => format!("{} message", self.role.label()).into(),
435 }
436 }
437
438 fn state_description(&self) -> SharedString {
439 match self.content.state() {
440 ProgressState::Pending => "Pending".into(),
441 ProgressState::Running => "Streaming".into(),
442 ProgressState::Complete => "Complete".into(),
443 ProgressState::Failed(reason) => format!("Failed: {reason}").into(),
444 }
445 }
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
450pub enum ChatEvent {
451 Prompt(PromptBarEvent),
453 RetryRequested {
455 message_id: SharedString,
457 },
458 FollowUpSelected {
460 message_id: SharedString,
462 follow_up_id: SharedString,
464 },
465 SourceActivated {
467 message_id: SharedString,
469 source_id: SharedString,
471 url: SharedString,
473 },
474 AttachmentActivated {
476 message_id: SharedString,
478 attachment_id: SharedString,
480 },
481 CitationActivated {
483 message_id: SharedString,
485 citation_id: SharedString,
487 destination: SharedString,
489 },
490 MessageCopied {
492 message_id: SharedString,
494 },
495 RegenerateRequested {
497 message_id: SharedString,
499 },
500 EditRequested {
502 message_id: SharedString,
504 },
505 EditSubmitted {
508 message_id: SharedString,
510 text: SharedString,
512 },
513 EditCancelled {
515 message_id: SharedString,
517 },
518 BranchSelected {
520 message_id: SharedString,
522 index: usize,
524 },
525 FeedbackSubmitted {
527 message_id: SharedString,
529 positive: bool,
531 },
532 SuggestionSelected {
534 suggestion_id: SharedString,
536 },
537 JumpedToLatest,
539}
540
541pub struct Chat {
573 style: gpui::StyleRefinement,
580 id: SharedString,
581 prompt_bar: Entity<PromptBar>,
582 messages: Arc<[ChatMessage]>,
583 message_focus_handles: HashMap<SharedString, FocusHandle>,
584 list_state: ListState,
585 resolved_layout: ResolvedLayoutKey,
587 visible_range: Range<usize>,
588 pinned_to_bottom: bool,
589 unread_message_ids: HashSet<SharedString>,
590 arrivals: HashSet<SharedString>,
593 welcome: Option<ChatWelcome>,
594 copied_message: Option<SharedString>,
595 copied_reset: Option<Task<()>>,
596 jump_drive: Option<JumpDrive>,
599 jump_generation: u64,
600 feedback: HashMap<SharedString, bool>,
601 editing: Option<EditSession>,
602 _prompt_subscription: Subscription,
603}
604
605#[derive(Clone, Copy)]
606struct JumpDrive {
607 generation: u64,
608 distance: Pixels,
609 primed: bool,
610 progress: f32,
611}
612
613impl Chat {
614 pub fn new(
616 id: impl Into<SharedString>,
617 prompt_bar: Entity<PromptBar>,
618 window: &mut Window,
619 cx: &mut Context<Self>,
620 ) -> Self {
621 let list_state = ListState::new(0, ListAlignment::Bottom, px(256.));
622 list_state.set_follow_mode(FollowMode::Tail);
623 let weak_chat = cx.weak_entity();
624 list_state.set_scroll_handler(move |event, _, cx| {
625 weak_chat
626 .update(cx, |chat, cx| {
627 chat.visible_range = event.visible_range.clone();
628 chat.pinned_to_bottom = event.is_following_tail;
629 if event.is_following_tail {
630 chat.unread_message_ids.clear();
631 }
632 cx.notify();
633 })
634 .ok();
635 });
636 let prompt_subscription = cx.subscribe_in(
637 &prompt_bar,
638 window,
639 |_, _, event: &PromptBarEvent, _, cx| {
640 cx.emit(ChatEvent::Prompt(event.clone()));
641 },
642 );
643
644 Self {
645 style: gpui::StyleRefinement::default(),
646 id: id.into(),
647 prompt_bar,
648 messages: Arc::from([]),
649 message_focus_handles: HashMap::new(),
650 list_state,
651 resolved_layout: ResolvedLayoutKey::default(),
652 visible_range: 0..0,
653 pinned_to_bottom: true,
654 unread_message_ids: HashSet::new(),
655 arrivals: HashSet::new(),
656 welcome: None,
657 copied_message: None,
658 copied_reset: None,
659 jump_drive: None,
660 jump_generation: 0,
661 feedback: HashMap::new(),
662 editing: None,
663 _prompt_subscription: prompt_subscription,
664 }
665 }
666
667 pub fn set_welcome(&mut self, welcome: Option<ChatWelcome>, cx: &mut Context<Self>) {
669 if self.welcome != welcome {
670 self.welcome = welcome;
671 cx.notify();
672 }
673 }
674
675 pub fn set_messages(
683 &mut self,
684 messages: Arc<[ChatMessage]>,
685 window: &mut Window,
686 cx: &mut Context<Self>,
687 ) {
688 if Arc::ptr_eq(&self.messages, &messages) || self.messages.as_ref() == messages.as_ref() {
689 return;
690 }
691 if !message_ids_are_unique(&messages) {
692 return;
693 }
694
695 let was_following = self.list_state.is_following_tail() && self.pinned_to_bottom;
696 let was_empty = self.messages.is_empty();
697 let old_offset = self.list_state.logical_scroll_top();
698 let old_anchor = self
699 .messages
700 .get(old_offset.item_ix)
701 .map(|message| (message.id.clone(), old_offset.offset_in_item));
702 let old_by_id: HashMap<_, _> = self
703 .messages
704 .iter()
705 .map(|message| (message.id.clone(), message))
706 .collect();
707 let last_retained_index = messages
708 .iter()
709 .rposition(|message| old_by_id.contains_key(&message.id));
710 let is_replacement = !was_empty && !messages.is_empty() && last_retained_index.is_none();
715 let appended_ids = if is_replacement {
716 Vec::new()
717 } else {
718 messages
719 .iter()
720 .skip(last_retained_index.map_or(0, |index| index + 1))
721 .filter(|message| !old_by_id.contains_key(&message.id))
722 .map(|message| message.id.clone())
723 .collect::<Vec<_>>()
724 };
725 let arrived = if was_empty {
728 Vec::new()
729 } else {
730 appended_ids.clone()
731 };
732 let settled = messages
733 .iter()
734 .filter(|message| {
735 old_by_id
736 .get(&message.id)
737 .is_some_and(|old| old.content.is_streaming())
738 && matches!(
739 message.content.state(),
740 ProgressState::Complete | ProgressState::Failed(_)
741 )
742 })
743 .map(|message| {
744 (
745 message.id.clone(),
746 *message.content.state() == ProgressState::Complete,
747 )
748 })
749 .collect::<Vec<_>>();
750 let (old_range, new_count) = structural_splice(&self.messages, &messages);
751 let structural_new_range = old_range.start..old_range.start + new_count;
752 let mut next_focus_handles = HashMap::with_capacity(messages.len());
753 for message in messages.iter() {
754 let focus_handle = self
755 .message_focus_handles
756 .remove(&message.id)
757 .unwrap_or_else(|| cx.focus_handle());
758 next_focus_handles.insert(message.id.clone(), focus_handle);
759 }
760
761 if !old_range.is_empty() || new_count > 0 {
762 let inserted_focus_handles = messages[structural_new_range.clone()]
763 .iter()
764 .map(|message| next_focus_handles.get(&message.id).cloned());
765 self.list_state
766 .splice_focusable(old_range, inserted_focus_handles);
767 }
768 self.message_focus_handles = next_focus_handles;
769
770 let changed = messages
771 .iter()
772 .enumerate()
773 .filter(|(ix, message)| {
774 !structural_new_range.contains(ix)
775 && old_by_id
776 .get(&message.id)
777 .is_some_and(|old| *old != *message)
778 })
779 .map(|(ix, _)| ix)
780 .collect::<Vec<_>>();
781 for ix in changed {
782 self.list_state.remeasure_items(ix..ix + 1);
783 }
784
785 let new_ids = messages
786 .iter()
787 .map(|message| message.id.clone())
788 .collect::<HashSet<_>>();
789 self.unread_message_ids
790 .retain(|message_id| new_ids.contains(message_id));
791 self.feedback
795 .retain(|message_id, _| new_ids.contains(message_id));
796 if self
797 .copied_message
798 .as_ref()
799 .is_some_and(|message_id| !new_ids.contains(message_id))
800 {
801 self.copied_message = None;
802 self.copied_reset = None;
803 }
804 let stale_editor = self
805 .editing
806 .as_ref()
807 .filter(|session| !new_ids.contains(&session.message_id))
808 .map(|session| session.editor.clone());
809 if let Some(editor) = stale_editor {
810 let editor_had_focus = editor.read(cx).focus_handle(cx).is_focused(window);
815 self.editing = None;
816 if editor_had_focus {
817 let prompt_focus = self.prompt_bar.read(cx).focus_handle(cx);
818 window.focus(&prompt_focus, cx);
819 }
820 }
821 self.messages = messages;
822 if self.messages.is_empty() || is_replacement {
823 self.jump_drive = None;
824 }
825 if self.messages.is_empty() {
826 self.list_state.set_follow_mode(FollowMode::Tail);
827 self.list_state.scroll_to_end();
828 self.unread_message_ids.clear();
829 self.pinned_to_bottom = true;
830 } else if was_following || is_replacement {
831 if is_replacement {
834 self.list_state.set_follow_mode(FollowMode::Tail);
835 }
836 self.list_state.scroll_to_end();
837 self.unread_message_ids.clear();
838 self.pinned_to_bottom = true;
839 } else {
840 if let Some((anchor_id, offset_in_item)) = old_anchor
841 && let Some(item_ix) = self
842 .messages
843 .iter()
844 .position(|message| message.id == anchor_id)
845 {
846 self.list_state.scroll_to(ListOffset {
847 item_ix,
848 offset_in_item,
849 });
850 }
851 self.unread_message_ids.extend(appended_ids);
852 }
853 self.arrivals
854 .retain(|message_id| new_ids.contains(message_id));
855 self.arrivals.extend(arrived.iter().cloned());
856 for message_id in arrived {
857 cues::emit(cx, Cue::MessageArrived { message_id });
858 }
859 for (message_id, succeeded) in settled {
860 cues::emit(
861 cx,
862 Cue::ResponseSettled {
863 message_id,
864 succeeded,
865 },
866 );
867 }
868 cx.notify();
869 }
870
871 pub fn messages(&self) -> &Arc<[ChatMessage]> {
873 &self.messages
874 }
875
876 pub fn prompt_bar(&self) -> &Entity<PromptBar> {
878 &self.prompt_bar
879 }
880
881 pub fn unread_count(&self) -> usize {
883 self.unread_message_ids.len()
884 }
885
886 pub fn is_pinned_to_bottom(&self) -> bool {
888 self.list_state.is_following_tail() && self.pinned_to_bottom
889 }
890
891 pub fn visible_range(&self) -> Range<usize> {
893 self.visible_range.clone()
894 }
895
896 pub fn scroll_to_latest(&mut self, cx: &mut Context<Self>) {
898 self.unread_message_ids.clear();
899 cx.emit(ChatEvent::JumpedToLatest);
900
901 let viewport = self.list_state.viewport_bounds().size.height;
905 if !crate::motion::motion_is_full(cx)
906 || viewport <= gpui::px(0.)
907 || self.messages.is_empty()
908 {
909 self.jump_drive = None;
910 self.list_state.set_follow_mode(FollowMode::Tail);
911 self.list_state.scroll_to_end();
912 self.pinned_to_bottom = true;
913 cx.notify();
914 return;
915 }
916
917 let current = self.list_state.scroll_px_offset_for_scrollbar().y;
921 let max = self.list_state.max_offset_for_scrollbar().y;
922 if max + current <= gpui::px(1.) {
923 self.jump_drive = None;
925 self.list_state.set_follow_mode(FollowMode::Tail);
926 self.list_state.scroll_to_end();
927 self.pinned_to_bottom = true;
928 cx.notify();
929 return;
930 }
931
932 let distance = if max + current > viewport * 2.0 {
937 self.list_state
941 .set_offset_from_scrollbar(gpui::point(Pixels::ZERO, -max + viewport * 0.9));
942 viewport * 0.9
943 } else {
944 max + current
945 };
946 self.jump_generation = self.jump_generation.wrapping_add(1);
947 self.jump_drive = Some(JumpDrive {
948 generation: self.jump_generation,
949 distance,
950 primed: false,
951 progress: 0.0,
952 });
953 self.pinned_to_bottom = false;
954 cx.notify();
955 }
956
957 fn cancel_jump(&mut self, cx: &mut Context<Self>) {
958 if self.jump_drive.take().is_some() {
959 cx.notify();
960 }
961 }
962
963 fn render_jump(&self, window: &mut Window, cx: &mut Context<Self>) {
964 let Some(drive) = self.jump_drive else { return };
965 let progress = transition(
969 ElementId::Name(format!("{}-jump-{}", self.id, drive.generation).into()),
970 if drive.primed || !crate::motion::motion_is_full(cx) {
971 1.0_f32
972 } else {
973 0.0
974 },
975 Transition::new(MotionTokens::read(cx).standard())
976 .ease(crate::motion::ease_in_out_quart),
977 window,
978 cx,
979 );
980 cx.defer_in(window, move |chat, _, cx| {
981 if !chat
982 .jump_drive
983 .is_some_and(|active| active.generation == drive.generation)
984 {
985 return;
986 }
987 if progress >= 1.0 || !crate::motion::motion_is_full(cx) {
988 chat.jump_drive = None;
989 chat.list_state.set_follow_mode(FollowMode::Tail);
990 chat.list_state.scroll_to_end();
991 chat.pinned_to_bottom = true;
992 cx.notify();
993 } else if !drive.primed {
994 chat.jump_drive = Some(JumpDrive {
995 primed: true,
996 ..drive
997 });
998 cx.notify();
999 } else if progress > drive.progress {
1000 chat.jump_drive = Some(JumpDrive { progress, ..drive });
1001 let remaining = chat.list_state.max_offset_for_scrollbar().y
1002 + chat.list_state.scroll_px_offset_for_scrollbar().y;
1003 let step = remaining - drive.distance * (1.0 - progress);
1004 if step > Pixels::ZERO {
1005 chat.list_state.scroll_by(step);
1006 cx.notify();
1007 }
1008 }
1009 });
1010 }
1011
1012 pub fn begin_edit(
1020 &mut self,
1021 message_id: impl Into<SharedString>,
1022 window: &mut Window,
1023 cx: &mut Context<Self>,
1024 ) {
1025 let message_id = message_id.into();
1026 let Some(message) = self
1027 .messages
1028 .iter()
1029 .find(|message| message.id == message_id)
1030 else {
1031 return;
1032 };
1033 if self
1034 .editing
1035 .as_ref()
1036 .is_some_and(|session| session.message_id == message_id)
1037 {
1038 return;
1039 }
1040 let text = message.content.text().to_owned();
1041 let editor = cx.new(|cx| {
1042 let mut state = TextareaState::new(window, cx)
1043 .auto_grow(1, 8)
1044 .submit_on_enter(true);
1045 state.set_value(text, window, cx);
1046 state
1047 });
1048 let subscription = cx.subscribe_in(
1049 &editor,
1050 window,
1051 |this, _, event: &InputEvent, window, cx| {
1052 if let InputEvent::PressEnter { shift: false, .. } = event {
1053 this.submit_edit(window, cx);
1054 cx.stop_propagation();
1055 }
1056 },
1057 );
1058 let focus_handle = editor.read(cx).focus_handle(cx);
1059 window.focus(&focus_handle, cx);
1060 self.editing = Some(EditSession {
1061 message_id: message_id.clone(),
1062 editor,
1063 _subscription: subscription,
1064 });
1065 cx.emit(ChatEvent::EditRequested { message_id });
1066 cx.notify();
1067 }
1068
1069 pub fn set_edit_draft(
1071 &mut self,
1072 draft: impl Into<String>,
1073 window: &mut Window,
1074 cx: &mut Context<Self>,
1075 ) {
1076 let Some(session) = &self.editing else {
1077 return;
1078 };
1079 let draft = draft.into();
1080 session.editor.update(cx, |editor, cx| {
1081 editor.set_value(draft, window, cx);
1082 });
1083 }
1084
1085 pub fn editing_message(&self) -> Option<&SharedString> {
1087 self.editing.as_ref().map(|session| &session.message_id)
1088 }
1089
1090 pub fn submit_edit(&mut self, _: &mut Window, cx: &mut Context<Self>) {
1093 let Some(session) = &self.editing else {
1094 return;
1095 };
1096 let draft = session.editor.read(cx).value().to_string();
1097 let text = draft.trim();
1098 if text.is_empty() {
1099 return;
1100 }
1101 let message_id = session.message_id.clone();
1102 let text: SharedString = text.to_owned().into();
1103 self.editing = None;
1104 cx.emit(ChatEvent::EditSubmitted { message_id, text });
1105 cx.notify();
1106 }
1107
1108 pub fn cancel_edit(&mut self, _: &mut Window, cx: &mut Context<Self>) {
1110 let Some(session) = self.editing.take() else {
1111 return;
1112 };
1113 cx.emit(ChatEvent::EditCancelled {
1114 message_id: session.message_id,
1115 });
1116 cx.notify();
1117 }
1118}
1119
1120impl EventEmitter<ChatEvent> for Chat {}
1121
1122impl gpui::Styled for Chat {
1123 fn style(&mut self) -> &mut gpui::StyleRefinement {
1124 &mut self.style
1125 }
1126}
1127
1128impl Render for Chat {
1129 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
1130 self.render_jump(window, cx);
1131 let rem_size = window.rem_size();
1136 if !self.resolved_layout.matches(rem_size) {
1137 cx.defer_in(window, move |chat, _, cx| {
1138 chat.resolve_layout(rem_size, cx);
1139 });
1140 }
1141
1142 let tokens = cx.theme().semantic_tokens();
1143 let transcript_id = ElementId::from((ElementId::from(self.id.clone()), "transcript"));
1144 let composer_id = ElementId::from((ElementId::from(self.id.clone()), "composer"));
1145 let unread = self.unread_message_ids.len();
1146 let show_jump = unread > 0 && !self.list_state.is_following_tail();
1147 let jump_label: SharedString = format!(
1148 "Jump to latest, {unread} unread message{}",
1149 if unread == 1 { "" } else { "s" }
1150 )
1151 .into();
1152
1153 chat_frame(&self.id)
1154 .capture_any_mouse_down(cx.listener(|chat, _, _, cx| chat.cancel_jump(cx)))
1155 .capture_key_down(cx.listener(|chat, _, _, cx| chat.cancel_jump(cx)))
1156 .gap(tokens.spacing.sm)
1157 .child(
1158 transcript_frame(transcript_id)
1159 .relative()
1160 .flex_1()
1161 .overflow_hidden()
1162 .child({
1163 let chat = cx.weak_entity();
1164 let list = self.list_state.clone();
1165 gpui::canvas(
1166 |_, _, _| (),
1167 move |_, _, window, _| {
1168 window.on_mouse_event(
1169 move |event: &gpui::ScrollWheelEvent, phase, _, cx| {
1170 if phase == gpui::DispatchPhase::Capture
1171 && list.viewport_bounds().contains(&event.position)
1172 {
1173 chat.update(cx, |chat, cx| chat.cancel_jump(cx)).ok();
1174 }
1175 },
1176 );
1177 },
1178 )
1179 .absolute()
1180 .size_full()
1181 })
1182 .when(self.messages.is_empty(), |this| {
1183 this.child(self.render_welcome(cx))
1184 })
1185 .when(!self.messages.is_empty(), |this| {
1186 this.child(
1187 list(self.list_state.clone(), cx.processor(Self::render_message))
1188 .size_full(),
1189 )
1190 .policy_vertical_scrollbar(&self.list_state, cx)
1191 .child(list_scroll_mask(&self.list_state))
1192 }),
1193 )
1194 .when(show_jump, |this| {
1195 this.child(
1196 jump_to_latest_button(&self.id, jump_label, window, cx)
1197 .on_click(cx.listener(|chat, _, _, cx| chat.scroll_to_latest(cx))),
1198 )
1199 })
1200 .child(
1201 div()
1202 .id(composer_id)
1203 .debug_selector(|| "chat-composer".into())
1204 .role(Role::Group)
1205 .aria_label("Message composer")
1206 .flex_none()
1207 .child(self.prompt_bar.clone()),
1208 )
1209 .refine_style(&self.style)
1210 }
1211}