Skip to main content

gpui_ai/
chat.rs

1//! Controlled, virtualized chat transcript composed from gpui-ai primitives.
2
3mod 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/// Where a message sits among its sibling versions (regenerations or edits).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct BranchPosition {
58    index: usize,
59    count: usize,
60}
61
62impl BranchPosition {
63    /// Creates a position; `index` is zero-based and clamped below `count`
64    /// (which is at least one).
65    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    /// Zero-based index of the shown version.
74    pub fn index(self) -> usize {
75        self.index
76    }
77
78    /// Number of versions.
79    pub fn count(self) -> usize {
80        self.count
81    }
82
83    /// The accessible name of the version switcher.
84    pub fn label(self) -> String {
85        format!("Version {} of {}", self.index + 1, self.count)
86    }
87}
88
89/// The in-place editor for one message, alive only while editing.
90struct EditSession {
91    message_id: SharedString,
92    editor: Entity<TextareaState>,
93    _subscription: Subscription,
94}
95
96/// How long the copy action shows its "copied" confirmation.
97const COPIED_FEEDBACK: Duration = Duration::from_secs(2);
98
99/// The semantic speaker or producer of a [`ChatMessage`].
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum ChatRole {
102    /// A message authored by the person using the application.
103    User,
104    /// A message authored by the assistant.
105    Assistant,
106    /// Application or conversation-level guidance.
107    System,
108    /// Output produced by an application-owned tool invocation.
109    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/// One application-owned message snapshot rendered by [`Chat`].
124///
125/// IDs must be unique within a controlled snapshot. The message keeps all
126/// progressive content and interaction metadata immutable; applications
127/// replace the enclosing `Arc<[ChatMessage]>` as work advances.
128#[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/// Application-controlled presentation for one message.
145///
146/// Chat deliberately does not prescribe how a message looks: consumers decide
147/// alignment and bubble treatment per message (typically by role), so a
148/// right-aligned user / left-aligned agent layout is one configuration away.
149#[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/// Horizontal placement of a message within the transcript.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum MessageAlignment {
167    /// Hug the transcript's leading edge (default).
168    Leading,
169    /// Push toward the transcript's trailing edge.
170    Trailing,
171}
172
173/// Surface treatment of the message frame.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum MessageBubble {
176    /// Card with border and surface background (default).
177    Bordered,
178    /// Filled tint with no border (chat-bubble look).
179    Filled,
180    /// No frame at all; content sits directly on the transcript.
181    Plain,
182}
183
184impl ChatMessageAppearance {
185    /// Creates an appearance with explicit alignment and bubble treatment.
186    pub fn new(alignment: MessageAlignment, bubble: MessageBubble) -> Self {
187        Self { alignment, bubble }
188    }
189
190    /// Horizontal placement of this message.
191    pub fn alignment(&self) -> MessageAlignment {
192        self.alignment
193    }
194
195    /// Surface treatment of this message.
196    pub fn bubble(&self) -> MessageBubble {
197        self.bubble
198    }
199}
200
201/// The per-message actions a transcript row offers.
202///
203/// Actions are quiet at rest and appear on hover or keyboard focus; the last
204/// settled message keeps them visible so the most likely next action is one
205/// click away. Every action is reported as a typed [`ChatEvent`] — the
206/// application performs the work.
207#[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    /// No actions.
217    pub fn none() -> Self {
218        Self::default()
219    }
220
221    /// The conventional set for a role: assistant replies can be copied,
222    /// regenerated, and rated; user prompts can be copied and edited; tool
223    /// output can be copied; system notes offer nothing.
224    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    /// Offers copying the message text to the clipboard.
234    pub fn copy(mut self, enabled: bool) -> Self {
235        self.copy = enabled;
236        self
237    }
238
239    /// Offers requesting a new response.
240    pub fn regenerate(mut self, enabled: bool) -> Self {
241        self.regenerate = enabled;
242        self
243    }
244
245    /// Offers editing (and resending) the message.
246    pub fn edit(mut self, enabled: bool) -> Self {
247        self.edit = enabled;
248        self
249    }
250
251    /// Offers helpful / not-helpful feedback.
252    pub fn feedback(mut self, enabled: bool) -> Self {
253        self.feedback = enabled;
254        self
255    }
256
257    /// Whether any action is offered.
258    pub fn is_empty(&self) -> bool {
259        !(self.copy || self.regenerate || self.edit || self.feedback)
260    }
261}
262
263/// What an empty conversation shows: a welcome, optional guidance, and
264/// starter suggestions.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct ChatWelcome {
267    title: SharedString,
268    description: Option<SharedString>,
269    suggestions: Vec<Suggestion>,
270}
271
272impl ChatWelcome {
273    /// Creates a welcome with a headline.
274    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    /// Adds supporting guidance under the headline.
283    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
284        self.description = Some(description.into());
285        self
286    }
287
288    /// Adds starter prompts, reported through [`ChatEvent::SuggestionSelected`].
289    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    /// Creates a message with stable application identity and progressive text.
297    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    /// Overrides the actions this message offers (default: [`MessageActions::for_role`]).
315    pub fn actions(mut self, actions: MessageActions) -> Self {
316        self.actions = Some(actions);
317        self
318    }
319
320    /// The actions this message offers.
321    pub fn message_actions(&self) -> MessageActions {
322        self.actions
323            .unwrap_or_else(|| MessageActions::for_role(self.role))
324    }
325
326    /// Sets this message's presentation (alignment and bubble treatment).
327    pub fn with_appearance(mut self, appearance: ChatMessageAppearance) -> Self {
328        self.appearance = appearance;
329        self
330    }
331
332    /// This message's presentation.
333    pub fn appearance(&self) -> &ChatMessageAppearance {
334        &self.appearance
335    }
336
337    /// Adds an optional visible author name.
338    pub fn author(mut self, author: impl Into<SharedString>) -> Self {
339        self.author = Some(author.into());
340        self
341    }
342
343    /// Adds application-owned inline citation metadata.
344    pub fn citations(mut self, citations: impl IntoIterator<Item = CitationRef>) -> Self {
345        self.citations = citations.into_iter().collect();
346        self
347    }
348
349    /// Adds source labels shown after progressive content settles.
350    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    /// Adds stable follow-up suggestions.
356    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    /// Attaches files shown read-only above the content; activating one
362    /// reports [`ChatEvent::AttachmentActivated`].
363    pub fn attachments(mut self, attachments: impl IntoIterator<Item = Attachment>) -> Self {
364        self.attachments = attachments.into_iter().collect();
365        self
366    }
367
368    /// Marks this message as one of several versions; the header shows a
369    /// switcher that reports [`ChatEvent::BranchSelected`].
370    pub fn branch(mut self, position: BranchPosition) -> Self {
371        self.branch = Some(position);
372        self
373    }
374
375    /// Controls whether a failed message exposes a retry action.
376    pub fn retryable(mut self, retryable: bool) -> Self {
377        self.retryable = retryable;
378        self
379    }
380
381    /// Returns the stable application-level message identifier.
382    pub fn id(&self) -> &SharedString {
383        &self.id
384    }
385
386    /// Returns the message role.
387    pub fn role(&self) -> ChatRole {
388        self.role
389    }
390
391    /// Returns the optional visible author.
392    pub fn author_name(&self) -> Option<&SharedString> {
393        self.author.as_ref()
394    }
395
396    /// Returns the progressive content snapshot.
397    pub fn content(&self) -> &StreamedContent {
398        &self.content
399    }
400
401    /// Returns inline citation metadata.
402    pub fn citation_refs(&self) -> &[CitationRef] {
403        &self.citations
404    }
405
406    /// Returns source labels.
407    pub fn source_labels(&self) -> &[SharedString] {
408        &self.sources
409    }
410
411    /// Returns follow-up suggestions.
412    pub fn follow_up_suggestions(&self) -> &[FollowUp] {
413        &self.follow_ups
414    }
415
416    /// Returns the attachments carried by this message.
417    pub fn attachment_refs(&self) -> &[Attachment] {
418        &self.attachments
419    }
420
421    /// Returns where this message sits among its versions, if branched.
422    pub fn branch_position(&self) -> Option<BranchPosition> {
423        self.branch
424    }
425
426    /// Returns whether a failed message may be retried.
427    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/// A typed application intent emitted by [`Chat`].
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub enum ChatEvent {
451    /// A cloned event from the composed [`PromptBar`].
452    Prompt(PromptBarEvent),
453    /// The user requested another attempt for a failed message.
454    RetryRequested {
455        /// Stable message identifier.
456        message_id: SharedString,
457    },
458    /// The user selected a follow-up attached to a message.
459    FollowUpSelected {
460        /// Stable message identifier.
461        message_id: SharedString,
462        /// Stable follow-up identifier.
463        follow_up_id: SharedString,
464    },
465    /// The user activated a source chip attached to a message.
466    SourceActivated {
467        /// Stable message identifier.
468        message_id: SharedString,
469        /// Stable source identifier.
470        source_id: SharedString,
471        /// The source location.
472        url: SharedString,
473    },
474    /// The user activated an attachment carried by a message.
475    AttachmentActivated {
476        /// Stable message identifier.
477        message_id: SharedString,
478        /// Stable attachment identifier.
479        attachment_id: SharedString,
480    },
481    /// The user activated an inline citation attached to a message.
482    CitationActivated {
483        /// Stable message identifier.
484        message_id: SharedString,
485        /// Stable citation identifier.
486        citation_id: SharedString,
487        /// Opaque application-owned destination.
488        destination: SharedString,
489    },
490    /// The user copied a message; the text is already on the clipboard.
491    MessageCopied {
492        /// Stable message identifier.
493        message_id: SharedString,
494    },
495    /// The user asked for a new response in place of this one.
496    RegenerateRequested {
497        /// Stable message identifier.
498        message_id: SharedString,
499    },
500    /// The user opened the in-place editor for this message.
501    EditRequested {
502        /// Stable message identifier.
503        message_id: SharedString,
504    },
505    /// The user committed an in-place edit; the application decides
506    /// whether that resends, branches, or simply rewrites the message.
507    EditSubmitted {
508        /// Stable message identifier.
509        message_id: SharedString,
510        /// The edited text.
511        text: SharedString,
512    },
513    /// The user abandoned an in-place edit.
514    EditCancelled {
515        /// Stable message identifier.
516        message_id: SharedString,
517    },
518    /// The user moved to another version of a branched message.
519    BranchSelected {
520        /// Stable message identifier.
521        message_id: SharedString,
522        /// Zero-based index of the chosen version.
523        index: usize,
524    },
525    /// The user rated a response.
526    FeedbackSubmitted {
527        /// Stable message identifier.
528        message_id: SharedString,
529        /// `true` for helpful, `false` for not helpful.
530        positive: bool,
531    },
532    /// The user chose a welcome suggestion.
533    SuggestionSelected {
534        /// Stable suggestion identifier.
535        suggestion_id: SharedString,
536    },
537    /// The user returned the transcript to active tail-following.
538    JumpedToLatest,
539}
540
541/// A controlled virtualized conversation composed with [`PromptBar`] and
542/// [`StreamingText`].
543///
544/// The application owns the `Arc<[ChatMessage]>` snapshot and every async
545/// producer. Chat retains only variable-height list, tail-follow, unread, and
546/// prompt-subscription state. The pinned `gpui::ListState` is used because it
547/// measures wrapped visible rows on demand and preserves logical anchors;
548/// `gpui_base::v_virtual_list` requires exact heights for every row up front.
549///
550/// # Example
551///
552/// ```no_run
553/// # use gpui_ai::prelude::*;
554/// # use gpui::AppContext;
555/// # use std::sync::Arc;
556/// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
557/// let prompt = cx.new(|cx| PromptBar::new("conversation-prompt", window, cx));
558/// let chat = cx.new(|cx| Chat::new("conversation", prompt, window, cx));
559/// chat.update(cx, |chat, cx| {
560///     chat.set_messages(
561///         Arc::from([ChatMessage::new(
562///             "welcome",
563///             ChatRole::Assistant,
564///             StreamedContent::done("How can I help?"),
565///         )]),
566///         window,
567///         cx,
568///     );
569/// });
570/// # }
571/// ```
572pub struct Chat {
573    /// Styles the caller put on this component, applied to its own frame.
574    ///
575    /// Last, so a caller outranks the component's defaults - the same rule the
576    /// builder components follow. A wrapper `div` cannot stand in for this:
577    /// a background, a border, or an ink set on a wrapper paints around the
578    /// component rather than on it.
579    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    /// Rem size the retained row heights were measured against.
586    resolved_layout: ResolvedLayoutKey,
587    visible_range: Range<usize>,
588    pinned_to_bottom: bool,
589    unread_message_ids: HashSet<SharedString>,
590    /// Messages appended to a live transcript; they settle in with one
591    /// reveal, while a freshly loaded history appears at rest.
592    arrivals: HashSet<SharedString>,
593    welcome: Option<ChatWelcome>,
594    copied_message: Option<SharedString>,
595    copied_reset: Option<Task<()>>,
596    /// The animated jump-to-latest drive. Owned so exactly one can run;
597    /// dropped on user interference, replacement, or completion.
598    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    /// Creates an empty chat around an application-supplied prompt entity.
615    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    /// Sets what an empty conversation shows.
668    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    /// Replaces the controlled message snapshot.
676    ///
677    /// A malformed snapshot containing duplicate IDs is ignored so recycled
678    /// rows can never alias one another. Structural edits are applied through
679    /// one list splice, while content-only changes invalidate only their
680    /// retained rows. When not following the tail, the prior top message ID
681    /// and pixel offset are restored in the replacement snapshot.
682    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        // A snapshot that shares no identity with the previous one is a
711        // different conversation, not a hundred new messages. Without this the
712        // switch would cue every message as an arrival, animate them all in,
713        // and mark the whole history unread.
714        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        // Only messages appended to a live transcript animate in and cue;
726        // a freshly loaded history settles silently.
727        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        // Transient state belongs to a message, so it cannot outlive one.
792        // Otherwise feedback grows without bound across conversation switches
793        // and an open editor keeps pointing at a message that is gone.
794        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            // Dropping the session is not enough on its own. If the editor
811            // being removed holds focus, the window keeps pointing at a
812            // handle whose entity is gone: the caret disappears and typing
813            // reaches nothing. Hand focus to the composer first.
814            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            // A different conversation opens at its end, like opening it fresh,
832            // rather than inheriting the previous transcript's scroll offset.
833            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    /// Returns the current application-owned message snapshot.
872    pub fn messages(&self) -> &Arc<[ChatMessage]> {
873        &self.messages
874    }
875
876    /// Returns the composed prompt entity.
877    pub fn prompt_bar(&self) -> &Entity<PromptBar> {
878        &self.prompt_bar
879    }
880
881    /// Returns the number of messages received while the transcript was offscreen.
882    pub fn unread_count(&self) -> usize {
883        self.unread_message_ids.len()
884    }
885
886    /// Returns whether the list is actively following the latest content.
887    pub fn is_pinned_to_bottom(&self) -> bool {
888        self.list_state.is_following_tail() && self.pinned_to_bottom
889    }
890
891    /// Returns the currently known visible message range.
892    pub fn visible_range(&self) -> Range<usize> {
893        self.visible_range.clone()
894    }
895
896    /// Resumes tail-following, clears unread state, and emits a typed event.
897    pub fn scroll_to_latest(&mut self, cx: &mut Context<Self>) {
898        self.unread_message_ids.clear();
899        cx.emit(ChatEvent::JumpedToLatest);
900
901        // The semantics land immediately; only the travel is staged. Under
902        // reduced motion, or with nothing measured to travel through, the
903        // jump snaps exactly as it always did.
904        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        // Distance is measured through the scrollbar readbacks: both walk
918        // the list's cached heights synchronously, and unlike per-item
919        // bounds they stay readable at the scrolled-to-end sentinel.
920        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            // Already at the tail; nothing to travel.
924            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        // Distance-capped: a tail more than about two viewports out does
933        // not scroll a blur of skipped transcript past the reader — the
934        // jump starts just under one viewport from the end and settles the
935        // remainder, so the motion always ends in legible context.
936        let distance = if max + current > viewport * 2.0 {
937            // Use the viewport-top coordinate, not scroll_to_end's sentinel
938            // one whole viewport past it; subtracting .9V from the sentinel
939            // would clamp straight to the tail and show no travel at all.
940            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        // Use the framework's frame demand and transition clock. A hidden
966        // chat has no timer waking it; reduction and input apply on the next
967        // frame, and every deferred write is guarded by this drive's identity.
968        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    /// Re-measures the transcript after the window's rem size changed.
1013    ///
1014    /// Row heights cache wrapped text laid out at the previous rem, and no
1015    /// message snapshot reports a zoom change, so nothing else invalidates
1016    /// them. The anchor policy is Chat's own: a transcript already following
1017    /// the tail keeps following it, and otherwise the message that was first
1018    /// on screen stays first.
1019    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    /// Replaces the in-place editor's draft.
1070    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    /// Returns the message being edited in place, if any.
1086    pub fn editing_message(&self) -> Option<&SharedString> {
1087        self.editing.as_ref().map(|session| &session.message_id)
1088    }
1089
1090    /// Commits the in-place edit. Empty drafts are ignored so a stray Enter
1091    /// cannot erase a message.
1092    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    /// Abandons the in-place edit.
1109    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        // The rem is a resolved-layout input, so a change to it invalidates
1132        // every measured row height. Reading it here costs nothing; the
1133        // reaction is deferred so that render itself neither mutates nor
1134        // notifies.
1135        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}