Introduce the ability to load and save conversations with the assistant (#2623)

Antonio Scandurra created

Closes
https://linear.app/zed-industries/issue/Z-1890/save-assistant-conversations-to-the-filesystem
Closes
https://linear.app/zed-industries/issue/Z-2459/cycling-message-roles-on-the-last-empty-message-alters-the-message
Closes
https://linear.app/zed-industries/issue/Z-2460/cycling-role-in-an-empty-message-cycles-wrong-messages-role
Closes https://linear.app/zed-industries/issue/Z-2365/assistant-toolbar
Closes
https://linear.app/zed-industries/issue/Z-2461/always-insert-an-empty-message-at-the-end-of-the-conversation

Release Notes:

- You can now save conversations with the assistant to
`~/.config/zed/conversations` with `cmd-s`. Conversations are also
automatically saved as they are edited.

Change summary

Cargo.lock                                 |   2 
assets/icons/assist_15.svg                 |   0 
assets/icons/hamburger_15.svg              |   3 
assets/icons/quote_15.svg                  |   0 
assets/icons/split_message_15.svg          |   0 
assets/keymaps/default.json                |  16 
assets/settings/default.json               |  54 
crates/ai/Cargo.toml                       |   4 
crates/ai/src/ai.rs                        |  92 ++
crates/ai/src/assistant.rs                 | 738 +++++++++++++++++++----
crates/gpui/src/elements/label.rs          |   1 
crates/gpui/src/elements/svg.rs            |  37 +
crates/project_panel/src/project_panel.rs  |   1 
crates/search/src/buffer_search.rs         |  10 
crates/terminal_view/src/terminal_panel.rs |   1 
crates/theme/src/theme.rs                  |  23 
crates/theme/src/ui.rs                     |  30 
crates/util/src/paths.rs                   |   1 
crates/workspace/src/dock.rs               |   3 
crates/workspace/src/pane.rs               |  13 
crates/workspace/src/toolbar.rs            |  74 +-
crates/workspace/src/workspace.rs          |  17 
styles/src/styleTree/assistant.ts          | 193 +++++
23 files changed, 1,050 insertions(+), 263 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -109,6 +109,8 @@ dependencies = [
  "isahc",
  "language",
  "menu",
+ "project",
+ "regex",
  "schemars",
  "search",
  "serde",

assets/icons/hamburger_15.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386 13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z" fill="#CCCAC2"/>
+</svg>

assets/keymaps/default.json 🔗

@@ -40,7 +40,8 @@
       "cmd-o": "workspace::Open",
       "alt-cmd-o": "projects::OpenRecent",
       "ctrl-~": "workspace::NewTerminal",
-      "ctrl-`": "terminal_panel::ToggleFocus"
+      "ctrl-`": "terminal_panel::ToggleFocus",
+      "shift-escape": "workspace::ToggleZoom"
     }
   },
   {
@@ -197,9 +198,17 @@
     }
   },
   {
-    "context": "AssistantEditor > Editor",
+    "context": "AssistantPanel",
+    "bindings": {
+        "cmd-g": "search::SelectNextMatch",
+        "cmd-shift-g": "search::SelectPrevMatch"
+    }
+  },
+  {
+    "context": "ConversationEditor > Editor",
     "bindings": {
       "cmd-enter": "assistant::Assist",
+      "cmd-s": "workspace::Save",
       "cmd->": "assistant::QuoteSelection",
       "shift-enter": "assistant::Split",
       "ctrl-r": "assistant::CycleMessageRole"
@@ -234,8 +243,7 @@
       "cmd-shift-g": "search::SelectPrevMatch",
       "alt-cmd-c": "search::ToggleCaseSensitive",
       "alt-cmd-w": "search::ToggleWholeWord",
-      "alt-cmd-r": "search::ToggleRegex",
-      "shift-escape": "workspace::ToggleZoom"
+      "alt-cmd-r": "search::ToggleRegex"
     }
   },
   // Bindings from VS Code

assets/settings/default.json 🔗

@@ -57,37 +57,37 @@
   "show_whitespaces": "selection",
   // Scrollbar related settings
   "scrollbar": {
-      // When to show the scrollbar in the editor.
-      // This setting can take four values:
-      //
-      // 1. Show the scrollbar if there's important information or
-      //    follow the system's configured behavior (default):
-      //   "auto"
-      // 2. Match the system's configured behavior:
-      //    "system"
-      // 3. Always show the scrollbar:
-      //    "always"
-      // 4. Never show the scrollbar:
-      //    "never"
-      "show": "auto",
-      // Whether to show git diff indicators in the scrollbar.
-      "git_diff": true
+    // When to show the scrollbar in the editor.
+    // This setting can take four values:
+    //
+    // 1. Show the scrollbar if there's important information or
+    //    follow the system's configured behavior (default):
+    //   "auto"
+    // 2. Match the system's configured behavior:
+    //    "system"
+    // 3. Always show the scrollbar:
+    //    "always"
+    // 4. Never show the scrollbar:
+    //    "never"
+    "show": "auto",
+    // Whether to show git diff indicators in the scrollbar.
+    "git_diff": true
   },
   "project_panel": {
-      // Whether to show the git status in the project panel.
-      "git_status": true,
-      // Where to dock project panel. Can be 'left' or 'right'.
-      "dock": "left",
-      // Default width of the project panel.
-      "default_width": 240
+    // Whether to show the git status in the project panel.
+    "git_status": true,
+    // Where to dock project panel. Can be 'left' or 'right'.
+    "dock": "left",
+    // Default width of the project panel.
+    "default_width": 240
   },
   "assistant": {
-      // Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
-      "dock": "right",
-      // Default width when the assistant is docked to the left or right.
-      "default_width": 450,
-      // Default height when the assistant is docked to the bottom.
-      "default_height": 320
+    // Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
+    "dock": "right",
+    // Default width when the assistant is docked to the left or right.
+    "default_width": 640,
+    // Default height when the assistant is docked to the bottom.
+    "default_height": 320
   },
   // Whether the screen sharing icon is shown in the os status bar.
   "show_call_status_icon": true,

crates/ai/Cargo.toml 🔗

@@ -22,9 +22,10 @@ util = { path = "../util" }
 workspace = { path = "../workspace" }
 
 anyhow.workspace = true
-chrono = "0.4"
+chrono = { version = "0.4", features = ["serde"] }
 futures.workspace = true
 isahc.workspace = true
+regex.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -33,3 +34,4 @@ tiktoken-rs = "0.4"
 
 [dev-dependencies]
 editor = { path = "../editor", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }

crates/ai/src/ai.rs 🔗

@@ -1,10 +1,22 @@
 pub mod assistant;
 mod assistant_settings;
 
+use anyhow::Result;
 pub use assistant::AssistantPanel;
+use chrono::{DateTime, Local};
+use collections::HashMap;
+use fs::Fs;
+use futures::StreamExt;
 use gpui::AppContext;
+use regex::Regex;
 use serde::{Deserialize, Serialize};
-use std::fmt::{self, Display};
+use std::{
+    cmp::Reverse,
+    fmt::{self, Display},
+    path::PathBuf,
+    sync::Arc,
+};
+use util::paths::CONVERSATIONS_DIR;
 
 // Data types for chat completion requests
 #[derive(Debug, Serialize)]
@@ -14,6 +26,84 @@ struct OpenAIRequest {
     stream: bool,
 }
 
