Continue Assistant 2 Messages Layout (#11465)

Nate Butler , Marshall Bowers , and Kyle Kelley created

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <1486634+maxdeviant@users.noreply.github.com>
Co-authored-by: Kyle Kelley <rgbkrk@gmail.com>

Change summary

crates/assistant2/evals/list-of-into-element.md         |   1 
crates/assistant2/evals/new-gpui-element.md             |   1 
crates/assistant2/evals/what-is-the-assistant2-crate.md |   1 
crates/assistant2/src/assistant2.rs                     | 103 +++---
crates/assistant2/src/ui/chat_message.rs                | 164 +++++-----
crates/assistant2/src/ui/composer.rs                    | 120 ++++---
6 files changed, 210 insertions(+), 180 deletions(-)

Detailed changes

crates/assistant2/src/assistant2.rs 🔗

@@ -182,8 +182,7 @@ impl Render for AssistantPanel {
         div()
             .size_full()
             .v_flex()
-            .p_2()
-            .bg(cx.theme().colors().background)
+            .bg(cx.theme().colors().panel_background)
             .child(self.chat.clone())
     }
 }
@@ -634,8 +633,13 @@ impl AssistantChat {
     }
 
     fn render_message(&self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
+        let is_first = ix == 0;
         let is_last = ix == self.messages.len() - 1;
 
+        let padding = Spacing::Large.rems(cx);
+
+        // Whenever there's a run of assistant messages, group as one Assistant UI element
+
         match &self.messages[ix] {
             ChatMessage::User(UserMessage {
                 id,
@@ -643,7 +647,7 @@ impl AssistantChat {
                 attachments,
             }) => div()
                 .id(SharedString::from(format!("message-{}-container", id.0)))
-                .when(!is_last, |element| element.mb_2())
+                .when(is_first, |this| this.pt(padding))
                 .map(|element| {
                     if self.editing_message_id() == Some(*id) {
                         element.child(Composer::new(
@@ -672,35 +676,39 @@ impl AssistantChat {
                                     }
                                 }
                             }))
-                            .child(crate::ui::ChatMessage::new(
-                                *id,
-                                UserOrAssistant::User(self.user_store.read(cx).current_user()),
-                                Some(
-                                    RichText::new(
-                                        body.read(cx).text(cx),
-                                        &[],
-                                        &self.language_registry,
-                                    )
-                                    .element(ElementId::from(id.0), cx),
-                                ),
-                                Some(
-                                    h_flex()
-                                        .gap_2()
-                                        .children(
-                                            attachments
-                                                .iter()
-                                                .map(|attachment| attachment.view.clone()),
+                            .child(
+                                crate::ui::ChatMessage::new(
+                                    *id,
+                                    UserOrAssistant::User(self.user_store.read(cx).current_user()),
+                                    Some(
+                                        RichText::new(
+                                            body.read(cx).text(cx),
+                                            &[],
+                                            &self.language_registry,
                                         )
-                                        .into_any_element(),
-                                ),
-                                self.is_message_collapsed(id),
-                                Box::new(cx.listener({
-                                    let id = *id;
-                                    move |assistant_chat, _event, _cx| {
-                                        assistant_chat.toggle_message_collapsed(id)
-                                    }
-                                })),
-                            ))
+                                        .element(ElementId::from(id.0), cx),
+                                    ),
+                                    Some(
+                                        h_flex()
+                                            .gap_2()
+                                            .children(
+                                                attachments
+                                                    .iter()
+                                                    .map(|attachment| attachment.view.clone()),
+                                            )
+                                            .into_any_element(),
+                                    ),
+                                    self.is_message_collapsed(id),
+                                    Box::new(cx.listener({
+                                        let id = *id;
+                                        move |assistant_chat, _event, _cx| {
+                                            assistant_chat.toggle_message_collapsed(id)
+                                        }
+                                    })),
+                                )
+                                // TODO: Wire up selections.
+                                .selected(is_last),
+                            )
                     }
                 })
                 .into_any(),
@@ -716,7 +724,6 @@ impl AssistantChat {
                 } else {
                     Some(
                         div()
-                            .p_2()
                             .child(body.element(ElementId::from(id.0), cx))
                             .into_any_element(),
                     )
@@ -734,20 +741,24 @@ impl AssistantChat {
                 };
 
                 div()
-                    .when(!is_last, |element| element.mb_2())
-                    .child(crate::ui::ChatMessage::new(
-                        *id,
-                        UserOrAssistant::Assistant,
-                        assistant_body,
-                        tools_body,
-                        self.is_message_collapsed(id),
-                        Box::new(cx.listener({
-                            let id = *id;
-                            move |assistant_chat, _event, _cx| {
-                                assistant_chat.toggle_message_collapsed(id)
-                            }
-                        })),
-                    ))
+                    .when(is_first, |this| this.pt(padding))
+                    .child(
+                        crate::ui::ChatMessage::new(
+                            *id,
+                            UserOrAssistant::Assistant,
+                            assistant_body,
+                            tools_body,
+                            self.is_message_collapsed(id),
+                            Box::new(cx.listener({
+                                let id = *id;
+                                move |assistant_chat, _event, _cx| {
+                                    assistant_chat.toggle_message_collapsed(id)
+                                }
+                            })),
+                        )
+                        // TODO: Wire up selections.
+                        .selected(is_last),
+                    )
                     .child(self.render_error(error.clone(), ix, cx))
                     .into_any()
             }

crates/assistant2/src/ui/chat_message.rs 🔗

@@ -1,8 +1,8 @@
 use std::sync::Arc;
 
 use client::User;
-use gpui::{AnyElement, ClickEvent};
-use ui::{prelude::*, Avatar};
+use gpui::{hsla, AnyElement, ClickEvent};
+use ui::{prelude::*, Avatar, Tooltip};
 
 use crate::MessageId;
 
@@ -17,6 +17,7 @@ pub struct ChatMessage {
     player: UserOrAssistant,
     message: Option<AnyElement>,
     tools_used: Option<AnyElement>,
+    selected: bool,
     collapsed: bool,
     on_collapse_handle_click: Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
 }
@@ -35,79 +36,36 @@ impl ChatMessage {
             player,
             message,
             tools_used,
+            selected: false,
             collapsed,
             on_collapse_handle_click,
         }
     }
 }
 
