Introduce recent files ambient context for assistant (#11791)

Antonio Scandurra and Nathan Sobo created

<img width="1637" alt="image"
src="https://github.com/zed-industries/zed/assets/482957/5aaec657-3499-42c9-9528-c83728f2a7a1">

Release Notes:

- Added a new ambient context feature that allows showing the model up
to three buffers (along with their diagnostics) that the user interacted
with recently.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

assets/icons/countdown_timer.svg        |   1 
crates/assistant/src/assistant.rs       |  10 
crates/assistant/src/assistant_panel.rs | 749 ++++++++++++++++----------
crates/assistant/src/embedded_scope.rs  |  91 ---
crates/gpui/src/window.rs               |  12 
crates/tab_switcher/src/tab_switcher.rs |   4 
crates/ui/src/components/icon.rs        |   2 
crates/workspace/src/pane.rs            |  26 
crates/workspace/src/workspace.rs       |   9 
9 files changed, 513 insertions(+), 391 deletions(-)

Detailed changes

assets/icons/countdown_timer.svg 🔗

@@ -0,0 +1 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.15 7.49998C13.15 4.66458 10.9402 1.84998 7.50002 1.84998C4.7217 1.84998 3.34851 3.90636 2.76336 4.99997H4.5C4.77614 4.99997 5 5.22383 5 5.49997C5 5.77611 4.77614 5.99997 4.5 5.99997H1.5C1.22386 5.99997 1 5.77611 1 5.49997V2.49997C1 2.22383 1.22386 1.99997 1.5 1.99997C1.77614 1.99997 2 2.22383 2 2.49997V4.31318C2.70453 3.07126 4.33406 0.849976 7.50002 0.849976C11.5628 0.849976 14.15 4.18537 14.15 7.49998C14.15 10.8146 11.5628 14.15 7.50002 14.15C5.55618 14.15 3.93778 13.3808 2.78548 12.2084C2.16852 11.5806 1.68668 10.839 1.35816 10.0407C1.25306 9.78536 1.37488 9.49315 1.63024 9.38806C1.8856 9.28296 2.17781 9.40478 2.2829 9.66014C2.56374 10.3425 2.97495 10.9745 3.4987 11.5074C4.47052 12.4963 5.83496 13.15 7.50002 13.15C10.9402 13.15 13.15 10.3354 13.15 7.49998ZM7 10V5.00001H8V10H7Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>

crates/assistant/src/assistant.rs 🔗

@@ -6,11 +6,8 @@ mod prompts;
 mod saved_conversation;
 mod streaming_diff;
 
-mod embedded_scope;
-
 pub use assistant_panel::AssistantPanel;
 use assistant_settings::{AssistantSettings, OpenAiModel, ZedDotDevModel};
-use chrono::{DateTime, Local};
 use client::{proto, Client};
 use command_palette_hooks::CommandPaletteFilter;
 pub(crate) use completion_provider::*;
@@ -26,7 +23,6 @@ use std::{
 actions!(
     assistant,
     [
-        NewConversation,
         Assist,
         Split,
         CycleMessageRole,
@@ -35,6 +31,7 @@ actions!(
         ResetKey,
         InlineAssist,
         ToggleIncludeConversation,
+        ToggleHistory,
     ]
 );
 
@@ -93,8 +90,8 @@ impl LanguageModel {
 
     pub fn display_name(&self) -> String {
         match self {
-            LanguageModel::OpenAi(model) => format!("openai/{}", model.display_name()),
-            LanguageModel::ZedDotDev(model) => format!("zed.dev/{}", model.display_name()),
+            LanguageModel::OpenAi(model) => model.display_name().into(),
+            LanguageModel::ZedDotDev(model) => model.display_name().into(),
         }
     }
 
@@ -178,7 +175,6 @@ pub struct LanguageModelChoiceDelta {
 #[derive(Clone, Debug, Serialize, Deserialize)]
 struct MessageMetadata {
     role: Role,
-    sent_at: DateTime<Local>,
     status: MessageStatus,
 }
 

crates/assistant/src/assistant_panel.rs 🔗

@@ -1,15 +1,13 @@
 use crate::{
     assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel},
     codegen::{self, Codegen, CodegenKind},
-    embedded_scope::EmbeddedScope,
     prompts::generate_content_prompt,
     Assist, CompletionProvider, CycleMessageRole, InlineAssist, LanguageModel,
     LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus,
-    NewConversation, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
-    SavedMessage, Split, ToggleFocus, ToggleIncludeConversation,
+    QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
+    Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation,
 };
 use anyhow::{anyhow, Result};
-use chrono::{DateTime, Local};
 use collections::{hash_map, HashMap, HashSet, VecDeque};
 use editor::{
     actions::{MoveDown, MoveUp},
@@ -17,21 +15,24 @@ use editor::{
         BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
     },
     scroll::{Autoscroll, AutoscrollStrategy},
-    Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBuffer, MultiBufferSnapshot,
-    RowExt, ToOffset as _, ToPoint,
+    Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt,
+    ToOffset as _, ToPoint,
 };
 use file_icons::FileIcons;
 use fs::Fs;
 use futures::StreamExt;
 use gpui::{
-    canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
-    AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, EventEmitter,
-    FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement,
-    IntoElement, Model, ModelContext, ParentElement, Pixels, Render, SharedString,
-    StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, UniformListScrollHandle,
-    View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext,
+    canvas, div, point, relative, rems, uniform_list, Action, AnyView, AppContext, AsyncAppContext,
+    AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
+    FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model,
+    ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled,
+    Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext,
+    WeakModel, WeakView, WhiteSpace, WindowContext,
+};
+use language::{
+    language_settings::SoftWrap, Buffer, BufferSnapshot, DiagnosticEntry, LanguageRegistry, Point,
+    ToOffset as _,
 };
-use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, Point, ToOffset as _};
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
 use project::Project;
@@ -40,19 +41,17 @@ use settings::Settings;
 use std::{cmp, fmt::Write, iter, ops::Range, path::PathBuf, sync::Arc, time::Duration};
 use telemetry_events::AssistantKind;
 use theme::ThemeSettings;
-use ui::{
-    prelude::*,
-    utils::{DateTimeType, FormatDistance},
-    ButtonLike, Tab, TabBar, Tooltip,
-};
+use ui::{popover_menu, prelude::*, ButtonLike, ContextMenu, Tab, TabBar, Tooltip};
 use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
 use uuid::Uuid;
-use workspace::notifications::NotificationId;
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     searchable::Direction,
     Event as WorkspaceEvent, Save, Toast, ToggleZoom, Toolbar, Workspace,
 };
+use workspace::{notifications::NotificationId, NewFile};
+
+const MAX_RECENT_BUFFERS: usize = 3;
 
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(
@@ -676,7 +675,7 @@ impl AssistantPanel {
             messages.extend(
                 conversation
                     .messages(cx)
-                    .map(|message| message.to_open_ai_message(buffer)),
+                    .map(|message| message.to_request_message(buffer)),
             );
         }
         let model = self.model.clone();
@@ -853,6 +852,18 @@ impl AssistantPanel {
         }
     }
 
+    fn toggle_history(&mut self, _: &ToggleHistory, cx: &mut ViewContext<Self>) {
+        self.show_saved_conversations = !self.show_saved_conversations;
+        cx.notify();
+    }
+
+    fn show_history(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.show_saved_conversations {
+            self.show_saved_conversations = true;
+            cx.notify();
+        }
+    }
+
     fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext<Self>) {
         let mut propagate = true;
         if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
@@ -907,36 +918,74 @@ impl AssistantPanel {
         Some(&self.active_conversation_editor.as_ref()?.editor)
     }
 
-    fn render_hamburger_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
-        IconButton::new("hamburger_button", IconName::Menu)
-            .on_click(cx.listener(|this, _event, cx| {
-                this.show_saved_conversations = !this.show_saved_conversations;
-                cx.notify();
-            }))
-            .tooltip(|cx| Tooltip::text("Conversation History", cx))
+    fn render_popover_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let assistant = cx.view().clone();
+        let zoomed = self.zoomed;
+        popover_menu("assistant-popover")
+            .trigger(IconButton::new("trigger", IconName::Menu))
+            .menu(move |cx| {
+                let assistant = assistant.clone();
+                ContextMenu::build(cx, |menu, _cx| {
+                    menu.entry(
+                        if zoomed { "Zoom Out" } else { "Zoom In" },
+                        Some(Box::new(ToggleZoom)),
+                        {
+                            let assistant = assistant.clone();
+                            move |cx| {
+                                assistant.focus_handle(cx).dispatch_action(&ToggleZoom, cx);
+                            }
+                        },
+                    )
+                    .entry("New Context", Some(Box::new(NewFile)), {
+                        let assistant = assistant.clone();
+                        move |cx| {
+                            assistant.focus_handle(cx).dispatch_action(&NewFile, cx);
+                        }
+                    })
+                    .entry("History", Some(Box::new(ToggleHistory)), {
+                        let assistant = assistant.clone();
+                        move |cx| assistant.update(cx, |assistant, cx| assistant.show_history(cx))
+                    })
+                })
+                .into()
+            })
     }
 
-    fn render_editor_tools(&self, cx: &mut ViewContext<Self>) -> Vec<AnyElement> {
-        if self.active_conversation_editor().is_some() {
-            vec![
-                Self::render_split_button(cx).into_any_element(),
-                Self::render_quote_button(cx).into_any_element(),
-                Self::render_assist_button(cx).into_any_element(),
-            ]
-        } else {
-            Default::default()
-        }
-    }
+    fn render_inject_context_menu(&self, _cx: &mut ViewContext<Self>) -> impl Element {
+        let workspace = self.workspace.clone();
 
-    fn render_split_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
-        IconButton::new("split_button", IconName::Snip)
-            .on_click(cx.listener(|this, _event, cx| {
-                if let Some(active_editor) = this.active_conversation_editor() {
-                    active_editor.update(cx, |editor, cx| editor.split(&Default::default(), cx));
-                }
+        popover_menu("inject-context-menu")
+            .trigger(IconButton::new("trigger", IconName::Quote).tooltip(|cx| {
+                // Tooltip::with_meta("Insert Context", None, "Type # to insert via keyboard", cx)
+                Tooltip::text("Insert Context", cx)
             }))
-            .icon_size(IconSize::Small)
-            .tooltip(|cx| Tooltip::for_action("Split Message", &Split, cx))
+            .menu(move |cx| {
+                ContextMenu::build(cx, |menu, _cx| {
+                    // menu.entry("Insert Search", None, {
+                    //     let assistant = assistant.clone();
+                    //     move |_cx| {}
+                    // })
+                    // .entry("Insert Docs", None, {
+                    //     let assistant = assistant.clone();
+                    //     move |cx| {}
+                    // })
+                    menu.entry("Quote Selection", None, {
+                        let workspace = workspace.clone();
+                        move |cx| {
+                            workspace
+                                .update(cx, |workspace, cx| {
+                                    ConversationEditor::quote_selection(
+                                        workspace,
+                                        &Default::default(),
+                                        cx,
+                                    )
+                                })
+                                .ok();
+                        }
+                    })
+                })
+                .into()
+            })
     }
 
     fn render_assist_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -950,44 +999,6 @@ impl AssistantPanel {
             .tooltip(|cx| Tooltip::for_action("Assist", &Assist, cx))
     }
 
-    fn render_quote_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
-        IconButton::new("quote_button", IconName::Quote)
-            .on_click(cx.listener(|this, _event, cx| {
-                if let Some(workspace) = this.workspace.upgrade() {
-                    cx.window_context().defer(move |cx| {
-                        workspace.update(cx, |workspace, cx| {
-                            ConversationEditor::quote_selection(workspace, &Default::default(), cx)
-                        });
-                    });
-                }
-            }))
-            .icon_size(IconSize::Small)
-            .tooltip(|cx| Tooltip::for_action("Quote Selection", &QuoteSelection, cx))
-    }
-
-    fn render_plus_button(cx: &mut ViewContext<Self>) -> impl IntoElement {
-        IconButton::new("plus_button", IconName::Plus)
-            .on_click(cx.listener(|this, _event, cx| {
-                this.new_conversation(cx);
-            }))
-            .icon_size(IconSize::Small)
-            .tooltip(|cx| Tooltip::for_action("New Conversation", &NewConversation, cx))
-    }
-
-    fn render_zoom_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let zoomed = self.zoomed;
-        IconButton::new("zoom_button", IconName::Maximize)
-            .on_click(cx.listener(|this, _event, cx| {
-                this.toggle_zoom(&ToggleZoom, cx);
-            }))
-            .selected(zoomed)
-            .selected_icon(IconName::Minimize)
-            .icon_size(IconSize::Small)
-            .tooltip(move |cx| {
-                Tooltip::for_action(if zoomed { "Zoom Out" } else { "Zoom In" }, &ToggleZoom, cx)
-            })
-    }
-
     fn render_saved_conversation(
         &mut self,
         index: usize,
@@ -1058,9 +1069,7 @@ impl AssistantPanel {
 
     fn render_signed_in(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let header = TabBar::new("assistant_header")
-            .start_child(
-                h_flex().gap_1().child(Self::render_hamburger_button(cx)), // .children(title),
-            )
+            .start_child(h_flex().gap_1().child(self.render_popover_button(cx)))
             .children(self.active_conversation_editor().map(|editor| {
                 h_flex()
                     .h(rems(Tab::CONTAINER_HEIGHT_IN_REMS))
@@ -1068,26 +1077,30 @@ impl AssistantPanel {
                     .px_2()
                     .child(Label::new(editor.read(cx).title(cx)).into_element())
             }))
-            .when(self.focus_handle.contains_focused(cx), |this| {
-                this.end_child(
-                    h_flex()
-                        .gap_2()
-                        .when(self.active_conversation_editor().is_some(), |this| {
-                            this.child(h_flex().gap_1().children(self.render_editor_tools(cx)))
-                                .child(
-                                    ui::Divider::vertical()
-                                        .inset()
-                                        .color(ui::DividerColor::Border),
-                                )
-                        })
-                        .child(
+            .end_child(
+                h_flex()
+                    .gap_2()
+                    .when_some(self.active_conversation_editor(), |this, editor| {
+                        let conversation = editor.read(cx).conversation.clone();
+                        this.child(
                             h_flex()
                                 .gap_1()
-                                .child(Self::render_plus_button(cx))
-                                .child(self.render_zoom_button(cx)),
-                        ),
-                )
-            });
+                                .child(self.render_model(&conversation, cx))
+                                .children(self.render_remaining_tokens(&conversation, cx)),
+                        )
+                        .child(
+                            ui::Divider::vertical()
+                                .inset()
+                                .color(ui::DividerColor::Border),
+                        )
+                    })
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .child(self.render_inject_context_menu(cx))
+                            .child(Self::render_assist_button(cx)),
+                    ),
+            );
 
         let contents = if self.active_conversation_editor().is_some() {
             let mut registrar = DivRegistrar::new(
@@ -1099,6 +1112,7 @@ impl AssistantPanel {
         } else {
             div()
         };
+
         v_flex()
             .key_context("AssistantPanel")
             .size_full()
@@ -1106,6 +1120,7 @@ impl AssistantPanel {
                 this.new_conversation(cx);
             }))
             .on_action(cx.listener(AssistantPanel::toggle_zoom))
+            .on_action(cx.listener(AssistantPanel::toggle_history))
             .on_action(cx.listener(AssistantPanel::deploy))
             .on_action(cx.listener(AssistantPanel::select_next_match))
             .on_action(cx.listener(AssistantPanel::select_prev_match))
@@ -1150,20 +1165,7 @@ impl AssistantPanel {
                     .into_any_element()
                 } else if let Some(editor) = self.active_conversation_editor() {
                     let editor = editor.clone();
-                    let conversation = editor.read(cx).conversation.clone();
-                    div()
-                        .size_full()
-                        .child(editor.clone())
-                        .child(
-                            h_flex()
-                                .absolute()
-                                .gap_1()
-                                .top_3()
-                                .right_5()
-                                .child(self.render_model(&conversation, cx))
-                                .children(self.render_remaining_tokens(&conversation, cx)),
-                        )
-                        .into_any_element()
+                    div().size_full().child(editor.clone()).into_any_element()
                 } else {
                     div().into_any_element()
                 },
@@ -1192,9 +1194,13 @@ impl AssistantPanel {
         } else if remaining_tokens <= 500 {
             Color::Warning
         } else {
-            Color::Default
+            Color::Muted
         };
-        Some(Label::new(remaining_tokens.to_string()).color(remaining_tokens_color))
+        Some(
+            Label::new(remaining_tokens.to_string())
+                .size(LabelSize::Small)
+                .color(remaining_tokens_color),
+        )
     }
 }
 
@@ -1319,7 +1325,7 @@ struct Summary {
 pub struct Conversation {
     id: Option<String>,
     buffer: Model<Buffer>,
-    embedded_scope: EmbeddedScope,
+    ambient_context: AmbientContext,
     message_anchors: Vec<MessageAnchor>,
     messages_metadata: HashMap<MessageId, MessageMetadata>,
     next_message_id: MessageId,
@@ -1335,13 +1341,40 @@ pub struct Conversation {
     _subscriptions: Vec<Subscription>,
 }
 
+#[derive(Default)]
+struct AmbientContext {
+    recent_buffers: RecentBuffersContext,
+}
+
+struct RecentBuffersContext {
+    enabled: bool,
+    buffers: Vec<RecentBuffer>,
+    message: String,
+    pending_message: Option<Task<()>>,
+}
+
+struct RecentBuffer {
+    buffer: WeakModel<Buffer>,
+    _subscription: Subscription,
+}
+
+impl Default for RecentBuffersContext {
+    fn default() -> Self {
+        Self {
+            enabled: true,
+            buffers: Vec::new(),
+            message: String::new(),
+            pending_message: None,
+        }
+    }
+}
+
 impl EventEmitter<ConversationEvent> for Conversation {}
 
 impl Conversation {
     fn new(
         model: LanguageModel,
         language_registry: Arc<LanguageRegistry>,
-        embedded_scope: EmbeddedScope,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let markdown = language_registry.language_for_name("Markdown");
@@ -1364,6 +1397,7 @@ impl Conversation {
             message_anchors: Default::default(),
             messages_metadata: Default::default(),
             next_message_id: Default::default(),
+            ambient_context: AmbientContext::default(),
             summary: None,
             pending_summary: Task::ready(None),
             completion_count: Default::default(),
@@ -1375,7 +1409,6 @@ impl Conversation {
             pending_save: Task::ready(Ok(())),
             path: None,
             buffer,
-            embedded_scope,
         };
 
         let message = MessageAnchor {
@@ -1387,7 +1420,6 @@ impl Conversation {
             message.id,
             MessageMetadata {
                 role: Role::User,
-                sent_at: Local::now(),
                 status: MessageStatus::Done,
             },
         );
@@ -1460,6 +1492,7 @@ impl Conversation {
                 message_anchors,
                 messages_metadata: saved_conversation.message_metadata,
                 next_message_id,
+                ambient_context: AmbientContext::default(),
                 summary: Some(Summary {
                     text: saved_conversation.summary,
                     done: true,
@@ -1474,13 +1507,193 @@ impl Conversation {
                 pending_save: Task::ready(Ok(())),
                 path: Some(path),
                 buffer,
-                embedded_scope: EmbeddedScope::new(),
             };
             this.count_remaining_tokens(cx);
             this
         })
     }
 
+    fn toggle_recent_buffers(&mut self, cx: &mut ModelContext<Self>) {
+        self.ambient_context.recent_buffers.enabled = !self.ambient_context.recent_buffers.enabled;
+        self.update_recent_buffers_context(cx);
+    }
+
+    fn set_recent_buffers(
+        &mut self,
+        buffers: impl IntoIterator<Item = Model<Buffer>>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        self.ambient_context.recent_buffers.buffers.clear();
+        self.ambient_context
+            .recent_buffers
+            .buffers
+            .extend(buffers.into_iter().map(|buffer| RecentBuffer {
+                buffer: buffer.downgrade(),
+                _subscription: cx.observe(&buffer, |this, _, cx| {
+                    this.update_recent_buffers_context(cx);
+                }),
+            }));
+        self.update_recent_buffers_context(cx);
+    }
+
+    fn update_recent_buffers_context(&mut self, cx: &mut ModelContext<Self>) {
+        let buffers = self
+            .ambient_context
+            .recent_buffers
+            .buffers
+            .iter()
+            .filter_map(|recent| {
+                recent
+                    .buffer
+                    .read_with(cx, |buffer, cx| {
+                        (
+                            buffer.file().map(|file| file.full_path(cx)),
+                            buffer.snapshot(),
+                        )
+                    })
+                    .ok()
+            })
+            .collect::<Vec<_>>();
+
+        if !self.ambient_context.recent_buffers.enabled || buffers.is_empty() {
+            self.ambient_context.recent_buffers.message.clear();
+            self.ambient_context.recent_buffers.pending_message = None;
+            self.count_remaining_tokens(cx);
+            cx.notify();
+        } else {
+            self.ambient_context.recent_buffers.pending_message =
+                Some(cx.spawn(|this, mut cx| async move {
+                    const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
+                    cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
+
+                    let message = cx
+                        .background_executor()
+                        .spawn(async move { Self::message_for_recent_buffers(&buffers) })
+                        .await;
+                    this.update(&mut cx, |this, cx| {
+                        this.ambient_context.recent_buffers.message = message;
+                        this.count_remaining_tokens(cx);
+                        cx.notify();
+                    })
+                    .ok();
+                }));
+        }
+    }
+
+    fn message_for_recent_buffers(buffers: &[(Option<PathBuf>, BufferSnapshot)]) -> String {
+        let mut message = String::new();
+        writeln!(
+            message,
+            "The following is a list of recent buffers that the user has opened."
+        )
+        .unwrap();
+        writeln!(
+            message,
+            "For every line in the buffer, I will include a row number that line corresponds to."
+        )
+        .unwrap();
+        writeln!(
+            message,
+            "Lines that don't have a number correspond to errors and warnings. For example:"
+        )
+        .unwrap();
+        writeln!(message, "path/to/file.md").unwrap();
+        writeln!(message, "```markdown").unwrap();
+        writeln!(message, "1 The quick brown fox").unwrap();
+        writeln!(message, "2 jumps over one active").unwrap();
+        writeln!(message, "             --- error: should be 'the'").unwrap();
+        writeln!(message, "                 ------ error: should be 'lazy'").unwrap();
+        writeln!(message, "3 dog").unwrap();
+        writeln!(message, "```").unwrap();
+
+        message.push('\n');
+        writeln!(message, "Here's the actual recent buffer list:").unwrap();
+        for (path, buffer) in buffers {
+            if let Some(path) = path {
+                writeln!(message, "{}", path.display()).unwrap();
+            } else {
+                writeln!(message, "untitled").unwrap();
+            }
+
+            if let Some(language) = buffer.language() {
+                writeln!(message, "```{}", language.name().to_lowercase()).unwrap();
+            } else {
+                writeln!(message, "```").unwrap();
+            }
+
+            let mut diagnostics = buffer
+                .diagnostics_in_range::<_, Point>(
+                    language::Anchor::MIN..language::Anchor::MAX,
+                    false,
+                )
+                .peekable();
+
+            let mut active_diagnostics = Vec::<DiagnosticEntry<Point>>::new();
+            const GUTTER_PADDING: usize = 4;
+            let gutter_width =
+                ((buffer.max_point().row + 1) as f32).log10() as usize + 1 + GUTTER_PADDING;
+            for buffer_row in 0..=buffer.max_point().row {
+                let display_row = buffer_row + 1;
+                active_diagnostics.retain(|diagnostic| {
+                    (diagnostic.range.start.row..=diagnostic.range.end.row).contains(&buffer_row)
+                });
+                while diagnostics.peek().map_or(false, |diagnostic| {
+                    (diagnostic.range.start.row..=diagnostic.range.end.row).contains(&buffer_row)
+                }) {
+                    active_diagnostics.push(diagnostics.next().unwrap());
+                }
+
+                let row_width = (display_row as f32).log10() as usize + 1;
+                write!(message, "{}", display_row).unwrap();
+                if row_width < gutter_width {
+                    message.extend(iter::repeat(' ').take(gutter_width - row_width));
+                }
+
+                for chunk in buffer.text_for_range(
+                    Point::new(buffer_row, 0)..Point::new(buffer_row, buffer.line_len(buffer_row)),
+                ) {
+                    message.push_str(chunk);
+                }
+                message.push('\n');
+
+                for diagnostic in &active_diagnostics {
+                    message.extend(iter::repeat(' ').take(gutter_width));
+
+                    let start_column = if diagnostic.range.start.row == buffer_row {
+                        message
+                            .extend(iter::repeat(' ').take(diagnostic.range.start.column as usize));
+                        diagnostic.range.start.column
+                    } else {
+                        0
+                    };
+                    let end_column = if diagnostic.range.end.row == buffer_row {
+                        diagnostic.range.end.column
+                    } else {
+                        buffer.line_len(buffer_row)
+                    };
+
+                    message.extend(iter::repeat('-').take((end_column - start_column) as usize));
+                    writeln!(message, " {}", diagnostic.diagnostic.message).unwrap();
+                }
+            }
+
+            message.push('\n');
+        }
+
+        writeln!(
+            message,
+            "When quoting the above code, mention which rows the code occurs at."
+        )
+        .unwrap();
+        writeln!(
+            message,
+            "Never include rows in the quoted code itself and only report lines that didn't start with a row number."
+        )
+        .unwrap();
+
+        message
+    }
+
     fn handle_buffer_event(
         &mut self,
         _: Model<Buffer>,
@@ -1656,20 +1869,27 @@ impl Conversation {
     }
 
     fn to_completion_request(&self, cx: &mut ModelContext<Conversation>) -> LanguageModelRequest {
-        let mut request = LanguageModelRequest {
+        let messages = self
+            .ambient_context
+            .recent_buffers
+            .enabled
+            .then(|| LanguageModelRequestMessage {
+                role: Role::System,
+                content: self.ambient_context.recent_buffers.message.clone(),
+            })
+            .into_iter()
+            .chain(
+                self.messages(cx)
+                    .filter(|message| matches!(message.status, MessageStatus::Done))
+                    .map(|message| message.to_request_message(self.buffer.read(cx))),
+            );
+
+        LanguageModelRequest {
             model: self.model.clone(),
-            messages: self
-                .messages(cx)
-                .filter(|message| matches!(message.status, MessageStatus::Done))
-                .map(|message| message.to_open_ai_message(self.buffer.read(cx)))
-                .collect(),
+            messages: messages.collect(),
             stop: vec![],
             temperature: 1.0,
-        };
-
-        let context_message = self.embedded_scope.message(cx);
-        request.messages.extend(context_message);
-        request
+        }
     }
 
     fn cancel_last_assist(&mut self) -> bool {
@@ -1721,14 +1941,8 @@ impl Conversation {
             };
             self.message_anchors
                 .insert(next_message_ix, message.clone());
-            self.messages_metadata.insert(
-                message.id,
-                MessageMetadata {
-                    role,
-                    sent_at: Local::now(),
-                    status,
-                },
-            );
+            self.messages_metadata
+                .insert(message.id, MessageMetadata { role, status });
             cx.emit(ConversationEvent::MessagesEdited);
             Some(message)
         } else {
@@ -1785,7 +1999,6 @@ impl Conversation {
                 suffix.id,
                 MessageMetadata {
                     role,
-                    sent_at: Local::now(),
                     status: MessageStatus::Done,
                 },
             );
@@ -1830,7 +2043,6 @@ impl Conversation {
                         selection.id,
                         MessageMetadata {
                             role,
-                            sent_at: Local::now(),
                             status: MessageStatus::Done,
                         },
                     );
@@ -1855,7 +2067,7 @@ impl Conversation {
             let messages = self
                 .messages(cx)
                 .take(2)
-                .map(|message| message.to_open_ai_message(self.buffer.read(cx)))
+                .map(|message| message.to_request_message(self.buffer.read(cx)))
                 .chain(Some(LanguageModelRequestMessage {
                     role: Role::User,
                     content: "Summarize the conversation into a short title without punctuation"
@@ -1962,7 +2174,6 @@ impl Conversation {
                     id: message_anchor.id,
                     anchor: message_anchor.start,
                     role: metadata.role,
-                    sent_at: metadata.sent_at,
                     status: metadata.status.clone(),
                 });
             }
@@ -2061,8 +2272,7 @@ impl ConversationEditor {
         workspace: View<Workspace>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let conversation = cx
-            .new_model(|cx| Conversation::new(model, language_registry, EmbeddedScope::new(), cx));
+        let conversation = cx.new_model(|cx| Conversation::new(model, language_registry, cx));
         Self::for_conversation(conversation, fs, workspace, cx)
     }
 
@@ -2096,7 +2306,7 @@ impl ConversationEditor {
             workspace: workspace.downgrade(),
             _subscriptions,
         };
-        cx.defer(|this, cx| this.update_active_buffer(workspace, cx));
+        this.update_recent_editors(cx);
         this.update_message_headers(cx);
         this
     }
@@ -2232,32 +2442,57 @@ impl ConversationEditor {
 
     fn handle_workspace_event(
         &mut self,
-        workspace: View<Workspace>,
+        _: View<Workspace>,
         event: &WorkspaceEvent,
         cx: &mut ViewContext<Self>,
     ) {
-        if let WorkspaceEvent::ActiveItemChanged = event {
-            self.update_active_buffer(workspace, cx);
+        match event {
+            WorkspaceEvent::ActiveItemChanged
+            | WorkspaceEvent::ItemAdded
+            | WorkspaceEvent::ItemRemoved
+            | WorkspaceEvent::PaneAdded(_)
+            | WorkspaceEvent::PaneRemoved => self.update_recent_editors(cx),
+            _ => {}
         }
     }
 
-    fn update_active_buffer(
-        &mut self,
-        workspace: View<Workspace>,
-        cx: &mut ViewContext<'_, ConversationEditor>,
-    ) {
-        let active_buffer = workspace
-            .read(cx)
-            .active_item(cx)
-            .and_then(|item| Some(item.act_as::<Editor>(cx)?.read(cx).buffer().clone()));
+    fn update_recent_editors(&mut self, cx: &mut ViewContext<ConversationEditor>) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let mut timestamps_by_entity_id = HashMap::default();
+        for pane in workspace.read(cx).panes() {
+            let pane = pane.read(cx);
+            for entry in pane.activation_history() {
+                timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp);
+            }
+        }
+
+        let mut timestamps_by_buffer = HashMap::default();
+        for editor in workspace.read(cx).items_of_type::<Editor>(cx) {
+            let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
+                continue;
+            };
+
+            let new_timestamp = timestamps_by_entity_id
+                .get(&editor.entity_id())
+                .copied()
+                .unwrap_or_default();
+            let timestamp = timestamps_by_buffer.entry(buffer).or_insert(new_timestamp);
+            *timestamp = cmp::max(*timestamp, new_timestamp);
+        }
+
+        let mut recent_buffers = timestamps_by_buffer.into_iter().collect::<Vec<_>>();
+        recent_buffers.sort_unstable_by_key(|(_, timestamp)| *timestamp);
+        if recent_buffers.len() > MAX_RECENT_BUFFERS {
+            let excess = recent_buffers.len() - MAX_RECENT_BUFFERS;
+            recent_buffers.drain(..excess);
+        }
 
         self.conversation.update(cx, |conversation, cx| {
             conversation
-                .embedded_scope
-                .set_active_buffer(active_buffer.clone(), cx);
-
-            conversation.count_remaining_tokens(cx);
-            cx.notify();
+                .set_recent_buffers(recent_buffers.into_iter().map(|(buffer, _)| buffer), cx);
         });
     }
 
@@ -2295,7 +2530,8 @@ impl ConversationEditor {
                 .conversation
                 .read(cx)
                 .messages(cx)
-                .map(|message| BlockProperties {
+                .enumerate()
+                .map(|(ix, message)| BlockProperties {
                     position: buffer
                         .anchor_in_excerpt(excerpt_id, message.anchor)
                         .unwrap(),
@@ -2303,7 +2539,7 @@ impl ConversationEditor {
                     style: BlockStyle::Sticky,
                     render: Box::new({
                         let conversation = self.conversation.clone();
-                        move |_cx| {
+                        move |cx| {
                             let message_id = message.id;
                             let sender = ButtonLike::new("role")
                                 .style(ButtonStyle::Filled)
@@ -2335,22 +2571,10 @@ impl ConversationEditor {
                             h_flex()
                                 .id(("message_header", message_id.0))
                                 .h_11()
+                                .w_full()
                                 .relative()
                                 .gap_1()
                                 .child(sender)
-                                // TODO: Only show this if the message if the message has been sent
-                                .child(
-                                    Label::new(
-                                        FormatDistance::from_now(DateTimeType::Local(
-                                            message.sent_at,
-                                        ))
-                                        .hide_prefix(true)
-                                        .add_suffix(true)
-                                        .to_string(),
-                                    )
-                                    .size(LabelSize::XSmall)
-                                    .color(Color::Muted),
-                                )
                                 .children(
                                     if let MessageStatus::Error(error) = message.status.clone() {
                                         Some(
@@ -2363,6 +2587,65 @@ impl ConversationEditor {
                                         None
                                     },
                                 )
+                                .children((ix == 0).then(|| {
+                                    div()
+                                        .h_flex()
+                                        .flex_1()
+                                        .justify_end()
+                                        .pr_4()
+                                        .gap_1()
+                                        .child(
+                                            IconButton::new("include_file", IconName::File)
+                                                .icon_size(IconSize::Small)
+                                                .selected(
+                                                    conversation
+                                                        .read(cx)
+                                                        .ambient_context
+                                                        .recent_buffers
+                                                        .enabled,
+                                                )
+                                                .on_click({
+                                                    let conversation = conversation.downgrade();
+                                                    move |_, cx| {
+                                                        conversation
+                                                            .update(cx, |conversation, cx| {
+                                                                conversation
+                                                                    .toggle_recent_buffers(cx);
+                                                            })
+                                                            .ok();
+                                                    }
+                                                })
+                                                .tooltip(|cx| {
+                                                    Tooltip::text("Include Open Files", cx)
+                                                }),
+                                        )
+                                        // .child(
+                                        //     IconButton::new("include_terminal", IconName::Terminal)
+                                        //         .icon_size(IconSize::Small)
+                                        //         .tooltip(|cx| {
+                                        //             Tooltip::text("Include Terminal", cx)
+                                        //         }),
+                                        // )
+                                        // .child(
+                                        //     IconButton::new(
+                                        //         "include_edit_history",
+                                        //         IconName::FileGit,
+                                        //     )
+                                        //     .icon_size(IconSize::Small)
+                                        //     .tooltip(
+                                        //         |cx| Tooltip::text("Include Edit History", cx),
+                                        //     ),
+                                        // )
+                                        // .child(
+                                        //     IconButton::new(
+                                        //         "include_file_trees",
+                                        //         IconName::FileTree,
+                                        //     )
+                                        //     .icon_size(IconSize::Small)
+                                        //     .tooltip(|cx| Tooltip::text("Include File Trees", cx)),
+                                        // )
+                                        .into_any()
+                                }))
                                 .into_any_element()
                         }
                     }),
@@ -2500,104 +2783,6 @@ impl ConversationEditor {
             .map(|summary| summary.text.clone())
             .unwrap_or_else(|| "New Conversation".into())
     }
-
-    fn render_embedded_scope(&self, cx: &mut ViewContext<Self>) -> Option<impl Element> {
-        let active_buffer = self
-            .conversation
-            .read(cx)
-            .embedded_scope
-            .active_buffer()?
-            .clone();
-
-        Some(
-            div()
-                .p_4()
-                .v_flex()
-                .child(
-                    div()
-                        .h_flex()
-                        .items_center()
-                        .child(Icon::new(IconName::File))
-                        .child(
-                            div()
-                                .h_6()
-                                .child(Label::new("File Contexts"))
-                                .ml_1()
-                                .font_weight(FontWeight::SEMIBOLD),
-                        ),
-                )
-                .child(
-                    div()
-                        .ml_4()
-                        .child(self.render_active_buffer(active_buffer, cx)),
-                ),
-        )
-    }
-
-    fn render_active_buffer(
-        &self,
-        buffer: Model<MultiBuffer>,
-        cx: &mut ViewContext<Self>,
-    ) -> impl Element {
-        let buffer = buffer.read(cx);
-        let icon_path;
-        let path;
-        if let Some(singleton) = buffer.as_singleton() {
-            let singleton = singleton.read(cx);
-
-            path = singleton.file().map(|file| file.full_path(cx));
-
-            icon_path = path
-                .as_ref()
-                .and_then(|path| FileIcons::get_icon(path.as_path(), cx))
-                .map(SharedString::from)
-                .unwrap_or_else(|| SharedString::from("icons/file_icons/file.svg"));
-        } else {
-            icon_path = SharedString::from("icons/file_icons/file.svg");
-            path = None;
-        }
-
-        let file_name = path.map_or("Untitled".to_string(), |path| {
-            path.to_string_lossy().to_string()
-        });
-
-        let enabled = self
-            .conversation
-            .read(cx)
-            .embedded_scope
-            .active_buffer_enabled();
-
-        let file_name_text_color = if enabled {
-            Color::Default
-        } else {
-            Color::Disabled
-        };
-
-        div()
-            .id("active-buffer")
-            .h_flex()
-            .cursor_pointer()
-            .child(Icon::from_path(icon_path).color(file_name_text_color))
-            .child(
-                div()
-                    .h_6()
-                    .child(Label::new(file_name).color(file_name_text_color))
-                    .ml_1(),
-            )
-            .children(enabled.then(|| {
-                div()
-                    .child(Icon::new(IconName::Check).color(file_name_text_color))
-                    .ml_1()
-            }))
-            .on_click(cx.listener(move |this, _, cx| {
-                this.conversation.update(cx, |conversation, cx| {
-                    conversation
-                        .embedded_scope
-                        .set_active_buffer_enabled(!enabled);
-                    cx.notify();
-                })
-            }))
-    }
 }
 
 impl EventEmitter<ConversationEditorEvent> for ConversationEditor {}

crates/assistant/src/embedded_scope.rs 🔗

@@ -1,91 +0,0 @@
-use editor::MultiBuffer;
-use gpui::{AppContext, Model, ModelContext, Subscription};
-
-use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role};
-
-#[derive(Default)]
-pub struct EmbeddedScope {
-    active_buffer: Option<Model<MultiBuffer>>,
-    active_buffer_enabled: bool,
-    active_buffer_subscription: Option<Subscription>,
-}
-
-impl EmbeddedScope {
-    pub fn new() -> Self {
-        Self {
-            active_buffer: None,
-            active_buffer_enabled: true,
-            active_buffer_subscription: None,
-        }
-    }
-
-    pub fn set_active_buffer(
-        &mut self,
-        buffer: Option<Model<MultiBuffer>>,
-        cx: &mut ModelContext<Conversation>,
-    ) {
-        self.active_buffer_subscription.take();
-
-        if let Some(active_buffer) = buffer.clone() {
-            self.active_buffer_subscription =
-                Some(cx.subscribe(&active_buffer, |conversation, _, e, cx| {
-                    if let multi_buffer::Event::Edited { .. } = e {
-                        conversation.count_remaining_tokens(cx)
-                    }
-                }));
-        }
-
-        self.active_buffer = buffer;
-    }
-
-    pub fn active_buffer(&self) -> Option<&Model<MultiBuffer>> {
-        self.active_buffer.as_ref()
-    }
-
-    pub fn active_buffer_enabled(&self) -> bool {
-        self.active_buffer_enabled
-    }
-
-    pub fn set_active_buffer_enabled(&mut self, enabled: bool) {
-        self.active_buffer_enabled = enabled;
-    }
-
-    /// Provide a message for the language model based on the active buffer.
-    pub fn message(&self, cx: &AppContext) -> Option<LanguageModelRequestMessage> {
-        if !self.active_buffer_enabled {
-            return None;
-        }
-
-        let active_buffer = self.active_buffer.as_ref()?;
-        let buffer = active_buffer.read(cx);
-
-        if let Some(singleton) = buffer.as_singleton() {
-            let singleton = singleton.read(cx);
-
-            let filename = singleton
-                .file()
-                .map(|file| file.path().to_string_lossy())
-                .unwrap_or("Untitled".into());
-
-            let text = singleton.text();
-
-            let language = singleton
-                .language()
-                .map(|l| {
-                    let name = l.code_fence_block_name();
-                    name.to_string()
-                })
-                .unwrap_or_default();
-
-            let markdown =
-                format!("User's active file `{filename}`:\n\n```{language}\n{text}```\n\n");
-
-            return Some(LanguageModelRequestMessage {
-                role: Role::System,
-                content: markdown,
-            });
-        }
-
-        None
-    }
-}

crates/gpui/src/window.rs 🔗

@@ -200,6 +200,18 @@ impl FocusHandle {
     pub fn contains(&self, other: &Self, cx: &WindowContext) -> bool {
         self.id.contains(other.id, cx)
     }
+
+    /// Dispatch an action on the element that rendered this focus handle
+    pub fn dispatch_action(&self, action: &dyn Action, cx: &mut WindowContext) {
+        if let Some(node_id) = cx
+            .window
+            .rendered_frame
+            .dispatch_tree
+            .focusable_node_id(self.id)
+        {
+            cx.dispatch_action_on_node(node_id, action)
+        }
+    }
 }
 
 impl Clone for FocusHandle {

crates/tab_switcher/src/tab_switcher.rs 🔗

@@ -189,8 +189,8 @@ impl TabSwitcherDelegate {
         let pane = pane.read(cx);
         let mut history_indices = HashMap::default();
         pane.activation_history().iter().rev().enumerate().for_each(
-            |(history_index, entity_id)| {
-                history_indices.insert(entity_id, history_index);
+            |(history_index, history_entry)| {
+                history_indices.insert(history_entry.entity_id, history_index);
             },
         );
 

crates/ui/src/components/icon.rs 🔗

@@ -107,6 +107,7 @@ pub enum IconName {
     CopilotError,
     CopilotInit,
     Copy,
+    CountdownTimer,
     Dash,
     Delete,
     Disconnected,
@@ -221,6 +222,7 @@ impl IconName {
             IconName::CopilotError => "icons/copilot_error.svg",
             IconName::CopilotInit => "icons/copilot_init.svg",
             IconName::Copy => "icons/copy.svg",
+            IconName::CountdownTimer => "icons/countdown_timer.svg",
             IconName::Dash => "icons/dash.svg",
             IconName::Delete => "icons/delete.svg",
             IconName::Disconnected => "icons/disconnected.svg",

crates/workspace/src/pane.rs 🔗

@@ -191,7 +191,8 @@ pub struct Pane {
     ),
     focus_handle: FocusHandle,
     items: Vec<Box<dyn ItemHandle>>,
-    activation_history: Vec<EntityId>,
+    activation_history: Vec<ActivationHistoryEntry>,
+    next_activation_timestamp: Arc<AtomicUsize>,
     zoomed: bool,
     was_focused: bool,
     active_item_index: usize,
@@ -219,6 +220,11 @@ pub struct Pane {
     double_click_dispatch_action: Box<dyn Action>,
 }
 
+pub struct ActivationHistoryEntry {
+    pub entity_id: EntityId,
+    pub timestamp: usize,
+}
+
 pub struct ItemNavHistory {
     history: NavHistory,
     item: Arc<dyn WeakItemHandle>,
@@ -296,6 +302,7 @@ impl Pane {
             focus_handle,
             items: Vec::new(),
             activation_history: Vec::new(),
+            next_activation_timestamp: next_timestamp.clone(),
             was_focused: false,
             zoomed: false,
             active_item_index: 0,
@@ -506,7 +513,7 @@ impl Pane {
         self.active_item_index
     }
 
-    pub fn activation_history(&self) -> &Vec<EntityId> {
+    pub fn activation_history(&self) -> &[ActivationHistoryEntry] {
         &self.activation_history
     }
 
@@ -892,10 +899,13 @@ impl Pane {
 
             if let Some(newly_active_item) = self.items.get(index) {
                 self.activation_history
-                    .retain(|&previously_active_item_id| {
-                        previously_active_item_id != newly_active_item.item_id()
-                    });
-                self.activation_history.push(newly_active_item.item_id());
+                    .retain(|entry| entry.entity_id != newly_active_item.item_id());
+                self.activation_history.push(ActivationHistoryEntry {
+                    entity_id: newly_active_item.item_id(),
+                    timestamp: self
+                        .next_activation_timestamp
+                        .fetch_add(1, Ordering::SeqCst),
+                });
             }
 
             self.update_toolbar(cx);
@@ -1211,7 +1221,7 @@ impl Pane {
         cx: &mut ViewContext<Self>,
     ) {
         self.activation_history
-            .retain(|&history_entry| history_entry != self.items[item_index].item_id());
+            .retain(|entry| entry.entity_id != self.items[item_index].item_id());
 
         if item_index == self.active_item_index {
             let index_to_activate = self
@@ -1219,7 +1229,7 @@ impl Pane {
                 .pop()
                 .and_then(|last_activated_item| {
                     self.items.iter().enumerate().find_map(|(index, item)| {
-                        (item.item_id() == last_activated_item).then_some(index)
+                        (item.item_id() == last_activated_item.entity_id).then_some(index)
                     })
                 })
                 // We didn't have a valid activation history entry, so fallback

crates/workspace/src/workspace.rs 🔗

@@ -532,6 +532,9 @@ impl DelayedDebouncedEditAction {
 
 pub enum Event {
     PaneAdded(View<Pane>),
+    PaneRemoved,
+    ItemAdded,
+    ItemRemoved,
     ActiveItemChanged,
     ContactRequestedJoin(u64),
     WorkspaceCreated(WeakView<Workspace>),
@@ -2513,7 +2516,10 @@ impl Workspace {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            pane::Event::AddItem { item } => item.added_to_pane(self, pane, cx),
+            pane::Event::AddItem { item } => {
+                item.added_to_pane(self, pane, cx);
+                cx.emit(Event::ItemAdded);
+            }
             pane::Event::Split(direction) => {
                 self.split_and_clone(pane, *direction, cx);
             }
@@ -2696,6 +2702,7 @@ impl Workspace {
         } else {
             self.active_item_path_changed(cx);
         }
+        cx.emit(Event::PaneRemoved);
     }
 
     pub fn panes(&self) -> &[View<Pane>] {