+#[derive(
+    Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Hash, Serialize, Deserialize,
+)]
+struct MessageId(usize);
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct MessageMetadata {
+    role: Role,
+    sent_at: DateTime<Local>,
+    status: MessageStatus,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+enum MessageStatus {
+    Pending,
+    Done,
+    Error(Arc<str>),
+}
+
+#[derive(Serialize, Deserialize)]
+struct SavedMessage {
+    id: MessageId,
+    start: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SavedConversation {
+    zed: String,
+    version: String,
+    text: String,
+    messages: Vec<SavedMessage>,
+    message_metadata: HashMap<MessageId, MessageMetadata>,
+    summary: String,
+    model: String,
+}
+
+impl SavedConversation {
+    const VERSION: &'static str = "0.1.0";
+}
+
+struct SavedConversationMetadata {
+    title: String,
+    path: PathBuf,
+    mtime: chrono::DateTime<chrono::Local>,
+}
+
+impl SavedConversationMetadata {
+    pub async fn list(fs: Arc<dyn Fs>) -> Result<Vec<Self>> {
+        fs.create_dir(&CONVERSATIONS_DIR).await?;
+
+        let mut paths = fs.read_dir(&CONVERSATIONS_DIR).await?;
+        let mut conversations = Vec::<SavedConversationMetadata>::new();
+        while let Some(path) = paths.next().await {
+            let path = path?;
+
+            let pattern = r" - \d+.zed.json$";
+            let re = Regex::new(pattern).unwrap();
+
+            let metadata = fs.metadata(&path).await?;
+            if let Some((file_name, metadata)) = path
+                .file_name()
+                .and_then(|name| name.to_str())
+                .zip(metadata)
+            {
+                let title = re.replace(file_name, "");
+                conversations.push(Self {
+                    title: title.into_owned(),
+                    path,
+                    mtime: metadata.mtime.into(),
+                });
+            }
+        }
+        conversations.sort_unstable_by_key(|conversation| Reverse(conversation.mtime));
+
+        Ok(conversations)
+    }
+}
+
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 struct RequestMessage {
     role: Role,

crates/ai/src/assistant.rs 🔗

@@ -1,6 +1,7 @@
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings},
-    OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role,
+    MessageId, MessageMetadata, MessageStatus, OpenAIRequest, OpenAIResponseStreamEvent,
+    RequestMessage, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
 };
 use anyhow::{anyhow, Result};
 use chrono::{DateTime, Local};
@@ -23,17 +24,26 @@ use gpui::{
 };
 use isahc::{http::StatusCode, Request, RequestExt};
 use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _};
+use search::BufferSearchBar;
 use serde::Deserialize;
 use settings::SettingsStore;
 use std::{
-    borrow::Cow, cell::RefCell, cmp, fmt::Write, io, iter, ops::Range, rc::Rc, sync::Arc,
+    cell::RefCell,
+    cmp, env,
+    fmt::Write,
+    io, iter,
+    ops::Range,
+    path::{Path, PathBuf},
+    rc::Rc,
+    sync::Arc,
     time::Duration,
 };
-use util::{channel::ReleaseChannel, post_inc, truncate_and_trailoff, ResultExt, TryFutureExt};
+use theme::AssistantStyle;
+use util::{channel::ReleaseChannel, paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel},
-    item::Item,
-    pane, Pane, Workspace,
+    searchable::Direction,
+    Save, ToggleZoom, Toolbar, Workspace,
 };
 
 const OPENAI_API_URL: &'static str = "https://api.openai.com/v1";
@@ -47,7 +57,7 @@ actions!(
         CycleMessageRole,
         QuoteSelection,
         ToggleFocus,
-        ResetKey
+        ResetKey,
     ]
 );
 
@@ -62,20 +72,28 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(
         |workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext<Workspace>| {
             if let Some(this) = workspace.panel::<AssistantPanel>(cx) {
-                this.update(cx, |this, cx| this.add_context(cx))
+                this.update(cx, |this, cx| {
+                    this.new_conversation(cx);
+                })
             }
 
             workspace.focus_panel::<AssistantPanel>(cx);
         },
     );
-    cx.add_action(AssistantEditor::assist);
-    cx.capture_action(AssistantEditor::cancel_last_assist);
-    cx.add_action(AssistantEditor::quote_selection);
-    cx.capture_action(AssistantEditor::copy);
-    cx.capture_action(AssistantEditor::split);
-    cx.capture_action(AssistantEditor::cycle_message_role);
+    cx.add_action(ConversationEditor::assist);
+    cx.capture_action(ConversationEditor::cancel_last_assist);
+    cx.capture_action(ConversationEditor::save);
+    cx.add_action(ConversationEditor::quote_selection);
+    cx.capture_action(ConversationEditor::copy);
+    cx.add_action(ConversationEditor::split);
+    cx.capture_action(ConversationEditor::cycle_message_role);
     cx.add_action(AssistantPanel::save_api_key);
     cx.add_action(AssistantPanel::reset_api_key);