+impl Selectable for ChatMessage {
+    fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
 impl RenderOnce for ChatMessage {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let message_group = SharedString::from(format!("{}_group", self.id.0));
+
         let collapse_handle_id = SharedString::from(format!("{}_collapse_handle", self.id.0));
-        let collapse_handle = h_flex()
-            .id(collapse_handle_id.clone())
-            .group(collapse_handle_id.clone())
-            .flex_none()
-            .justify_center()
-            .w_1()
-            .mx_2()
-            .h_full()
-            .on_click(self.on_collapse_handle_click)
-            .child(
-                div()
-                    .w_px()
-                    .h_full()
-                    .rounded_lg()
-                    .overflow_hidden()
-                    .bg(cx.theme().colors().element_background)
-                    .group_hover(collapse_handle_id, |this| {
-                        this.bg(cx.theme().colors().element_hover)
-                    }),
-            );
 
-        let content_padding = rems(1.);
+        let content_padding = Spacing::Small.rems(cx);
         // Clamp the message height to exactly 1.5 lines when collapsed.
         let collapsed_height = content_padding.to_pixels(cx.rem_size()) + cx.line_height() * 1.5;
 
-        v_flex()
-            .gap_1()
-            .child(ChatMessageHeader::new(self.player))
-            .when(self.message.is_some() || self.tools_used.is_some(), |el| {
-                el.child(
-                    h_flex().gap_3().child(collapse_handle).child(
-                        div()
-                            .overflow_hidden()
-                            .w_full()
-                            .p(content_padding)
-                            .gap_3()
-                            .rounded_lg()
-                            .when(self.collapsed, |this| this.h(collapsed_height))
-                            .bg(cx.theme().colors().surface_background)
-                            .children(self.message)
-                            .children(self.tools_used),
-                    ),
-                )
-            })
-    }
-}
-
-#[derive(IntoElement)]
-struct ChatMessageHeader {
-    player: UserOrAssistant,
-    contexts: Vec<()>,
-}
-
-impl ChatMessageHeader {
-    fn new(player: UserOrAssistant) -> Self {
-        Self {
-            player,
-            contexts: Vec::new(),
-        }
-    }
-}
+        let background_color = if let UserOrAssistant::User(_) = &self.player {
+            Some(cx.theme().colors().surface_background)
+        } else {
+            None
+        };
 
