moly_kit/widgets/
messages.rs

1use std::{
2    cell::{Ref, RefMut},
3    collections::HashMap,
4};
5
6use crate::{
7    protocol::*,
8    utils::makepad::{EventExt, ItemsRangeIter},
9    widgets::{avatar::AvatarWidgetRefExt, message_loading::MessageLoadingWidgetRefExt},
10};
11use makepad_code_editor::code_view::CodeViewWidgetRefExt;
12use makepad_widgets::*;
13
14use super::{
15    citation::CitationAction, slot::SlotWidgetRefExt,
16    standard_message_content::StandardMessageContentWidgetRefExt,
17};
18
19live_design! {
20    use link::theme::*;
21    use link::widgets::*;
22    use link::moly_kit_theme::*;
23    use link::shaders::*;
24
25    use crate::widgets::chat_lines::*;
26    use crate::clients::deep_inquire::widgets::deep_inquire_content::*;
27
28    pub Messages = {{Messages}} {
29        flow: Overlay,
30
31        // TODO: Consider moving this out to it's own crate now that custom content
32        // is supported.
33        deep_inquire_content: <DeepInquireContent> {}
34
35        list = <PortalList> {
36            grab_key_focus: true
37            scroll_bar: {
38                bar_size: 0.0,
39            }
40            UserLine = <UserLine> {}
41            BotLine = <BotLine> {}
42            LoadingLine = <LoadingLine> {}
43            AppLine = <AppLine> {}
44            ErrorLine = <ErrorLine> {}
45            SystemLine = <SystemLine> {}
46            ToolRequestLine = <ToolRequestLine> {}
47            ToolResultLine = <ToolResultLine> {}
48
49            // Acts as marker for:
50            // - Knowing if the end of the list has been reached.
51            // - To jump to bottom with proper precision.
52            EndOfChat = <View> {height: 0.1}
53        }
54        <View> {
55            align: {x: 1.0, y: 1.0},
56            jump_to_bottom = <Button> {
57                width: 36,
58                height: 36,
59                margin: {left: 2, right: 2, top: 2, bottom: 10},
60                icon_walk: {
61                    width: 16, height: 16
62                    margin: {left: 4.5, top: 6.5},
63                }
64                draw_icon: {
65                    svg_file: dep("crate://self/resources/jump_to_bottom.svg")
66                    color: #1C1B1F,
67                    color_hover: #x0
68                }
69                draw_bg: {
70                    fn pixel(self) -> vec4 {
71                        let sdf = Sdf2d::viewport(self.pos * self.rect_size);
72                        let center = self.rect_size * 0.5;
73                        let radius = min(self.rect_size.x, self.rect_size.y) * 0.5;
74
75                        sdf.circle(center.x, center.y, radius - 1.0);
76                        sdf.fill_keep(#fff);
77                        sdf.stroke(#EAECF0, 1.5);
78
79                        return sdf.result
80                    }
81                }
82            }
83        }
84    }
85}
86
87/// Relevant actions that should be handled by a parent.
88///
89/// If includes an index, it refers to the index of the message in the list.
90#[derive(Debug, PartialEq, Copy, Clone, DefaultNone)]
91pub enum MessagesAction {
92    /// The message at the given index should be copied.
93    Copy(usize),
94
95    /// The message at the given index should be deleted.
96    Delete(usize),
97
98    /// The message at the given index should be edited and saved.
99    EditSave(usize),
100
101    /// The message at the given index should be edited, saved and the messages
102    /// history should be regenerated from here.
103    EditRegenerate(usize),
104
105    /// The tool request at the given index should be approved and executed.
106    ToolApprove(usize),
107
108    /// The tool request at the given index should be denied.
109    ToolDeny(usize),
110
111    None,
112}
113
114/// Represents the current open editor for a message.
115#[derive(Debug)]
116struct Editor {
117    index: usize,
118    buffer: String,
119}
120
121/// View over a conversation with messages.
122///
123/// This is mostly a dummy widget. Prefer using and adapting [crate::widgets::chat::Chat] instead.
124#[derive(Live, Widget)]
125pub struct Messages {
126    #[deref]
127    deref: View,
128
129    /// The list of messages rendered by this widget.
130    #[rust]
131    pub messages: Vec<Message>,
132
133    /// Used to get bot information.
134    #[rust]
135    pub bot_context: Option<BotContext>,
136
137    /// Registry of DSL templates used by custom content widgets.
138    ///
139    /// This is exposed as it is for easy manipulation and it's passed to
140    /// [BotClient::content_widget] method allowing it to create widgets with
141    /// [WidgetRef::new_from_ptr].
142    #[rust]
143    pub templates: HashMap<LiveId, LivePtr>,
144
145    #[live]
146    deep_inquire_content: LivePtr,
147
148    #[rust]
149    current_editor: Option<Editor>,
150
151    #[rust]
152    is_list_end_drawn: bool,
153
154    /// Keep track of the drawn items in the [[PortalList]] to be abale to retrive
155    /// the visible items anytime.
156    ///
157    /// The method [[PortalList::visible_items]] just returns a count/length.
158    #[rust]
159    visible_range: Option<(usize, usize)>,
160
161    /// Used to trigger a defered scroll to bottom after the message list has been replaced.
162    #[rust]
163    should_defer_scroll_to_bottom: bool,
164
165    #[rust]
166    hovered_index: Option<usize>,
167
168    #[rust]
169    user_scrolled: bool,
170
171    #[rust]
172    sticking_to_bottom: bool,
173}
174
175impl Widget for Messages {
176    fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
177        self.deref.handle_event(cx, event, scope);
178        self.handle_list(cx, event, scope);
179
180        let jump_to_bottom = self.button(id!(jump_to_bottom));
181
182        if jump_to_bottom.clicked(event.actions()) {
183            self.scroll_to_bottom(cx, false);
184            // Reset the scrolling state, so that if the user clicks the button during a stream,
185            // we forget they scrolled, and assume they want to stick to the bottom.
186            self.user_scrolled = false;
187            self.sticking_to_bottom = false;
188            self.redraw(cx);
189        }
190
191        for action in event.widget_actions() {
192            if let CitationAction::Open(url) = action.cast() {
193                let _ = robius_open::Uri::new(url.as_str()).open();
194            }
195        }
196    }
197
198    fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
199        let list_uid = self.portal_list(id!(list)).widget_uid();
200
201        while let Some(widget) = self.deref.draw_walk(cx, scope, walk).step() {
202            if widget.widget_uid() == list_uid {
203                self.draw_list(cx, widget.as_portal_list());
204            }
205        }
206
207        DrawStep::done()
208    }
209}
210
211impl Messages {
212    fn draw_list(&mut self, cx: &mut Cx2d, list_ref: PortalListRef) {
213        self.is_list_end_drawn = false;
214        self.visible_range = None;
215
216        // Trick to render one more item representing the end of the chat without
217        // risking a manual math bug. Removed immediately after rendering the items.
218        self.messages.push(Message {
219            from: EntityId::App,
220            // End-of-chat marker
221            content: MessageContent {
222                text: "EOC".into(),
223                ..Default::default()
224            },
225            ..Default::default()
226        });
227
228        if self.should_defer_scroll_to_bottom {
229            // Note: Not using `smooth_scroll_to_end` because it makes asumptions about the list range and the items
230            // that are only true after we've updated the list through itreation on next_visible_item.
231            list_ref.set_first_id(self.messages.len().saturating_sub(1));
232            self.should_defer_scroll_to_bottom = false;
233        }
234
235        let context = self.bot_context.clone().expect("no bot client set");
236        let mut client = context.client();
237
238        let mut list = list_ref.borrow_mut().unwrap();
239        list.set_item_range(cx, 0, self.messages.len());
240
241        while let Some(index) = list.next_visible_item(cx) {
242            if index >= self.messages.len() {
243                continue;
244            }
245
246            if let Some((_start, end)) = &mut self.visible_range {
247                *end = (*end).max(index);
248            } else {
249                self.visible_range = Some((index, index));
250            }
251
252            let message = &self.messages[index];
253
254            match &message.from {
255                EntityId::System => {
256                    // Render system messages (tool results, etc.)
257                    let item = if message.metadata.is_writing() {
258                        // Show loading animation for system messages that are being written
259                        let item = list.item(cx, index, live_id!(LoadingLine));
260                        item.message_loading(id!(content_section.loading))
261                            .animate(cx);
262                        item
263                    } else {
264                        list.item(cx, index, live_id!(SystemLine))
265                    };
266
267                    item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
268                        Some(Picture::Grapheme("S".into()));
269                    item.label(id!(name)).set_text(cx, "System");
270
271                    if !message.metadata.is_writing() {
272                        item.slot(id!(content))
273                            .current()
274                            .as_standard_message_content()
275                            .set_content(cx, &message.content);
276                    }
277
278                    self.apply_actions_and_editor_visibility(cx, &item, index);
279                    item.draw_all(cx, &mut Scope::empty());
280                }
281                EntityId::Tool => {
282                    // Render tool execution results
283                    let item = if message.metadata.is_writing() {
284                        // Show loading animation for tool execution
285                        let item = list.item(cx, index, live_id!(LoadingLine));
286                        item.message_loading(id!(content_section.loading))
287                            .animate(cx);
288                        item
289                    } else {
290                        list.item(cx, index, live_id!(ToolResultLine))
291                    };
292
293                    item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
294                        Some(Picture::Grapheme("T".into()));
295                    item.label(id!(name)).set_text(cx, "Tool");
296
297                    if !message.metadata.is_writing() {
298                        item.slot(id!(content))
299                            .current()
300                            .as_standard_message_content()
301                            .set_content(cx, &message.content);
302                    }
303
304                    self.apply_actions_and_editor_visibility(cx, &item, index);
305                    item.draw_all(cx, &mut Scope::empty());
306                }
307                EntityId::App => {
308                    // Handle EOC marker
309                    if message.content.text == "EOC" {
310                        let item = list.item(cx, index, live_id!(EndOfChat));
311                        item.draw_all(cx, &mut Scope::empty());
312                        self.is_list_end_drawn = true;
313                        continue;
314                    }
315
316                    // Handle error messages
317                    if let Some((left, right)) = message.content.text.split_once(':') {
318                        if let Some("error") = left
319                            .split_whitespace()
320                            .last()
321                            .map(|s| s.to_lowercase())
322                            .as_deref()
323                        {
324                            let item = list.item(cx, index, live_id!(ErrorLine));
325                            item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
326                                Some(Picture::Grapheme("X".into()));
327                            item.label(id!(name)).set_text(cx, left);
328
329                            let error_content = MessageContent {
330                                text: right.to_string(),
331                                ..Default::default()
332                            };
333                            item.slot(id!(content))
334                                .current()
335                                .as_standard_message_content()
336                                .set_content(cx, &error_content);
337
338                            self.apply_actions_and_editor_visibility(cx, &item, index);
339                            item.draw_all(cx, &mut Scope::empty());
340                            continue;
341                        }
342                    }
343
344                    // Handle regular app messages
345                    let item = list.item(cx, index, live_id!(AppLine));
346                    item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
347                        Some(Picture::Grapheme("A".into()));
348
349                    item.slot(id!(content))
350                        .current()
351                        .as_standard_message_content()
352                        .set_content(cx, &message.content);
353
354                    self.apply_actions_and_editor_visibility(cx, &item, index);
355                    item.draw_all(cx, &mut Scope::empty());
356                }
357                EntityId::User => {
358                    let item = list.item(cx, index, live_id!(UserLine));
359
360                    item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
361                        Some(Picture::Grapheme("Y".into()));
362                    item.label(id!(name)).set_text(cx, "You");
363
364                    item.slot(id!(content))
365                        .current()
366                        .as_standard_message_content()
367                        .set_content(cx, &message.content);
368
369                    self.apply_actions_and_editor_visibility(cx, &item, index);
370                    item.draw_all(cx, &mut Scope::empty());
371                }
372                EntityId::Bot(id) => {
373                    let bot = context.get_bot(id);
374
375                    let (name, avatar) = bot
376                        .as_ref()
377                        .map(|b| (b.name.as_str(), b.avatar.clone()))
378                        .unwrap_or(("Unknown bot", Picture::Grapheme("B".into())));
379
380                    let item =
381                        if message.metadata.is_writing() && message.content.is_empty() {
382                            let item = list.item(cx, index, live_id!(LoadingLine));
383                            item.message_loading(id!(content_section.loading))
384                                .animate(cx);
385                            item
386                        } else if !message.content.tool_calls.is_empty() {
387                            let item = list.item(cx, index, live_id!(ToolRequestLine));
388
389                            // Set visibility and status based on permission status
390                            let has_pending = message.content.tool_calls.iter().any(|tc| {
391                                tc.permission_status == ToolCallPermissionStatus::Pending
392                            });
393                            let has_denied =
394                                message.content.tool_calls.iter().any(|tc| {
395                                    tc.permission_status == ToolCallPermissionStatus::Denied
396                                });
397
398                            // Show/hide tool actions based on status
399                            item.view(id!(tool_actions)).set_visible(cx, has_pending);
400
401                            // Set status text, only show if denied
402                            if has_denied {
403                                item.view(id!(status_view)).set_visible(cx, true);
404                                item.label(id!(approved_status)).set_text(cx, "Denied");
405                            } else {
406                                item.view(id!(status_view)).set_visible(cx, false);
407                            }
408
409                            item
410                        } else {
411                            list.item(cx, index, live_id!(BotLine))
412                        };
413
414                    item.avatar(id!(avatar)).borrow_mut().unwrap().avatar = Some(avatar);
415                    item.label(id!(name)).set_text(cx, name);
416
417                    let mut slot = item.slot(id!(content));
418                    if let Some(custom_content) = client.content_widget(
419                        cx,
420                        slot.current().clone(),
421                        &self.templates,
422                        &message.content,
423                    ) {
424                        slot.replace(custom_content);
425                    } else {
426                        // Since portal list may reuse widgets, we must restore
427                        // the default widget just in case.
428                        slot.restore();
429                        slot.default()
430                            .as_standard_message_content()
431                            .set_content_with_metadata(cx, &message.content, &message.metadata);
432                    }
433
434                    let has_any_tool_calls = !message.content.tool_calls.is_empty();
435                    // For messages with tool calls, don't apply standard actions/editor,
436                    // Users must be prevented from editing or deleting tool calls since most AI providers will return errors
437                    // if tool calls are not properly formatted, or are not followed by a proper tool call response.
438                    if has_any_tool_calls {
439                        item.draw_all(cx, &mut Scope::empty());
440                    } else {
441                        self.apply_actions_and_editor_visibility(cx, &item, index);
442                        item.draw_all(cx, &mut Scope::empty());
443                    }
444                }
445            }
446        }
447
448        if let Some(message) = self.messages.pop() {
449            assert!(message.from == EntityId::App);
450            assert!(message.content.text == "EOC");
451        }
452
453        self.button(id!(jump_to_bottom))
454            .set_visible(cx, !self.is_at_bottom() && !self.sticking_to_bottom);
455    }
456
457    /// Check if we're at the end of the messages list.
458    pub fn is_at_bottom(&self) -> bool {
459        self.is_list_end_drawn
460    }
461
462    pub fn user_scrolled(&self) -> bool {
463        self.user_scrolled
464    }
465
466    /// Jump to the end of the list instantly.
467    pub fn scroll_to_bottom(&mut self, cx: &mut Cx, triggered_by_stream: bool) {
468        if self.messages.len() > 0 {
469            let list = self.portal_list(id!(list));
470
471            if triggered_by_stream {
472                // Use immediate scroll instead of smooth scroll to prevent continuous scroll actions
473                list.set_first_id_and_scroll(self.messages.len().saturating_sub(1), 0.0);
474            } else {
475                list.smooth_scroll_to_end(cx, 100.0, None);
476            }
477            self.sticking_to_bottom = triggered_by_stream;
478        }
479    }
480
481    /// Show or hide the editor for a message.
482    ///
483    /// Limitation: Only one editor can be shown at a time. If you try to show another editor,
484    /// the previous one will be hidden. If you try to hide an editor different from the one
485    /// currently shown, nothing will happen.
486    pub fn set_message_editor_visibility(&mut self, index: usize, visible: bool) {
487        if index >= self.messages.len() {
488            return;
489        }
490
491        if visible {
492            let buffer = self.messages[index].content.text.clone();
493            self.current_editor = Some(Editor { index, buffer });
494        } else if self.current_editor.as_ref().map(|e| e.index) == Some(index) {
495            self.current_editor = None;
496        }
497    }
498
499    /// If currently editing a message, this will return the text in it's editor.
500    pub fn current_editor_text(&self) -> Option<String> {
501        self.current_editor
502            .as_ref()
503            .and_then(|editor| self.portal_list(id!(list)).get_item(editor.index))
504            .map(|(_id, widget)| widget.text_input(id!(input)).text())
505    }
506
507    /// If currently editing a message, this will return the index of the message.
508    pub fn current_editor_index(&self) -> Option<usize> {
509        self.current_editor.as_ref().map(|e| e.index)
510    }
511
512    fn handle_list(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
513        let Some(range) = self.visible_range else {
514            return;
515        };
516
517        let list = self.portal_list(id!(list));
518        let range = range.0..=range.1;
519
520        // Handle item actions
521        for (index, item) in ItemsRangeIter::new(list, range) {
522            if let Event::MouseMove(event) = event {
523                if item.area().rect(cx).contains(event.abs) {
524                    self.hovered_index = Some(index);
525                    item.redraw(cx);
526                }
527            }
528
529            let actions = event.actions();
530
531            if item.button(id!(copy)).clicked(actions) {
532                cx.widget_action(self.widget_uid(), &scope.path, MessagesAction::Copy(index));
533            }
534
535            if item.button(id!(delete)).clicked(actions) {
536                cx.widget_action(
537                    self.widget_uid(),
538                    &scope.path,
539                    MessagesAction::Delete(index),
540                );
541            }
542
543            if item.button(id!(edit)).clicked(actions) {
544                self.set_message_editor_visibility(index, true);
545                self.redraw(cx);
546            }
547
548            if item.button(id!(edit_actions.cancel)).clicked(actions) {
549                self.set_message_editor_visibility(index, false);
550                self.redraw(cx);
551            }
552
553            // Being more explicit because makepad query may actually check for
554            // other save button somewhere else (like in the image viewer modal).
555            if item.button(id!(edit_actions.save)).clicked(actions) {
556                cx.widget_action(
557                    self.widget_uid(),
558                    &scope.path,
559                    MessagesAction::EditSave(index),
560                );
561            }
562
563            if item
564                .button(id!(edit_actions.save_and_regenerate))
565                .clicked(actions)
566            {
567                cx.widget_action(
568                    self.widget_uid(),
569                    &scope.path,
570                    MessagesAction::EditRegenerate(index),
571                );
572            }
573
574            if item.button(id!(tool_actions.approve)).clicked(actions) {
575                cx.widget_action(
576                    self.widget_uid(),
577                    &scope.path,
578                    MessagesAction::ToolApprove(index),
579                );
580            }
581
582            if item.button(id!(tool_actions.deny)).clicked(actions) {
583                cx.widget_action(
584                    self.widget_uid(),
585                    &scope.path,
586                    MessagesAction::ToolDeny(index),
587                );
588            }
589
590            if let Some(change) = item.text_input(id!(input)).changed(actions) {
591                self.current_editor.as_mut().unwrap().buffer = change;
592            }
593        }
594
595        // Handle code copy
596        // Since the Markdown widget could have multiple code blocks, we need the widget that triggered the action
597        if let Some(wa) = event.actions().widget_action(id!(copy_code_button)) {
598            if wa.widget().as_button().pressed(event.actions()) {
599                // nth(2) refers to the code view in the MessageMarkdown widget
600                let code_view = wa.widget_nth(2).widget(id!(code_view));
601                let text_to_copy = code_view.as_code_view().text();
602                cx.copy_to_clipboard(&text_to_copy);
603            }
604        }
605
606        // Detect if the user has manually scrolled the list.
607        // Ideally we should use `PortalList::was_scrolled` or `PortalList::scrolled` but they aren't reliable.
608        match event.hits(cx, self.area()) {
609            Hit::FingerScroll(_e) => {
610                self.user_scrolled = true;
611                self.sticking_to_bottom = false;
612            }
613            _ => {}
614        }
615    }
616
617    fn apply_actions_and_editor_visibility(
618        &mut self,
619        cx: &mut Cx,
620        widget: &WidgetRef,
621        index: usize,
622    ) {
623        let editor = widget.view(id!(editor));
624        let actions = widget.view(id!(actions));
625        let edit_actions = widget.view(id!(edit_actions));
626        let content_section = widget.view(id!(content_section));
627
628        let is_hovered = self.hovered_index == Some(index);
629        let is_current_editor = self.current_editor.as_ref().map(|e| e.index) == Some(index);
630
631        edit_actions.set_visible(cx, is_current_editor);
632        editor.set_visible(cx, is_current_editor);
633        actions.set_visible(cx, !is_current_editor && is_hovered);
634        content_section.set_visible(cx, !is_current_editor);
635
636        if is_current_editor {
637            editor
638                .text_input(id!(input))
639                .set_text(cx, &self.current_editor.as_ref().unwrap().buffer);
640        }
641    }
642
643    /// Set the messages and defer a scroll to bottom if requested.
644    pub fn set_messages(&mut self, messages: Vec<Message>, scroll_to_bottom: bool) {
645        self.messages = messages;
646        self.should_defer_scroll_to_bottom = scroll_to_bottom;
647    }
648
649    pub fn reset_scroll_state(&mut self) {
650        self.user_scrolled = false;
651        self.sticking_to_bottom = false;
652    }
653}
654
655impl MessagesRef {
656    /// Immutable access to the underlying [[Messages]].
657    ///
658    /// Panics if the widget reference is empty or if it's already borrowed.
659    pub fn read(&self) -> Ref<'_, Messages> {
660        self.borrow().unwrap()
661    }
662
663    /// Mutable access to the underlying [[Messages]].
664    ///
665    /// Panics if the widget reference is empty or if it's already borrowed.
666    pub fn write(&mut self) -> RefMut<'_, Messages> {
667        self.borrow_mut().unwrap()
668    }
669
670    /// Immutable reader to the underlying [[Messages]].
671    ///
672    /// Panics if the widget reference is empty or if it's already borrowed.
673    pub fn read_with<R>(&self, f: impl FnOnce(&Messages) -> R) -> R {
674        f(&*self.read())
675    }
676
677    /// Mutable writer to the underlying [[Messages]].
678    ///
679    /// Panics if the widget reference is empty or if it's already borrowed.
680    pub fn write_with<R>(&mut self, f: impl FnOnce(&mut Messages) -> R) -> R {
681        f(&mut *self.write())
682    }
683}
684
685impl LiveHook for Messages {
686    fn after_new_from_doc(&mut self, _cx: &mut Cx) {
687        self.templates
688            .insert(live_id!(DeepInquireContent), self.deep_inquire_content);
689    }
690}