+    cx.add_action(AssistantPanel::toggle_zoom);
+    cx.add_action(AssistantPanel::deploy);
+    cx.add_action(AssistantPanel::select_next_match);
+    cx.add_action(AssistantPanel::select_prev_match);
+    cx.add_action(AssistantPanel::handle_editor_cancel);
     cx.add_action(
         |workspace: &mut Workspace, _: &ToggleFocus, cx: &mut ViewContext<Workspace>| {
             workspace.toggle_panel_focus::<AssistantPanel>(cx);
@@ -83,6 +101,7 @@ pub fn init(cx: &mut AppContext) {
     );
 }
 
+#[derive(Debug)]
 pub enum AssistantPanelEvent {
     ZoomIn,
     ZoomOut,
@@ -92,15 +111,24 @@ pub enum AssistantPanelEvent {
 }
 
 pub struct AssistantPanel {
+    workspace: WeakViewHandle<Workspace>,
     width: Option<f32>,
     height: Option<f32>,
-    pane: ViewHandle<Pane>,
+    active_editor_index: Option<usize>,
+    prev_active_editor_index: Option<usize>,
+    editors: Vec<ViewHandle<ConversationEditor>>,
+    saved_conversations: Vec<SavedConversationMetadata>,
+    saved_conversations_list_state: UniformListState,
+    zoomed: bool,
+    has_focus: bool,
+    toolbar: ViewHandle<Toolbar>,
     api_key: Rc<RefCell<Option<String>>>,
     api_key_editor: Option<ViewHandle<Editor>>,
     has_read_credentials: bool,
     languages: Arc<LanguageRegistry>,
     fs: Arc<dyn Fs>,
     subscriptions: Vec<Subscription>,
+    _watch_saved_conversations: Task<Result<()>>,
 }
 
 impl AssistantPanel {
@@ -109,66 +137,51 @@ impl AssistantPanel {
         cx: AsyncAppContext,
     ) -> Task<Result<ViewHandle<Self>>> {
         cx.spawn(|mut cx| async move {
+            let fs = workspace.read_with(&cx, |workspace, _| workspace.app_state().fs.clone())?;
+            let saved_conversations = SavedConversationMetadata::list(fs.clone())
+                .await
+                .log_err()
+                .unwrap_or_default();
+
             // TODO: deserialize state.
+            let workspace_handle = workspace.clone();
             workspace.update(&mut cx, |workspace, cx| {
                 cx.add_view::<Self, _>(|cx| {
-                    let weak_self = cx.weak_handle();
-                    let pane = cx.add_view(|cx| {
-                        let mut pane = Pane::new(
-                            workspace.weak_handle(),
-                            workspace.project().clone(),
-                            workspace.app_state().background_actions,
-                            Default::default(),
-                            cx,
-                        );
-                        pane.set_can_split(false, cx);
-                        pane.set_can_navigate(false, cx);
-                        pane.on_can_drop(move |_, _| false);
-                        pane.set_render_tab_bar_buttons(cx, move |pane, cx| {
-                            let weak_self = weak_self.clone();
-                            Flex::row()
-                                .with_child(Pane::render_tab_bar_button(
-                                    0,
-                                    "icons/plus_12.svg",
-                                    false,
-                                    Some(("New Context".into(), Some(Box::new(NewContext)))),
-                                    cx,
-                                    move |_, cx| {
-                                        let weak_self = weak_self.clone();
-                                        cx.window_context().defer(move |cx| {
-                                            if let Some(this) = weak_self.upgrade(cx) {
-                                                this.update(cx, |this, cx| this.add_context(cx));
-                                            }
-                                        })
-                                    },
-                                    None,
-                                ))
-                                .with_child(Pane::render_tab_bar_button(
-                                    1,
-                                    if pane.is_zoomed() {
-                                        "icons/minimize_8.svg"
-                                    } else {
-                                        "icons/maximize_8.svg"
-                                    },
-                                    pane.is_zoomed(),
-                                    Some((
-                                        "Toggle Zoom".into(),
-                                        Some(Box::new(workspace::ToggleZoom)),
-                                    )),
-                                    cx,
-                                    move |pane, cx| pane.toggle_zoom(&Default::default(), cx),
-                                    None,
-                                ))
-                                .into_any()
-                        });
-                        let buffer_search_bar = cx.add_view(search::BufferSearchBar::new);
-                        pane.toolbar()
-                            .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx));
-                        pane
+                    const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100);
+                    let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move {
+                        let mut events = fs
+                            .watch(&CONVERSATIONS_DIR, CONVERSATION_WATCH_DURATION)
+                            .await;
+                        while events.next().await.is_some() {
+                            let saved_conversations = SavedConversationMetadata::list(fs.clone())
+                                .await
+                                .log_err()
+                                .unwrap_or_default();
+                            this.update(&mut cx, |this, _| {
+                                this.saved_conversations = saved_conversations
+                            })
+                            .ok();
+                        }
+
+                        anyhow::Ok(())
                     });
 
+                    let toolbar = cx.add_view(|cx| {
+                        let mut toolbar = Toolbar::new(None);
+                        toolbar.set_can_navigate(false, cx);
+                        toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
+                        toolbar
+                    });
                     let mut this = Self {
-                        pane,
+                        workspace: workspace_handle,
+                        active_editor_index: Default::default(),
+                        prev_active_editor_index: Default::default(),
+                        editors: Default::default(),
+                        saved_conversations,
+                        saved_conversations_list_state: Default::default(),
+                        zoomed: false,
+                        has_focus: false,
+                        toolbar,
                         api_key: Rc::new(RefCell::new(None)),
                         api_key_editor: None,
                         has_read_credentials: false,
@@ -177,20 +190,18 @@ impl AssistantPanel {
                         width: None,
                         height: None,
                         subscriptions: Default::default(),
+                        _watch_saved_conversations,
                     };
 
                     let mut old_dock_position = this.position(cx);
-                    this.subscriptions = vec![
-                        cx.observe(&this.pane, |_, _, cx| cx.notify()),
-                        cx.subscribe(&this.pane, Self::handle_pane_event),
-                        cx.observe_global::<SettingsStore, _>(move |this, cx| {
+                    this.subscriptions =
+                        vec![cx.observe_global::<SettingsStore, _>(move |this, cx| {
                             let new_dock_position = this.position(cx);
                             if new_dock_position != old_dock_position {
                                 old_dock_position = new_dock_position;
                                 cx.emit(AssistantPanelEvent::DockPositionChanged);
                             }
-                        }),
-                    ];
+                        })];
 
                     this
                 })
@@ -198,40 +209,64 @@ impl AssistantPanel {
         })
     }
 
-    fn handle_pane_event(
+    fn new_conversation(&mut self, cx: &mut ViewContext<Self>) -> ViewHandle<ConversationEditor> {
+        let editor = cx.add_view(|cx| {
+            ConversationEditor::new(
+                self.api_key.clone(),
+                self.languages.clone(),
+                self.fs.clone(),
+                cx,
+            )
+        });
+        self.add_conversation(editor.clone(), cx);
+        editor
+    }
+
+    fn add_conversation(
         &mut self,
-        _pane: ViewHandle<Pane>,
-        event: &pane::Event,
+        editor: ViewHandle<ConversationEditor>,
         cx: &mut ViewContext<Self>,
     ) {
-        match event {
-            pane::Event::ZoomIn => cx.emit(AssistantPanelEvent::ZoomIn),
-            pane::Event::ZoomOut => cx.emit(AssistantPanelEvent::ZoomOut),
-            pane::Event::Focus => cx.emit(AssistantPanelEvent::Focus),
-            pane::Event::Remove => cx.emit(AssistantPanelEvent::Close),
-            _ => {}
-        }
-    }
+        self.subscriptions
+            .push(cx.subscribe(&editor, Self::handle_conversation_editor_event));
 
-    fn add_context(&mut self, cx: &mut ViewContext<Self>) {
-        let focus = self.has_focus(cx);
-        let editor = cx
-            .add_view(|cx| AssistantEditor::new(self.api_key.clone(), self.languages.clone(), cx));
+        let conversation = editor.read(cx).conversation.clone();
         self.subscriptions
-            .push(cx.subscribe(&editor, Self::handle_assistant_editor_event));
-        self.pane.update(cx, |pane, cx| {
-            pane.add_item(Box::new(editor), true, focus, None, cx)
-        });
+            .push(cx.observe(&conversation, |_, _, cx| cx.notify()));
+
+        let index = self.editors.len();
+        self.editors.push(editor);
+        self.set_active_editor_index(Some(index), cx);
     }
 
-    fn handle_assistant_editor_event(
+    fn set_active_editor_index(&mut self, index: Option<usize>, cx: &mut ViewContext<Self>) {
+        self.prev_active_editor_index = self.active_editor_index;
+        self.active_editor_index = index;
+        if let Some(editor) = self.active_editor() {
+            let editor = editor.read(cx).editor.clone();
+            self.toolbar.update(cx, |toolbar, cx| {
+                toolbar.set_active_item(Some(&editor), cx);
+            });
+            if self.has_focus(cx) {
+                cx.focus(&editor);
+            }
+        } else {
+            self.toolbar.update(cx, |toolbar, cx| {
+                toolbar.set_active_item(None, cx);
+            });
+        }
+
+        cx.notify();
+    }
+
+    fn handle_conversation_editor_event(
         &mut self,
-        _: ViewHandle<AssistantEditor>,
-        event: &AssistantEditorEvent,
+        _: ViewHandle<ConversationEditor>,
+        event: &ConversationEditorEvent,
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            AssistantEditorEvent::TabContentChanged => self.pane.update(cx, |_, cx| cx.notify()),
+            ConversationEditorEvent::TabContentChanged => cx.notify(),
         }
     }
 
@@ -262,6 +297,266 @@ impl AssistantPanel {
         cx.focus_self();
         cx.notify();
     }
+
+    fn toggle_zoom(&mut self, _: &workspace::ToggleZoom, cx: &mut ViewContext<Self>) {
+        if self.zoomed {
+            cx.emit(AssistantPanelEvent::ZoomOut)
+        } else {
+            cx.emit(AssistantPanelEvent::ZoomIn)
+        }
+    }
+
+    fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext<Self>) {
+        if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+            if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) {
+                return;
+            }
+        }
+        cx.propagate_action();
+    }
+
+    fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) {
+        if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+            if !search_bar.read(cx).is_dismissed() {
+                search_bar.update(cx, |search_bar, cx| {
+                    search_bar.dismiss(&Default::default(), cx)
+                });
+                return;
+            }
+        }
+        cx.propagate_action();
+    }
+
+    fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext<Self>) {
+        if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+            search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, cx));
+        }
+    }
+
+    fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext<Self>) {
+        if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
+            search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, cx));
+        }
+    }
+
+    fn active_editor(&self) -> Option<&ViewHandle<ConversationEditor>> {
+        self.editors.get(self.active_editor_index?)
+    }
+
+    fn render_hamburger_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+        enum ListConversations {}
+        let theme = theme::current(cx);
+        MouseEventHandler::<ListConversations, _>::new(0, cx, |state, _| {
+            let style = theme.assistant.hamburger_button.style_for(state);
+            Svg::for_style(style.icon.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+            if this.active_editor().is_some() {
+                this.set_active_editor_index(None, cx);
+            } else {
+                this.set_active_editor_index(this.prev_active_editor_index, cx);
+            }
+        })
+    }
+
+    fn render_editor_tools(&self, cx: &mut ViewContext<Self>) -> Vec<AnyElement<Self>> {
+        if self.active_editor().is_some() {
+            vec![
+                Self::render_split_button(cx).into_any(),
+                Self::render_quote_button(cx).into_any(),
+                Self::render_assist_button(cx).into_any(),
+            ]
+        } else {
+            Default::default()
+        }
+    }
+
+    fn render_split_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+        let theme = theme::current(cx);
+        let tooltip_style = theme::current(cx).tooltip.clone();
+        MouseEventHandler::<Split, _>::new(0, cx, |state, _| {
+            let style = theme.assistant.split_button.style_for(state);
+            Svg::for_style(style.icon.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+            if let Some(active_editor) = this.active_editor() {
+                active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx));
+            }
+        })
+        .with_tooltip::<Split>(
+            1,
+            "Split Message".into(),
+            Some(Box::new(Split)),
+            tooltip_style,
+            cx,
+        )
+    }
+
+    fn render_assist_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+        let theme = theme::current(cx);
+        let tooltip_style = theme::current(cx).tooltip.clone();
+        MouseEventHandler::<Assist, _>::new(0, cx, |state, _| {
+            let style = theme.assistant.assist_button.style_for(state);
+            Svg::for_style(style.icon.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+            if let Some(active_editor) = this.active_editor() {
+                active_editor.update(cx, |editor, cx| editor.assist(&Default::default(), cx));
+            }
+        })
+        .with_tooltip::<Assist>(
+            1,
+            "Assist".into(),
+            Some(Box::new(Assist)),
+            tooltip_style,
+            cx,
+        )
+    }
+
+    fn render_quote_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+        let theme = theme::current(cx);
+        let tooltip_style = theme::current(cx).tooltip.clone();
+        MouseEventHandler::<QuoteSelection, _>::new(0, cx, |state, _| {
+            let style = theme.assistant.quote_button.style_for(state);
+            Svg::for_style(style.icon.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+            if let Some(workspace) = this.workspace.upgrade(cx) {
+                cx.window_context().defer(move |cx| {
+                    workspace.update(cx, |workspace, cx| {
+                        ConversationEditor::quote_selection(workspace, &Default::default(), cx)
+                    });
+                });
+            }
+        })
+        .with_tooltip::<QuoteSelection>(
+            1,
+            "Assist".into(),
+            Some(Box::new(QuoteSelection)),
+            tooltip_style,
+            cx,
+        )
+    }
+
+    fn render_plus_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
+        enum AddConversation {}
+        let theme = theme::current(cx);
+        MouseEventHandler::<AddConversation, _>::new(0, cx, |state, _| {
+            let style = theme.assistant.plus_button.style_for(state);
+            Svg::for_style(style.icon.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, this: &mut Self, cx| {
+            this.new_conversation(cx);
+        })
+    }
+
+    fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl Element<Self> {
+        enum ToggleZoomButton {}
+
+        let theme = theme::current(cx);
+        let style = if self.zoomed {
+            &theme.assistant.zoom_out_button
+        } else {
+            &theme.assistant.zoom_in_button
+        };
+
+        MouseEventHandler::<ToggleZoomButton, _>::new(0, cx, |state, _| {
+            let style = style.style_for(state);
+            Svg::for_style(style.icon.clone())
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, this, cx| {
+            this.toggle_zoom(&ToggleZoom, cx);
+        })
+    }
+
+    fn render_saved_conversation(
+        &mut self,
+        index: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> impl Element<Self> {
+        let conversation = &self.saved_conversations[index];
+        let path = conversation.path.clone();
+        MouseEventHandler::<SavedConversationMetadata, _>::new(index, cx, move |state, cx| {
+            let style = &theme::current(cx).assistant.saved_conversation;
+            Flex::row()
+                .with_child(
+                    Label::new(
+                        conversation.mtime.format("%F %I:%M%p").to_string(),
+                        style.saved_at.text.clone(),
+                    )
+                    .aligned()
+                    .contained()
+                    .with_style(style.saved_at.container),
+                )
+                .with_child(
+                    Label::new(conversation.title.clone(), style.title.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(style.title.container),
+                )
+                .contained()
+                .with_style(*style.container.style_for(state))
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.open_conversation(path.clone(), cx)
+                .detach_and_log_err(cx)
+        })
+    }
+
+    fn open_conversation(&mut self, path: PathBuf, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+        if let Some(ix) = self.editor_index_for_path(&path, cx) {
+            self.set_active_editor_index(Some(ix), cx);
+            return Task::ready(Ok(()));
+        }
+
+        let fs = self.fs.clone();
+        let api_key = self.api_key.clone();
+        let languages = self.languages.clone();
+        cx.spawn(|this, mut cx| async move {
+            let saved_conversation = fs.load(&path).await?;
+            let saved_conversation = serde_json::from_str(&saved_conversation)?;
+            let conversation = cx.add_model(|cx| {
+                Conversation::deserialize(saved_conversation, path.clone(), api_key, languages, cx)
+            });
+            this.update(&mut cx, |this, cx| {
+                // If, by the time we've loaded the conversation, the user has already opened
+                // the same conversation, we don't want to open it again.
+                if let Some(ix) = this.editor_index_for_path(&path, cx) {
+                    this.set_active_editor_index(Some(ix), cx);
+                } else {
+                    let editor = cx
+                        .add_view(|cx| ConversationEditor::for_conversation(conversation, fs, cx));
+                    this.add_conversation(editor, cx);
+                }
+            })?;
+            Ok(())
+        })
+    }
+
+    fn editor_index_for_path(&self, path: &Path, cx: &AppContext) -> Option<usize> {
+        self.editors
+            .iter()
+            .position(|editor| editor.read(cx).conversation.read(cx).path.as_deref() == Some(path))
+    }
 }
 
 fn build_api_key_editor(cx: &mut ViewContext<AssistantPanel>) -> ViewHandle<Editor> {
@@ -285,7 +580,8 @@ impl View for AssistantPanel {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let style = &theme::current(cx).assistant;
+        let theme = &theme::current(cx);
+        let style = &theme.assistant;
         if let Some(api_key_editor) = self.api_key_editor.as_ref() {
             Flex::column()
                 .with_child(
@@ -306,19 +602,76 @@ impl View for AssistantPanel {
                 .aligned()
                 .into_any()
         } else {
-            ChildView::new(&self.pane, cx).into_any()
+            let title = self.active_editor().map(|editor| {
+                Label::new(editor.read(cx).title(cx), style.title.text.clone())
+                    .contained()
+                    .with_style(style.title.container)
+                    .aligned()
+                    .left()
+                    .flex(1., false)
+            });
+
+            Flex::column()
+                .with_child(
+                    Flex::row()
+                        .with_child(Self::render_hamburger_button(cx).aligned())
+                        .with_children(title)
+                        .with_children(
+                            self.render_editor_tools(cx)
+                                .into_iter()
+                                .map(|tool| tool.aligned().flex_float()),
+                        )
+                        .with_child(Self::render_plus_button(cx).aligned().flex_float())
+                        .with_child(self.render_zoom_button(cx).aligned())
+                        .contained()
+                        .with_style(theme.workspace.tab_bar.container)
+                        .expanded()
+                        .constrained()
+                        .with_height(theme.workspace.tab_bar.height),
+                )
+                .with_children(if self.toolbar.read(cx).hidden() {
+                    None
+                } else {
+                    Some(ChildView::new(&self.toolbar, cx).expanded())
+                })
+                .with_child(if let Some(editor) = self.active_editor() {
+                    ChildView::new(editor, cx).flex(1., true).into_any()
+                } else {
+                    UniformList::new(
+                        self.saved_conversations_list_state.clone(),
+                        self.saved_conversations.len(),
+                        cx,
+                        |this, range, items, cx| {
+                            for ix in range {
+                                items.push(this.render_saved_conversation(ix, cx).into_any());
+                            }
+                        },
+                    )
+                    .flex(1., true)
+                    .into_any()
+                })
+                .into_any()
         }
     }
 
     fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        self.toolbar
+            .update(cx, |toolbar, cx| toolbar.focus_changed(true, cx));
         if cx.is_self_focused() {
-            if let Some(api_key_editor) = self.api_key_editor.as_ref() {
+            if let Some(editor) = self.active_editor() {
+                cx.focus(editor);
+            } else if let Some(api_key_editor) = self.api_key_editor.as_ref() {
                 cx.focus(api_key_editor);
-            } else {
-                cx.focus(&self.pane);
             }
         }
     }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = false;
+        self.toolbar
+            .update(cx, |toolbar, cx| toolbar.focus_changed(false, cx));
+    }
 }
 
 impl Panel for AssistantPanel {
@@ -371,19 +724,22 @@ impl Panel for AssistantPanel {
         matches!(event, AssistantPanelEvent::ZoomOut)
     }
 
-    fn is_zoomed(&self, cx: &WindowContext) -> bool {
-        self.pane.read(cx).is_zoomed()
+    fn is_zoomed(&self, _: &WindowContext) -> bool {
+        self.zoomed
     }
 
     fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>) {
-        self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx));
+        self.zoomed = zoomed;
+        cx.notify();
     }
 
     fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
         if active {
             if self.api_key.borrow().is_none() && !self.has_read_credentials {
                 self.has_read_credentials = true;
-                let api_key = if let Some((_, api_key)) = cx
+                let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") {
+                    Some(api_key)
+                } else if let Some((_, api_key)) = cx
                     .platform()
                     .read_credentials(OPENAI_API_URL)
                     .log_err()
@@ -401,8 +757,8 @@ impl Panel for AssistantPanel {
                 }
             }
 
-            if self.pane.read(cx).items_len() == 0 {
-                self.add_context(cx);
+            if self.editors.is_empty() {
+                self.new_conversation(cx);
             }
         }
     }
@@ -427,12 +783,8 @@ impl Panel for AssistantPanel {
         matches!(event, AssistantPanelEvent::Close)
     }
 
-    fn has_focus(&self, cx: &WindowContext) -> bool {
-        self.pane.read(cx).has_focus()
-            || self
-                .api_key_editor
-                .as_ref()
-                .map_or(false, |editor| editor.is_focused(cx))
+    fn has_focus(&self, _: &WindowContext) -> bool {
+        self.has_focus
     }
 
     fn is_focus_event(event: &Self::Event) -> bool {
@@ -440,18 +792,24 @@ impl Panel for AssistantPanel {
     }
 }
 
-enum AssistantEvent {
+enum ConversationEvent {
     MessagesEdited,
     SummaryChanged,
     StreamedCompletion,
 }
 
-struct Assistant {
+#[derive(Default)]
+struct Summary {
+    text: String,
+    done: bool,
+}
+
+struct Conversation {
     buffer: ModelHandle<Buffer>,
     message_anchors: Vec<MessageAnchor>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     next_message_id: MessageId,
-    summary: Option<String>,
+    summary: Option<Summary>,
     pending_summary: Task<Option<()>>,
     completion_count: usize,
     pending_completions: Vec<PendingCompletion>,
@@ -460,14 +818,16 @@ struct Assistant {
     max_token_count: usize,
     pending_token_count: Task<Option<()>>,
     api_key: Rc<RefCell<Option<String>>>,
+    pending_save: Task<Result<()>>,
+    path: Option<PathBuf>,
     _subscriptions: Vec<Subscription>,
 }
 
-impl Entity for Assistant {
-    type Event = AssistantEvent;
+impl Entity for Conversation {
+    type Event = ConversationEvent;
 }
 
-impl Assistant {
+impl Conversation {
     fn new(
         api_key: Rc<RefCell<Option<String>>>,
         language_registry: Arc<LanguageRegistry>,
@@ -505,6 +865,8 @@ impl Assistant {
             pending_token_count: Task::ready(None),
             model: model.into(),
             _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
+            pending_save: Task::ready(Ok(())),
+            path: None,
             api_key,
             buffer,
         };
@@ -526,6 +888,88 @@ impl Assistant {
         this
     }
 
+    fn serialize(&self, cx: &AppContext) -> SavedConversation {
+        SavedConversation {
+            zed: "conversation".into(),
+            version: SavedConversation::VERSION.into(),
+            text: self.buffer.read(cx).text(),
+            message_metadata: self.messages_metadata.clone(),
+            messages: self
+                .messages(cx)
+                .map(|message| SavedMessage {
+                    id: message.id,
+                    start: message.offset_range.start,
+                })
+                .collect(),
+            summary: self
+                .summary
+                .as_ref()
+                .map(|summary| summary.text.clone())
+                .unwrap_or_default(),
+            model: self.model.clone(),
+        }
+    }
+
+    fn deserialize(
+        saved_conversation: SavedConversation,
+        path: PathBuf,
+        api_key: Rc<RefCell<Option<String>>>,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let model = saved_conversation.model;
+        let markdown = language_registry.language_for_name("Markdown");
+        let mut message_anchors = Vec::new();
+        let mut next_message_id = MessageId(0);
+        let buffer = cx.add_model(|cx| {
+            let mut buffer = Buffer::new(0, saved_conversation.text, cx);
+            for message in saved_conversation.messages {
+                message_anchors.push(MessageAnchor {
+                    id: message.id,
+                    start: buffer.anchor_before(message.start),
+                });
+                next_message_id = cmp::max(next_message_id, MessageId(message.id.0 + 1));
+            }
+            buffer.set_language_registry(language_registry);
+            cx.spawn_weak(|buffer, mut cx| async move {
+                let markdown = markdown.await?;
+                let buffer = buffer
+                    .upgrade(&cx)
+                    .ok_or_else(|| anyhow!("buffer was dropped"))?;
+                buffer.update(&mut cx, |buffer, cx| {
+                    buffer.set_language(Some(markdown), cx)
+                });
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+            buffer
+        });
+
+        let mut this = Self {
+            message_anchors,
+            messages_metadata: saved_conversation.message_metadata,
+            next_message_id,
+            summary: Some(Summary {
+                text: saved_conversation.summary,
+                done: true,
+            }),
+            pending_summary: Task::ready(None),
+            completion_count: Default::default(),
+            pending_completions: Default::default(),
+            token_count: None,
+            max_token_count: tiktoken_rs::model::get_context_size(&model),
+            pending_token_count: Task::ready(None),
+            model,
+            _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
+            pending_save: Task::ready(Ok(())),
+            path: Some(path),
+            api_key,
+            buffer,
+        };
+        this.count_remaining_tokens(cx);
+        this
+    }
+
     fn handle_buffer_event(
         &mut self,
         _: ModelHandle<Buffer>,
@@ -535,7 +979,7 @@ impl Assistant {
         match event {
             language::Event::Edited => {
                 self.count_remaining_tokens(cx);
-                cx.emit(AssistantEvent::MessagesEdited);
+                cx.emit(ConversationEvent::MessagesEdited);
             }
             _ => {}
         }
@@ -552,7 +996,11 @@ impl Assistant {
                         Role::Assistant => "assistant".into(),
                         Role::System => "system".into(),
                     },
-                    content: self.buffer.read(cx).text_for_range(message.range).collect(),
+                    content: self
+                        .buffer
+                        .read(cx)
+                        .text_for_range(message.offset_range)
+                        .collect(),
                     name: None,
                 })
             })
@@ -567,7 +1015,7 @@ impl Assistant {
                     .await?;
 
                 this.upgrade(&cx)
-                    .ok_or_else(|| anyhow!("assistant was dropped"))?
+                    .ok_or_else(|| anyhow!("conversation was dropped"))?
                     .update(&mut cx, |this, cx| {
                         this.max_token_count = tiktoken_rs::model::get_context_size(&this.model);
                         this.token_count = Some(token_count);
@@ -596,6 +1044,14 @@ impl Assistant {
     ) -> Vec<MessageAnchor> {
         let mut user_messages = Vec::new();
         let mut tasks = Vec::new();
+
+        let last_message_id = self.message_anchors.iter().rev().find_map(|message| {
+            message
+                .start
+                .is_valid(self.buffer.read(cx))
+                .then_some(message.id)
+        });
+
         for selected_message_id in selected_messages {
             let selected_message_role =
                 if let Some(metadata) = self.messages_metadata.get(&selected_message_id) {
@@ -658,6 +1114,19 @@ impl Assistant {
                     )
                     .unwrap();
 
+                // Queue up the user's next reply
+                if Some(selected_message_id) == last_message_id {
+                    let user_message = self
+                        .insert_message_after(
+                            assistant_message.id,
+                            Role::User,
+                            MessageStatus::Done,
+                            cx,
+                        )
+                        .unwrap();
+                    user_messages.push(user_message);
+                }
+
                 tasks.push(cx.spawn_weak({
                     |this, mut cx| async move {
                         let assistant_message_id = assistant_message.id;
@@ -668,7 +1137,7 @@ impl Assistant {
                                 let mut message = message?;
                                 if let Some(choice) = message.choices.pop() {
                                     this.upgrade(&cx)
-                                        .ok_or_else(|| anyhow!("assistant was dropped"))?
+                                        .ok_or_else(|| anyhow!("conversation was dropped"))?
                                         .update(&mut cx, |this, cx| {
                                             let text: Arc<str> = choice.delta.content?.into();
                                             let message_ix = this.message_anchors.iter().position(
@@ -686,7 +1155,7 @@ impl Assistant {
                                                     });
                                                 buffer.edit([(offset..offset, text)], None, cx);
                                             });
-                                            cx.emit(AssistantEvent::StreamedCompletion);
+                                            cx.emit(ConversationEvent::StreamedCompletion);
 
                                             Some(())
                                         });
@@ -695,7 +1164,7 @@ impl Assistant {
                             }
 
                             this.upgrade(&cx)
-                                .ok_or_else(|| anyhow!("assistant was dropped"))?
+                                .ok_or_else(|| anyhow!("conversation was dropped"))?
                                 .update(&mut cx, |this, cx| {
                                     this.pending_completions.retain(|completion| {
                                         completion.id != this.completion_count
@@ -749,7 +1218,7 @@ impl Assistant {
         for id in ids {
             if let Some(metadata) = self.messages_metadata.get_mut(&id) {
                 metadata.role.cycle();
-                cx.emit(AssistantEvent::MessagesEdited);
+                cx.emit(ConversationEvent::MessagesEdited);
                 cx.notify();
             }
         }
@@ -767,10 +1236,19 @@ impl Assistant {
             .iter()
             .position(|message| message.id == message_id)
         {
+            // Find the next valid message after the one we were given.
+            let mut next_message_ix = prev_message_ix + 1;
+            while let Some(next_message) = self.message_anchors.get(next_message_ix) {
+                if next_message.start.is_valid(self.buffer.read(cx)) {
+                    break;
+                }
+                next_message_ix += 1;
+            }
+
             let start = self.buffer.update(cx, |buffer, cx| {
-                let offset = self.message_anchors[prev_message_ix + 1..]
-                    .iter()
-                    .find(|message| message.start.is_valid(buffer))
+                let offset = self
+                    .message_anchors
+                    .get(next_message_ix)
                     .map_or(buffer.len(), |message| message.start.to_offset(buffer) - 1);
                 buffer.edit([(offset..offset, "\n")], None, cx);
                 buffer.anchor_before(offset + 1)

crates/gpui/src/elements/label.rs 🔗

@@ -164,6 +164,7 @@ impl<V: View> Element<V> for Label {
         _: &mut V,
         cx: &mut ViewContext<V>,
     ) -> Self::PaintState {
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
         line.paint(
             scene,
             bounds.origin(),

crates/gpui/src/elements/svg.rs 🔗

@@ -1,7 +1,5 @@
-use std::{borrow::Cow, ops::Range};
-
-use serde_json::json;
-
+use super::constrain_size_preserving_aspect_ratio;
+use crate::json::ToJson;
 use crate::{
     color::Color,
     geometry::{
@@ -10,6 +8,10 @@ use crate::{
     },
     scene, Element, LayoutContext, SceneBuilder, SizeConstraint, View, ViewContext,
 };
+use schemars::JsonSchema;
+use serde_derive::Deserialize;
+use serde_json::json;
+use std::{borrow::Cow, ops::Range};
 
 pub struct Svg {
     path: Cow<'static, str>,
@@ -24,6 +26,14 @@ impl Svg {
         }
     }
 
+    pub fn for_style<V: View>(style: SvgStyle) -> impl Element<V> {
+        Self::new(style.asset)
+            .with_color(style.color)
+            .constrained()
+            .with_width(style.dimensions.width)
+            .with_height(style.dimensions.height)
+    }
+
     pub fn with_color(mut self, color: Color) -> Self {
         self.color = color;
         self
@@ -105,9 +115,24 @@ impl<V: View> Element<V> for Svg {
     }
 }
 
-use crate::json::ToJson;
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct SvgStyle {
+    pub color: Color,
+    pub asset: String,
+    pub dimensions: Dimensions,
+}
 
-use super::constrain_size_preserving_aspect_ratio;
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct Dimensions {
+    pub width: f32,
+    pub height: f32,
+}
+
+impl Dimensions {
+    pub fn to_vec(&self) -> Vector2F {
+        vec2f(self.width, self.height)
+    }
+}
 
 fn from_usvg_rect(rect: usvg::Rect) -> RectF {
     RectF::new(

crates/search/src/buffer_search.rs 🔗

@@ -259,7 +259,11 @@ impl BufferSearchBar {
         }
     }
 
-    fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
+    pub fn is_dismissed(&self) -> bool {
+        self.dismissed
+    }
+
+    pub fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
         self.dismissed = true;
         for searchable_item in self.seachable_items_with_matches.keys() {
             if let Some(searchable_item) =
@@ -275,7 +279,7 @@ impl BufferSearchBar {
         cx.notify();
     }
 
-    fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
+    pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
         let searchable_item = if let Some(searchable_item) = &self.active_searchable_item {
             SearchableItemHandle::boxed_clone(searchable_item.as_ref())
         } else {
@@ -484,7 +488,7 @@ impl BufferSearchBar {
         self.select_match(Direction::Prev, cx);
     }
 
-    fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+    pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
         if let Some(index) = self.active_match_index {
             if let Some(searchable_item) = self.active_searchable_item.as_ref() {
                 if let Some(matches) = self

crates/theme/src/theme.rs 🔗

@@ -4,7 +4,7 @@ pub mod ui;
 
 use gpui::{
     color::Color,
-    elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, TooltipStyle},
+    elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, SvgStyle, TooltipStyle},
     fonts::{HighlightStyle, TextStyle},
     platform, AppContext, AssetSource, Border, MouseState,
 };
@@ -12,7 +12,7 @@ use serde::{de::DeserializeOwned, Deserialize};
 use serde_json::Value;
 use settings::SettingsStore;
 use std::{collections::HashMap, sync::Arc};
-use ui::{ButtonStyle, CheckboxStyle, IconStyle, ModalStyle, SvgStyle};
+use ui::{ButtonStyle, CheckboxStyle, IconStyle, ModalStyle};
 
 pub use theme_registry::*;
 pub use theme_settings::*;
@@ -992,18 +992,33 @@ pub struct TerminalStyle {
 #[derive(Clone, Deserialize, Default)]
 pub struct AssistantStyle {
     pub container: ContainerStyle,
-    pub header: ContainerStyle,
+    pub hamburger_button: Interactive<IconStyle>,
+    pub split_button: Interactive<IconStyle>,
+    pub assist_button: Interactive<IconStyle>,
+    pub quote_button: Interactive<IconStyle>,
+    pub zoom_in_button: Interactive<IconStyle>,
+    pub zoom_out_button: Interactive<IconStyle>,
+    pub plus_button: Interactive<IconStyle>,
+    pub title: ContainedText,
+    pub message_header: ContainerStyle,
     pub sent_at: ContainedText,
     pub user_sender: Interactive<ContainedText>,
     pub assistant_sender: Interactive<ContainedText>,
     pub system_sender: Interactive<ContainedText>,
-    pub model_info_container: ContainerStyle,
     pub model: Interactive<ContainedText>,
     pub remaining_tokens: ContainedText,
     pub no_remaining_tokens: ContainedText,
     pub error_icon: Icon,
     pub api_key_editor: FieldEditor,
     pub api_key_prompt: ContainedText,
+    pub saved_conversation: SavedConversation,
+}
+
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct SavedConversation {
+    pub container: Interactive<ContainerStyle>,
+    pub saved_at: ContainedText,
+    pub title: ContainedText,
 }
 
 #[derive(Clone, Deserialize, Default)]

crates/theme/src/ui.rs 🔗

@@ -1,13 +1,12 @@
 use std::borrow::Cow;
 
 use gpui::{
-    color::Color,
     elements::{
-        ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label,
-        MouseEventHandler, ParentElement, Stack, Svg,
+        ConstrainedBox, Container, ContainerStyle, Dimensions, Empty, Flex, KeystrokeLabel, Label,
+        MouseEventHandler, ParentElement, Stack, Svg, SvgStyle,
     },
     fonts::TextStyle,
-    geometry::vector::{vec2f, Vector2F},
+    geometry::vector::Vector2F,
     platform,
     platform::MouseButton,
     scene::MouseClick,
@@ -93,25 +92,6 @@ where
     .with_cursor_style(platform::CursorStyle::PointingHand)
 }
 
-#[derive(Clone, Deserialize, Default)]
-pub struct SvgStyle {
-    pub color: Color,
-    pub asset: String,
-    pub dimensions: Dimensions,
-}
-
-#[derive(Clone, Deserialize, Default)]
-pub struct Dimensions {
-    pub width: f32,
-    pub height: f32,
-}
-
-impl Dimensions {
-    pub fn to_vec(&self) -> Vector2F {
-        vec2f(self.width, self.height)
-    }
-}
-
 pub fn svg<V: View>(style: &SvgStyle) -> ConstrainedBox<V> {
     Svg::new(style.asset.clone())
         .with_color(style.color)
@@ -122,8 +102,8 @@ pub fn svg<V: View>(style: &SvgStyle) -> ConstrainedBox<V> {
 
 #[derive(Clone, Deserialize, Default)]
 pub struct IconStyle {
-    icon: SvgStyle,
-    container: ContainerStyle,
+    pub icon: SvgStyle,
+    pub container: ContainerStyle,
 }
 
 pub fn icon<V: View>(style: &IconStyle) -> Container<V> {

crates/util/src/paths.rs 🔗

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
 lazy_static::lazy_static! {
     pub static ref HOME: PathBuf = dirs::home_dir().expect("failed to determine home directory");
     pub static ref CONFIG_DIR: PathBuf = HOME.join(".config").join("zed");
+    pub static ref CONVERSATIONS_DIR: PathBuf = HOME.join(".config/zed/conversations");
     pub static ref LOGS_DIR: PathBuf = HOME.join("Library/Logs/Zed");
     pub static ref SUPPORT_DIR: PathBuf = HOME.join("Library/Application Support/Zed");
     pub static ref LANGUAGES_DIR: PathBuf = HOME.join("Library/Application Support/Zed/languages");

crates/workspace/src/dock.rs 🔗

@@ -249,7 +249,7 @@ impl Dock {
         }
     }
 
-    pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
+    pub(crate) fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
         let subscriptions = [
             cx.observe(&panel, |_, _, cx| cx.notify()),
             cx.subscribe(&panel, |this, panel, event, cx| {
@@ -605,6 +605,7 @@ pub mod test {
     use super::*;
     use gpui::{ViewContext, WindowContext};
 
+    #[derive(Debug)]
     pub enum TestPanelEvent {
         PositionChanged,
         Activated,

crates/workspace/src/pane.rs 🔗

@@ -1,9 +1,10 @@
 mod dragged_item_receiver;
 
 use super::{ItemHandle, SplitDirection};
+pub use crate::toolbar::Toolbar;
 use crate::{
-    item::WeakItemHandle, notify_of_new_dock, toolbar::Toolbar, AutosaveSetting, Item,
-    NewCenterTerminal, NewFile, NewSearch, ToggleZoom, Workspace, WorkspaceSettings,
+    item::WeakItemHandle, notify_of_new_dock, AutosaveSetting, Item, NewCenterTerminal, NewFile,
+    NewSearch, ToggleZoom, Workspace, WorkspaceSettings,
 };
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
@@ -250,7 +251,7 @@ impl Pane {
                 pane: handle.clone(),
                 next_timestamp,
             }))),
-            toolbar: cx.add_view(|_| Toolbar::new(handle)),
+            toolbar: cx.add_view(|_| Toolbar::new(Some(handle))),
             tab_bar_context_menu: TabBarContextMenu {
                 kind: TabBarContextMenuKind::New,
                 handle: context_menu,
@@ -1112,7 +1113,7 @@ impl Pane {
             .get(self.active_item_index)
             .map(|item| item.as_ref());
         self.toolbar.update(cx, |toolbar, cx| {
-            toolbar.set_active_pane_item(active_item, cx);
+            toolbar.set_active_item(active_item, cx);
         });
     }
 
@@ -1602,7 +1603,7 @@ impl View for Pane {
         }
 
         self.toolbar.update(cx, |toolbar, cx| {
-            toolbar.pane_focus_update(true, cx);
+            toolbar.focus_changed(true, cx);
         });
 
         if let Some(active_item) = self.active_item() {
@@ -1631,7 +1632,7 @@ impl View for Pane {
     fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
         self.has_focus = false;
         self.toolbar.update(cx, |toolbar, cx| {
-            toolbar.pane_focus_update(false, cx);
+            toolbar.focus_changed(false, cx);
         });
         cx.notify();
     }

crates/workspace/src/toolbar.rs 🔗

@@ -38,7 +38,7 @@ trait ToolbarItemViewHandle {
         active_pane_item: Option<&dyn ItemHandle>,
         cx: &mut WindowContext,
     ) -> ToolbarItemLocation;
-    fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext);
+    fn focus_changed(&mut self, pane_focused: bool, cx: &mut WindowContext);
     fn row_count(&self, cx: &WindowContext) -> usize;
 }
 
@@ -51,10 +51,10 @@ pub enum ToolbarItemLocation {
 }
 
 pub struct Toolbar {
-    active_pane_item: Option<Box<dyn ItemHandle>>,
+    active_item: Option<Box<dyn ItemHandle>>,
     hidden: bool,
     can_navigate: bool,
-    pane: WeakViewHandle<Pane>,
+    pane: Option<WeakViewHandle<Pane>>,
     items: Vec<(Box<dyn ToolbarItemViewHandle>, ToolbarItemLocation)>,
 }
 
@@ -121,7 +121,7 @@ impl View for Toolbar {
         let pane = self.pane.clone();
         let mut enable_go_backward = false;
         let mut enable_go_forward = false;
-        if let Some(pane) = pane.upgrade(cx) {
+        if let Some(pane) = pane.and_then(|pane| pane.upgrade(cx)) {
             let pane = pane.read(cx);
             enable_go_backward = pane.can_navigate_backward();
             enable_go_forward = pane.can_navigate_forward();
@@ -143,19 +143,17 @@ impl View for Toolbar {
                 enable_go_backward,
                 spacing,
                 {
-                    let pane = pane.clone();
                     move |toolbar, cx| {
-                        if let Some(workspace) = toolbar
-                            .pane
-                            .upgrade(cx)
-                            .and_then(|pane| pane.read(cx).workspace().upgrade(cx))
+                        if let Some(pane) = toolbar.pane.as_ref().and_then(|pane| pane.upgrade(cx))
                         {
-                            let pane = pane.clone();
-                            cx.window_context().defer(move |cx| {
-                                workspace.update(cx, |workspace, cx| {
-                                    workspace.go_back(pane.clone(), cx).detach_and_log_err(cx);
-                                });
-                            })
+                            if let Some(workspace) = pane.read(cx).workspace().upgrade(cx) {
+                                let pane = pane.downgrade();
+                                cx.window_context().defer(move |cx| {
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.go_back(pane, cx).detach_and_log_err(cx);
+                                    });
+                                })
+                            }
                         }
                     }
                 },
@@ -171,21 +169,17 @@ impl View for Toolbar {
                 enable_go_forward,
                 spacing,
                 {
-                    let pane = pane.clone();
                     move |toolbar, cx| {
-                        if let Some(workspace) = toolbar
-                            .pane
-                            .upgrade(cx)
-                            .and_then(|pane| pane.read(cx).workspace().upgrade(cx))
+                        if let Some(pane) = toolbar.pane.as_ref().and_then(|pane| pane.upgrade(cx))
                         {
-                            let pane = pane.clone();
-                            cx.window_context().defer(move |cx| {
-                                workspace.update(cx, |workspace, cx| {
-                                    workspace
-                                        .go_forward(pane.clone(), cx)
-                                        .detach_and_log_err(cx);
-                                });
-                            });
+                            if let Some(workspace) = pane.read(cx).workspace().upgrade(cx) {
+                                let pane = pane.downgrade();
+                                cx.window_context().defer(move |cx| {
+                                    workspace.update(cx, |workspace, cx| {
+                                        workspace.go_forward(pane, cx).detach_and_log_err(cx);
+                                    });
+                                })
+                            }
                         }
                     }
                 },
@@ -269,9 +263,9 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>
 }
 
 impl Toolbar {
-    pub fn new(pane: WeakViewHandle<Pane>) -> Self {
+    pub fn new(pane: Option<WeakViewHandle<Pane>>) -> Self {
         Self {
-            active_pane_item: None,
+            active_item: None,
             pane,
             items: Default::default(),
             hidden: false,
@@ -288,7 +282,7 @@ impl Toolbar {
     where
         T: 'static + ToolbarItemView,
     {
-        let location = item.set_active_pane_item(self.active_pane_item.as_deref(), cx);
+        let location = item.set_active_pane_item(self.active_item.as_deref(), cx);
         cx.subscribe(&item, |this, item, event, cx| {
             if let Some((_, current_location)) =
                 this.items.iter_mut().find(|(i, _)| i.id() == item.id())
@@ -307,20 +301,16 @@ impl Toolbar {
         cx.notify();
     }
 
-    pub fn set_active_pane_item(
-        &mut self,
-        pane_item: Option<&dyn ItemHandle>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.active_pane_item = pane_item.map(|item| item.boxed_clone());
+    pub fn set_active_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
+        self.active_item = item.map(|item| item.boxed_clone());
         self.hidden = self
-            .active_pane_item
+            .active_item
             .as_ref()
             .map(|item| !item.show_toolbar(cx))
             .unwrap_or(false);
 
         for (toolbar_item, current_location) in self.items.iter_mut() {
-            let new_location = toolbar_item.set_active_pane_item(pane_item, cx);
+            let new_location = toolbar_item.set_active_pane_item(item, cx);
             if new_location != *current_location {
                 *current_location = new_location;
                 cx.notify();
@@ -328,9 +318,9 @@ impl Toolbar {
         }
     }
 
-    pub fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut ViewContext<Self>) {
+    pub fn focus_changed(&mut self, focused: bool, cx: &mut ViewContext<Self>) {
         for (toolbar_item, _) in self.items.iter_mut() {
-            toolbar_item.pane_focus_update(pane_focused, cx);
+            toolbar_item.focus_changed(focused, cx);
         }
     }
 
@@ -364,7 +354,7 @@ impl<T: ToolbarItemView> ToolbarItemViewHandle for ViewHandle<T> {
         })
     }
 
-    fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext) {
+    fn focus_changed(&mut self, pane_focused: bool, cx: &mut WindowContext) {
         self.update(cx, |this, cx| {
             this.pane_focus_update(pane_focused, cx);
             cx.notify();

crates/workspace/src/workspace.rs 🔗

@@ -861,7 +861,10 @@ impl Workspace {
         &self.right_dock
     }
 
-    pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
+    pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>)
+    where
+        T::Event: std::fmt::Debug,
+    {
         let dock = match panel.position(cx) {
             DockPosition::Left => &self.left_dock,
             DockPosition::Bottom => &self.bottom_dock,
@@ -904,10 +907,11 @@ impl Workspace {
                     });
                 } else if T::should_zoom_in_on_event(event) {
                     dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, true, cx));
-                    if panel.has_focus(cx) {
-                        this.zoomed = Some(panel.downgrade().into_any());
-                        this.zoomed_position = Some(panel.read(cx).position(cx));
+                    if !panel.has_focus(cx) {
+                        cx.focus(&panel);
                     }
+                    this.zoomed = Some(panel.downgrade().into_any());
+                    this.zoomed_position = Some(panel.read(cx).position(cx));
                 } else if T::should_zoom_out_on_event(event) {
                     dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, false, cx));
                     if this.zoomed_position == Some(prev_position) {
@@ -1702,6 +1706,11 @@ impl Workspace {
         cx.notify();
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn zoomed_view(&self, cx: &AppContext) -> Option<AnyViewHandle> {
+        self.zoomed.and_then(|view| view.upgrade(cx))
+    }
+
     fn dismiss_zoomed_items_to_reveal(
         &mut self,
         dock_to_reveal: Option<DockPosition>,

styles/src/styleTree/assistant.ts 🔗

@@ -10,11 +10,190 @@ export default function assistant(colorScheme: ColorScheme) {
             background: editor(colorScheme).background,
             padding: { left: 12 },
         },
-        header: {
+        messageHeader: {
             border: border(layer, "default", { bottom: true, top: true }),
             margin: { bottom: 6, top: 6 },
             background: editor(colorScheme).background,
         },
+        hamburgerButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/hamburger_15.svg",
+                    dimensions: {
+                        width: 15,
+                        height: 15,
+                    },
+                },
+                container: {
+                    margin: { left: 12 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        splitButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/split_message_15.svg",
+                    dimensions: {
+                        width: 15,
+                        height: 15,
+                    },
+                },
+                container: {
+                    margin: { left: 12 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        quoteButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/quote_15.svg",
+                    dimensions: {
+                        width: 15,
+                        height: 15,
+                    },
+                },
+                container: {
+                    margin: { left: 12 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        assistButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/assist_15.svg",
+                    dimensions: {
+                        width: 15,
+                        height: 15,
+                    },
+                },
+                container: {
+                    margin: { left: 12, right: 24 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        zoomInButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/maximize_8.svg",
+                    dimensions: {
+                        width: 12,
+                        height: 12,
+                    },
+                },
+                container: {
+                    margin: { right: 12 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        zoomOutButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/minimize_8.svg",
+                    dimensions: {
+                        width: 12,
+                        height: 12,
+                    },
+                },
+                container: {
+                    margin: { right: 12 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        plusButton: interactive({
+            base: {
+                icon: {
+                    color: foreground(layer, "variant"),
+                    asset: "icons/plus_12.svg",
+                    dimensions: {
+                        width: 12,
+                        height: 12,
+                    },
+                },
+                container: {
+                    margin: { right: 12 },
+                }
+            },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered")
+                    }
+                }
+            }
+        }),
+        title: {
+            margin: { left: 12 },
+            ...text(layer, "sans", "default", { size: "sm" })
+        },
+        savedConversation: {
+            container: interactive({
+                base: {
+                    background: background(layer, "on"),
+                    padding: { top: 4, bottom: 4 }
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "on", "hovered"),
+                    }
+                },
+            }),
+            savedAt: {
+                margin: { left: 8 },
+                ...text(layer, "sans", "default", { size: "xs" }),
+            },
+            title: {
+                margin: { left: 16 },
+                ...text(layer, "sans", "default", { size: "sm", weight: "bold" }),
+            }
+        },
         userSender: {
             default: {
                 ...text(layer, "sans", "default", {
@@ -43,13 +222,10 @@ export default function assistant(colorScheme: ColorScheme) {
             margin: { top: 2, left: 8 },
             ...text(layer, "sans", "default", { size: "2xs" }),
         },
-        modelInfoContainer: {
-            margin: { right: 16, top: 4 },
-        },
         model: interactive({
             base: {
                 background: background(layer, "on"),
-                border: border(layer, "on", { overlay: true }),
+                margin: { left: 12, right: 12, top: 12 },
                 padding: 4,
                 cornerRadius: 4,
                 ...text(layer, "sans", "default", { size: "xs" }),
@@ -57,22 +233,21 @@ export default function assistant(colorScheme: ColorScheme) {
             state: {
                 hovered: {
                     background: background(layer, "on", "hovered"),
+                    border: border(layer, "on", { overlay: true }),
                 },
             },
         }),
         remainingTokens: {
             background: background(layer, "on"),
-            border: border(layer, "on", { overlay: true }),
+            margin: { top: 12, right: 12 },
             padding: 4,
-            margin: { left: 4 },
             cornerRadius: 4,
             ...text(layer, "sans", "positive", { size: "xs" }),
         },
         noRemainingTokens: {
             background: background(layer, "on"),
-            border: border(layer, "on", { overlay: true }),
+            margin: { top: 12, right: 12 },
             padding: 4,
-            margin: { left: 4 },
             cornerRadius: 4,
             ...text(layer, "sans", "negative", { size: "xs" }),
         },