-impl RenderOnce for ChatMessageHeader {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
         let (username, avatar_uri) = match self.player {
             UserOrAssistant::Assistant => (
                 "Assistant".into(),
@@ -119,23 +77,77 @@ impl RenderOnce for ChatMessageHeader {
             UserOrAssistant::User(None) => ("You".into(), None),
         };
 
-        h_flex()
-            .justify_between()
+        v_flex()
+            .group(message_group.clone())
+            .gap(Spacing::XSmall.rems(cx))
+            .p(Spacing::XSmall.rems(cx))
+            .when(self.selected, |element| {
+                element.bg(hsla(0.6, 0.67, 0.46, 0.12))
+            })
+            .rounded_lg()
             .child(
                 h_flex()
-                    .gap_3()
-                    .map(|this| {
-                        let avatar_size = rems_from_px(20.);
-                        if let Some(avatar_uri) = avatar_uri {
-                            this.child(Avatar::new(avatar_uri).size(avatar_size))
-                        } else {
-                            this.child(div().size(avatar_size))
-                        }
-                    })
-                    .child(Label::new(username).color(Color::Default)),
+                    .justify_between()
+                    .px(content_padding)
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .map(|this| {
+                                let avatar_size = rems_from_px(20.);
+                                if let Some(avatar_uri) = avatar_uri {
+                                    this.child(Avatar::new(avatar_uri).size(avatar_size))
+                                } else {
+                                    this.child(div().size(avatar_size))
+                                }
+                            })
+                            .child(Label::new(username).color(Color::Muted)),
+                    )
+                    .child(
+                        h_flex().visible_on_hover(message_group).child(
+                            // temp icons
+                            IconButton::new(
+                                collapse_handle_id.clone(),
+                                if self.collapsed {
+                                    IconName::ArrowUp
+                                } else {
+                                    IconName::ArrowDown
+                                },
+                            )
+                            .icon_size(IconSize::XSmall)
+                            .icon_color(Color::Muted)
+                            .on_click(self.on_collapse_handle_click)
+                            .tooltip(|cx| Tooltip::text("Collapse Message", cx)),
+                        ), // .child(
+                           //     IconButton::new("copy-message", IconName::Copy)
+                           //         .icon_color(Color::Muted)
+                           //         .icon_size(IconSize::XSmall),
+                           // )
+                           // .child(
+                           //     IconButton::new("menu", IconName::Ellipsis)
+                           //         .icon_color(Color::Muted)
+                           //         .icon_size(IconSize::XSmall),
+                           // ),
+                    ),
             )
-            .child(div().when(!self.contexts.is_empty(), |this| {
-                this.child(Label::new(self.contexts.len().to_string()).color(Color::Muted))
-            }))
+            .when(self.message.is_some() || self.tools_used.is_some(), |el| {
+                el.child(
+                    h_flex().child(
+                        v_flex()
+                            .relative()
+                            .overflow_hidden()
+                            .w_full()
+                            .p(content_padding)
+                            .gap_3()
+                            .text_ui(cx)
+                            .rounded_lg()
+                            .when_some(background_color, |this, background_color| {
+                                this.bg(background_color)
+                            })
+                            .when(self.collapsed, |this| this.h(collapsed_height))
+                            .children(self.message)
+                            .when_some(self.tools_used, |this, tools_used| this.child(tools_used)),
+                    ),
+                )
+            })
     }
 }

crates/assistant2/src/ui/composer.rs 🔗

@@ -6,7 +6,7 @@ use editor::{Editor, EditorElement, EditorStyle};
 use gpui::{AnyElement, FontStyle, FontWeight, TextStyle, View, WeakView, WhiteSpace};
 use settings::Settings;
 use theme::ThemeSettings;
-use ui::{popover_menu, prelude::*, ButtonLike, ContextMenu, Divider, Tooltip};
+use ui::{popover_menu, prelude::*, ButtonLike, ContextMenu, Divider, TextSize, Tooltip};
 
 #[derive(IntoElement)]
 pub struct Composer {
@@ -50,67 +50,71 @@ impl Composer {
 
 impl RenderOnce for Composer {
     fn render(mut self, cx: &mut WindowContext) -> impl IntoElement {
-        let font_size = rems(0.875);
+        let font_size = TextSize::Default.rems(cx);
         let line_height = font_size.to_pixels(cx.rem_size()) * 1.3;
 
-        h_flex().w_full().items_start().mt_2().child(
-            v_flex().size_full().gap_1().child(
-                v_flex()
-                    .w_full()
-                    .p_3()
-                    .bg(cx.theme().colors().editor_background)
-                    .rounded_lg()
-                    .child(
-                        v_flex()
-                            .justify_between()
-                            .w_full()
-                            .gap_2()
-                            .child({
-                                let settings = ThemeSettings::get_global(cx);
-                                let text_style = TextStyle {
-                                    color: cx.theme().colors().editor_foreground,
-                                    font_family: settings.buffer_font.family.clone(),
-                                    font_features: settings.buffer_font.features.clone(),
-                                    font_size: font_size.into(),
-                                    font_weight: FontWeight::NORMAL,
-                                    font_style: FontStyle::Normal,
-                                    line_height: line_height.into(),
-                                    background_color: None,
-                                    underline: None,
-                                    strikethrough: None,
-                                    white_space: WhiteSpace::Normal,
-                                };
+        h_flex()
+            .p(Spacing::Small.rems(cx))
+            .w_full()
+            .items_start()
+            .child(
+                v_flex().size_full().gap_1().child(
+                    v_flex()
+                        .w_full()
+                        .p_3()
+                        .bg(cx.theme().colors().editor_background)
+                        .rounded_lg()
+                        .child(
+                            v_flex()
+                                .justify_between()
+                                .w_full()
+                                .gap_2()
+                                .child({
+                                    let settings = ThemeSettings::get_global(cx);
+                                    let text_style = TextStyle {
+                                        color: cx.theme().colors().editor_foreground,
+                                        font_family: settings.buffer_font.family.clone(),
+                                        font_features: settings.buffer_font.features.clone(),
+                                        font_size: font_size.into(),
+                                        font_weight: FontWeight::NORMAL,
+                                        font_style: FontStyle::Normal,
+                                        line_height: line_height.into(),
+                                        background_color: None,
+                                        underline: None,
+                                        strikethrough: None,
+                                        white_space: WhiteSpace::Normal,
+                                    };
 
-                                EditorElement::new(
-                                    &self.editor,
-                                    EditorStyle {
-                                        background: cx.theme().colors().editor_background,
-                                        local_player: cx.theme().players().local(),
-                                        text: text_style,
-                                        ..Default::default()
-                                    },
-                                )
-                            })
-                            .child(
-                                h_flex()
-                                    .flex_none()
-                                    .gap_2()
-                                    .justify_between()
-                                    .w_full()
-                                    .child(
-                                        h_flex().gap_1().child(
-                                            h_flex()
-                                                .gap_2()
-                                                .child(self.render_tools(cx))
-                                                .child(Divider::vertical())
-                                                .child(self.render_attachment_tools(cx)),
-                                        ),
+                                    EditorElement::new(
+                                        &self.editor,
+                                        EditorStyle {
+                                            background: cx.theme().colors().editor_background,
+                                            local_player: cx.theme().players().local(),
+                                            text: text_style,
+                                            ..Default::default()
+                                        },
                                     )
-                                    .child(h_flex().gap_1().child(self.model_selector)),
-                            ),
-                    ),
-            ),
-        )
+                                })
+                                .child(
+                                    h_flex()
+                                        .flex_none()
+                                        .gap_2()
+                                        .justify_between()
+                                        .w_full()
+                                        .child(
+                                            h_flex().gap_1().child(
+                                                h_flex()
+                                                    .gap_2()
+                                                    .child(self.render_tools(cx))
+                                                    .child(Divider::vertical())
+                                                    .child(self.render_attachment_tools(cx)),
+                                            ),
+                                        )
+                                        .child(h_flex().gap_1().child(self.model_selector)),
+                                ),
+                        ),
+                ),
+            )
     }
 }