Detailed changes
@@ -408,7 +408,6 @@ dependencies = [
"theme",
"theme_settings",
"time",
- "time_format",
"tree-sitter-md",
"ui",
"ui_input",
@@ -21715,7 +21714,6 @@ dependencies = [
"postage",
"pretty_assertions",
"project",
- "release_channel",
"remote",
"schemars",
"serde",
@@ -224,7 +224,6 @@
"context": "AgentPanel",
"bindings": {
"ctrl-n": "agent::NewThread",
- "ctrl-shift-h": "agent::OpenHistory",
"ctrl-alt-c": "agent::OpenSettings",
"ctrl-alt-p": "agent::ManageProfiles",
"ctrl-alt-l": "agent::OpenRulesLibrary",
@@ -234,7 +233,6 @@
"alt-tab": "agent::CycleFavoriteModels",
// `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AgentPanel` context
"alt-l": "agent::CycleFavoriteModels",
- "shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
@@ -264,7 +264,6 @@
"use_key_equivalents": true,
"bindings": {
"cmd-n": "agent::NewThread",
- "cmd-shift-h": "agent::OpenHistory",
"cmd-alt-c": "agent::OpenSettings",
"cmd-alt-l": "agent::OpenRulesLibrary",
"cmd-alt-p": "agent::ManageProfiles",
@@ -272,7 +271,6 @@
"shift-tab": "agent::CycleModeSelector",
"cmd-alt-/": "agent::ToggleModelSelector",
"alt-tab": "agent::CycleFavoriteModels",
- "cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
@@ -225,7 +225,6 @@
"use_key_equivalents": true,
"bindings": {
"ctrl-n": "agent::NewThread",
- "ctrl-shift-h": "agent::OpenHistory",
"shift-alt-c": "agent::OpenSettings",
"shift-alt-l": "agent::OpenRulesLibrary",
"shift-alt-p": "agent::ManageProfiles",
@@ -235,7 +234,6 @@
// `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Windows under the `AgentPanel` context
"alt-l": "agent::CycleFavoriteModels",
"shift-alt-/": "agent::ToggleModelSelector",
- "shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
@@ -102,7 +102,6 @@ text.workspace = true
theme.workspace = true
theme_settings.workspace = true
time.workspace = true
-time_format.workspace = true
ui.workspace = true
ui_input.workspace = true
url.workspace = true
@@ -10,7 +10,7 @@ use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscri
use project::{AgentServerStore, AgentServersUpdated, Project};
use watch::Receiver;
-use crate::{Agent, ThreadHistory};
+use crate::Agent;
pub enum AgentConnectionEntry {
Connecting {
@@ -25,7 +25,6 @@ pub enum AgentConnectionEntry {
#[derive(Clone)]
pub struct AgentConnectedState {
pub connection: Rc<dyn AgentConnection>,
- pub history: Option<Entity<ThreadHistory>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -44,13 +43,6 @@ impl AgentConnectionEntry {
}
}
- pub fn history(&self) -> Option<&Entity<ThreadHistory>> {
- match self {
- AgentConnectionEntry::Connected(state) => state.history.as_ref(),
- _ => None,
- }
- }
-
pub fn status(&self) -> AgentConnectionStatus {
match self {
AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting,
@@ -241,16 +233,8 @@ impl AgentConnectionStore {
let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx));
let connect_task = server.connect(delegate, self.project.clone(), cx);
- let connect_task = cx.spawn(async move |_this, cx| match connect_task.await {
- Ok(connection) => cx.update(|cx| {
- let history = connection
- .session_list(cx)
- .map(|session_list| cx.new(|cx| ThreadHistory::new(session_list, cx)));
- Ok(AgentConnectedState {
- connection,
- history,
- })
- }),
+ let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await {
+ Ok(connection) => Ok(AgentConnectedState { connection }),
Err(err) => match err.downcast::<LoadError>() {
Ok(load_error) => Err(load_error),
Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))),
@@ -30,12 +30,15 @@ use zed_actions::{
};
use crate::DEFAULT_THREAD_TITLE;
+use crate::ExpandMessageEditor;
+use crate::ManageProfiles;
+use crate::agent_connection_store::AgentConnectionStore;
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow,
InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
- OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata,
- ShowThreadMetadata, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+ ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata,
+ ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
ui::EndTrialUpsell,
@@ -44,12 +47,9 @@ use crate::{
Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
NewNativeAgentThreadFromSummary,
};
-use crate::{ExpandMessageEditor, ThreadHistoryView};
-use crate::{ManageProfiles, ThreadHistoryViewEvent};
-use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
use agent_settings::AgentSettings;
use ai_onboarding::AgentPanelOnboarding;
-use anyhow::{Context as _, Result};
+use anyhow::Result;
use chrono::{DateTime, Utc};
use client::UserStore;
use cloud_api_types::Plan;
@@ -60,14 +60,13 @@ use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
- DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
- Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
+ Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription,
+ Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptStore, UserPromptId};
-use release_channel::ReleaseChannel;
use rules_library::{RulesLibrary, open_rules_library};
use settings::TerminalDockPosition;
use settings::{Settings, update_settings_file};
@@ -78,7 +77,7 @@ use ui::{
Button, Callout, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle,
Tab, Tooltip, prelude::*, utils::WithRemSize,
};
-use util::{ResultExt as _, debug_panic};
+use util::ResultExt as _;
use workspace::{
CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
@@ -87,12 +86,8 @@ use workspace::{
const AGENT_PANEL_KEY: &str = "agent_panel";
const MIN_PANEL_WIDTH: Pixels = px(300.);
-const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
-fn agent_v2_enabled(cx: &App) -> bool {
- !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable))
-}
/// Maximum number of idle threads kept in the agent panel's retained list.
/// Set as a GPUI global to override; otherwise defaults to 5.
pub struct MaxIdleRetainedThreads(pub usize);
@@ -202,12 +197,6 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.expand_message_editor(window, cx));
}
})
- .register_action(|workspace, _: &OpenHistory, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- workspace.focus_panel::<AgentPanel>(window, cx);
- panel.update(cx, |panel, cx| panel.open_history(window, cx));
- }
- })
.register_action(|workspace, _: &OpenSettings, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
@@ -248,14 +237,6 @@ pub fn init(cx: &mut App) {
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
}
})
- .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- workspace.focus_panel::<AgentPanel>(window, cx);
- panel.update(cx, |panel, cx| {
- panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
- });
- }
- })
.register_action(|workspace, _: &ToggleOptionsMenu, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
@@ -666,14 +647,12 @@ impl From<AgentThread> for BaseView {
}
enum OverlayView {
- History { view: Entity<ThreadHistoryView> },
Configuration,
}
enum VisibleSurface<'a> {
Uninitialized,
AgentThread(&'a Entity<ConversationView>),
- History(&'a Entity<ThreadHistoryView>),
Configuration(Option<&'a Entity<AgentConfiguration>>),
}
@@ -691,7 +670,6 @@ impl BaseView {
impl OverlayView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
- OverlayView::History { .. } => WhichFontSize::AgentFont,
OverlayView::Configuration => WhichFontSize::None,
}
}
@@ -718,8 +696,6 @@ pub struct AgentPanel {
retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
- agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
- agent_navigation_menu: Option<Entity<ContextMenu>>,
_extension_subscription: Option<Subscription>,
_project_subscription: Subscription,
zoomed: bool,
@@ -743,18 +719,43 @@ impl AgentPanel {
let selected_agent = self.selected_agent.clone();
let is_draft_active = self.active_thread_is_draft(cx);
- let last_active_thread = self.active_agent_thread(cx).map(|thread| {
- let thread = thread.read(cx);
-
- let title = thread.title();
- let work_dirs = thread.work_dirs().cloned();
- SerializedActiveThread {
- session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()),
- agent_type: self.selected_agent.clone(),
- title: title.map(|t| t.to_string()),
- work_dirs: work_dirs.map(|dirs| dirs.serialize()),
- }
- });
+ let last_active_thread = self
+ .active_agent_thread(cx)
+ .map(|thread| {
+ let thread = thread.read(cx);
+
+ let title = thread.title();
+ let work_dirs = thread.work_dirs().cloned();
+ SerializedActiveThread {
+ session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()),
+ agent_type: self.selected_agent.clone(),
+ title: title.map(|t| t.to_string()),
+ work_dirs: work_dirs.map(|dirs| dirs.serialize()),
+ }
+ })
+ .or_else(|| {
+ // The active view may be in `Loading` or `LoadError` — for
+ // example, while a restored thread is waiting for a custom
+ // agent to finish registering. Without this fallback, a
+ // stray `serialize()` triggered during that window would
+ // write `session_id=None` and wipe the restored session
+ if is_draft_active {
+ return None;
+ }
+ let conversation_view = self.active_conversation_view()?;
+ let session_id = conversation_view.read(cx).root_session_id.clone()?;
+ let metadata = ThreadMetadataStore::try_global(cx)
+ .and_then(|store| store.read(cx).entry_by_session(&session_id).cloned());
+ Some(SerializedActiveThread {
+ session_id: Some(session_id.0.to_string()),
+ agent_type: self.selected_agent.clone(),
+ title: metadata
+ .as_ref()
+ .and_then(|m| m.title.as_ref())
+ .map(|t| t.to_string()),
+ work_dirs: metadata.map(|m| m.folder_paths().serialize()),
+ })
+ });
let kvp = KeyValueStore::global(cx);
let draft_thread_prompt = self.draft_thread.as_ref().and_then(|conversation| {
@@ -950,7 +951,7 @@ impl AgentPanel {
pub(crate) fn new(
workspace: &Workspace,
prompt_store: Option<Entity<PromptStore>>,
- window: &mut Window,
+ _window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let fs = workspace.app_state().fs.clone();
@@ -968,48 +969,6 @@ impl AgentPanel {
let base_view = BaseView::Uninitialized;
- let weak_panel = cx.entity().downgrade();
-
- window.defer(cx, move |window, cx| {
- let panel = weak_panel.clone();
- let agent_navigation_menu =
- ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
- if let Some(panel) = panel.upgrade() {
- if let Some(history) = panel
- .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx))
- {
- menu = Self::populate_recently_updated_menu_section(
- menu, panel, history, cx,
- );
- menu = menu.action("View All", Box::new(OpenHistory));
- }
- }
-
- menu = menu
- .fixed_width(px(320.).into())
- .keep_open_on_confirm(false)
- .key_context("NavigationMenu");
-
- menu
- });
- weak_panel
- .update(cx, |panel, cx| {
- cx.subscribe_in(
- &agent_navigation_menu,
- window,
- |_, menu, _: &DismissEvent, window, cx| {
- menu.update(cx, |menu, _| {
- menu.clear_selected();
- });
- cx.focus_self(window);
- },
- )
- .detach();
- panel.agent_navigation_menu = Some(agent_navigation_menu);
- })
- .ok();
- });
-
let weak_panel = cx.entity().downgrade();
let onboarding = cx.new(|cx| {
AgentPanelOnboarding::new(
@@ -1082,8 +1041,7 @@ impl AgentPanel {
retained_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
- agent_navigation_menu_handle: PopoverMenuHandle::default(),
- agent_navigation_menu: None,
+
_extension_subscription: extension_subscription,
_project_subscription,
zoomed: false,
@@ -1426,31 +1384,30 @@ impl AgentPanel {
) {
let session_id = action.from_session_id.clone();
- let Some(history) = self
- .connection_store
+ let Some(thread) = ThreadStore::global(cx)
.read(cx)
- .entry(&Agent::NativeAgent)
- .and_then(|e| e.read(cx).history().cloned())
+ .entries()
+ .find(|t| t.id == session_id)
else {
- debug_panic!("Native agent is not registered");
+ log::error!("No session found for summarization with id {}", session_id);
+ return;
+ };
+
+ let Some(parent_session_id) = thread.parent_session_id else {
+ log::error!("Session {} has no parent session", session_id);
return;
};
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
- let thread = history
- .read(cx)
- .session_for_id(&session_id)
- .context("Session not found")?;
-
this.external_thread(
Some(Agent::NativeAgent),
None,
None,
None,
Some(AgentInitialContent::ThreadSummary {
- session_id: thread.session_id,
- title: thread.title,
+ session_id: parent_session_id,
+ title: Some(thread.title),
}),
true,
"agent_panel",
@@ -1521,79 +1478,6 @@ impl AgentPanel {
})
}
- fn has_history_for_selected_agent(&self, cx: &App) -> bool {
- match &self.selected_agent {
- Agent::NativeAgent => true,
- Agent::Custom { .. } => self
- .connection_store
- .read(cx)
- .entry(&self.selected_agent)
- .map_or(false, |entry| entry.read(cx).history().is_some()),
- #[cfg(any(test, feature = "test-support"))]
- Agent::Stub => false,
- }
- }
-
- fn history_for_selected_agent(
- &self,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Option<Entity<ThreadHistoryView>> {
- let agent = self.selected_agent.clone();
- let history = self
- .connection_store
- .read(cx)
- .entry(&agent)?
- .read(cx)
- .history()?
- .clone();
- Some(self.create_thread_history_view(agent, history, window, cx))
- }
-
- fn create_thread_history_view(
- &self,
- agent: Agent,
- history: Entity<ThreadHistory>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Entity<ThreadHistoryView> {
- let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
- cx.subscribe_in(
- &view,
- window,
- move |this, _, event, window, cx| match event {
- ThreadHistoryViewEvent::Open(thread) => {
- this.load_agent_thread(
- agent.clone(),
- thread.session_id.clone(),
- thread.work_dirs.clone(),
- thread.title.clone(),
- true,
- "agent_panel",
- window,
- cx,
- );
- }
- },
- )
- .detach();
- view
- }
-
- fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if matches!(self.overlay_view, Some(OverlayView::History { .. })) {
- self.clear_overlay(true, window, cx);
- return;
- }
-
- let Some(view) = self.history_for_selected_agent(window, cx) else {
- return;
- };
-
- self.set_overlay(OverlayView::History { view }, true, window, cx);
- cx.notify();
- }
-
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
if self.overlay_view.is_some() {
self.clear_overlay(true, window, cx);
@@ -1601,18 +1485,6 @@ impl AgentPanel {
}
}
- pub fn toggle_navigation_menu(
- &mut self,
- _: &ToggleNavigationMenu,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if !self.has_history_for_selected_agent(cx) {
- return;
- }
- self.agent_navigation_menu_handle.toggle(window, cx);
- }
-
pub fn toggle_options_menu(
&mut self,
_: &ToggleOptionsMenu,
@@ -2256,18 +2128,7 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let was_in_history = matches!(self.overlay_view, Some(OverlayView::History { .. }));
self.overlay_view = Some(overlay);
-
- if let Some(OverlayView::History { view }) = &self.overlay_view
- && !was_in_history
- {
- view.update(cx, |view, cx| {
- view.history()
- .update(cx, |history, cx| history.refresh_full_history(cx))
- });
- }
-
if focus {
self.focus_handle(cx).focus(window, cx);
}
@@ -2324,7 +2185,6 @@ impl AgentPanel {
fn visible_surface(&self) -> VisibleSurface<'_> {
if let Some(overlay_view) = &self.overlay_view {
return match overlay_view {
- OverlayView::History { view } => VisibleSurface::History(view),
OverlayView::Configuration => {
VisibleSurface::Configuration(self.configuration.as_ref())
}
@@ -2343,10 +2203,6 @@ impl AgentPanel {
self.overlay_view.is_some()
}
- fn is_history_or_configuration_visible(&self) -> bool {
- self.is_overlay_open()
- }
-
fn visible_font_size(&self) -> WhichFontSize {
self.overlay_view.as_ref().map_or_else(
|| self.base_view.which_font_size_used(),
@@ -2354,63 +2210,6 @@ impl AgentPanel {
)
}
- fn populate_recently_updated_menu_section(
- mut menu: ContextMenu,
- panel: Entity<Self>,
- view: Entity<ThreadHistoryView>,
- cx: &mut Context<ContextMenu>,
- ) -> ContextMenu {
- let entries = view
- .read(cx)
- .history()
- .read(cx)
- .sessions()
- .iter()
- .take(RECENTLY_UPDATED_MENU_LIMIT)
- .cloned()
- .collect::<Vec<_>>();
-
- if entries.is_empty() {
- return menu;
- }
-
- menu = menu.header("Recently Updated");
-
- for entry in entries {
- let title = entry
- .title
- .as_ref()
- .filter(|title| !title.is_empty())
- .cloned()
- .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
-
- menu = menu.entry(title, None, {
- let panel = panel.downgrade();
- let entry = entry.clone();
- move |window, cx| {
- let entry = entry.clone();
- panel
- .update(cx, move |this, cx| {
- let agent = this.selected_agent(cx);
- this.load_agent_thread(
- agent,
- entry.session_id.clone(),
- entry.work_dirs.clone(),
- entry.title.clone(),
- true,
- "agent_panel",
- window,
- cx,
- );
- })
- .ok();
- }
- });
- }
-
- menu.separator()
- }
-
fn subscribe_to_active_thread_view(
server_view: &Entity<ConversationView>,
window: &mut Window,
@@ -2680,7 +2479,6 @@ impl Focusable for AgentPanel {
match self.visible_surface() {
VisibleSurface::Uninitialized => self.focus_handle.clone(),
VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx),
- VisibleSurface::History(view) => view.read(cx).focus_handle(cx),
VisibleSurface::Configuration(configuration) => {
if let Some(configuration) = configuration {
configuration.focus_handle(cx)
@@ -3016,7 +2814,6 @@ impl AgentPanel {
.into_any_element()
}
}
- VisibleSurface::History(_) => Label::new("History").truncate().into_any_element(),
VisibleSurface::Configuration(_) => {
Label::new("Settings").truncate().into_any_element()
}
@@ -3143,47 +2940,6 @@ impl AgentPanel {
})
}
- fn render_recent_entries_menu(
- &self,
- icon: IconName,
- corner: Corner,
- cx: &mut Context<Self>,
- ) -> impl IntoElement {
- let focus_handle = self.focus_handle(cx);
-
- PopoverMenu::new("agent-nav-menu")
- .trigger_with_tooltip(
- IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
- {
- move |_window, cx| {
- Tooltip::for_action_in(
- "Toggle Recently Updated Threads",
- &ToggleNavigationMenu,
- &focus_handle,
- cx,
- )
- }
- },
- )
- .anchor(corner)
- .with_handle(self.agent_navigation_menu_handle.clone())
- .menu({
- let menu = self.agent_navigation_menu.clone();
- move |window, cx| {
- telemetry::event!("View Thread History Clicked");
-
- if let Some(menu) = menu.as_ref() {
- menu.update(cx, |_, cx| {
- cx.defer_in(window, |menu, window, cx| {
- menu.rebuild(window, cx);
- });
- })
- }
- menu.clone()
- }
- })
- }
-
fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
@@ -3453,11 +3209,9 @@ impl AgentPanel {
selected_agent.into_any_element()
};
- let show_history_menu = self.has_history_for_selected_agent(cx);
- let agent_v2_enabled = agent_v2_enabled(cx);
let is_empty_state = !self.active_thread_has_messages(cx);
- let is_in_history_or_config = self.is_history_or_configuration_visible();
+ let is_in_history_or_config = self.is_overlay_open();
let is_full_screen = self.is_zoomed(window, cx);
let full_screen_button = if is_full_screen {
@@ -3476,7 +3230,7 @@ impl AgentPanel {
}))
};
- let use_v2_empty_toolbar = agent_v2_enabled && is_empty_state && !is_in_history_or_config;
+ let use_v2_empty_toolbar = is_empty_state && !is_in_history_or_config;
let max_content_width = AgentSettings::get_global(cx).max_content_width;
@@ -3552,13 +3306,6 @@ impl AgentPanel {
.gap_1()
.pl_1()
.pr_1()
- .when(show_history_menu && !agent_v2_enabled, |this| {
- this.child(self.render_recent_entries_menu(
- IconName::MenuAltTemp,
- Corner::TopRight,
- cx,
- ))
- })
.child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
@@ -3604,13 +3351,6 @@ impl AgentPanel {
.pl_1()
.pr_1()
.child(new_thread_menu)
- .when(show_history_menu && !agent_v2_enabled, |this| {
- this.child(self.render_recent_entries_menu(
- IconName::MenuAltTemp,
- Corner::TopRight,
- cx,
- ))
- })
.child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
@@ -3698,17 +3438,13 @@ impl AgentPanel {
match &self.base_view {
BaseView::Uninitialized => false,
- BaseView::AgentThread { conversation_view }
- if conversation_view.read(cx).as_native_thread(cx).is_none() =>
- {
- false
- }
BaseView::AgentThread { conversation_view } => {
- let history_is_empty = conversation_view
- .read(cx)
- .history()
- .is_none_or(|h| h.read(cx).is_empty());
- history_is_empty || !has_configured_non_zed_providers
+ if conversation_view.read(cx).as_native_thread(cx).is_some() {
+ let history_is_empty = ThreadStore::global(cx).read(cx).is_empty();
+ history_is_empty || !has_configured_non_zed_providers
+ } else {
+ false
+ }
}
}
}
@@ -3896,16 +3632,12 @@ impl Render for AgentPanel {
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
this.new_thread(action, window, cx);
}))
- .on_action(cx.listener(|this, _: &OpenHistory, window, cx| {
- this.open_history(window, cx);
- }))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
this.open_configuration(window, cx);
}))
.on_action(cx.listener(Self::open_active_thread_as_markdown))
.on_action(cx.listener(Self::deploy_rules_library))
.on_action(cx.listener(Self::go_back))
- .on_action(cx.listener(Self::toggle_navigation_menu))
.on_action(cx.listener(Self::toggle_options_menu))
.on_action(cx.listener(Self::increase_font_size))
.on_action(cx.listener(Self::decrease_font_size))
@@ -3926,7 +3658,6 @@ impl Render for AgentPanel {
VisibleSurface::AgentThread(conversation_view) => parent
.child(conversation_view.clone())
.child(self.render_drag_target(cx)),
- VisibleSurface::History(view) => parent.child(view.clone()),
VisibleSurface::Configuration(configuration) => {
parent.children(configuration.cloned())
}
@@ -3970,13 +3701,6 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
return;
};
- let history = panel
- .read(cx)
- .connection_store()
- .read(cx)
- .entry(&crate::Agent::NativeAgent)
- .and_then(|s| s.read(cx).history())
- .map(|h| h.downgrade());
let project = workspace.read(cx).project().downgrade();
let panel = panel.read(cx);
let thread_store = panel.thread_store().clone();
@@ -3986,7 +3710,6 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
project,
thread_store,
None,
- history,
initial_prompt,
window,
cx,
@@ -4052,6 +3775,35 @@ impl AgentPanel {
self.set_base_view(thread.into(), true, window, cx);
}
+ /// Opens a restored external thread with an arbitrary AgentServer and
+ /// a specific `resume_session_id` — as if we just restored from the KVP.
+ ///
+ /// Test-only helper. Not compiled into production builds.
+ pub fn open_restored_thread_with_server(
+ &mut self,
+ server: Rc<dyn AgentServer>,
+ resume_session_id: acp::SessionId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let ext_agent = Agent::Custom {
+ id: server.agent_id(),
+ };
+
+ let thread = self.create_agent_thread_with_server(
+ ext_agent,
+ Some(server),
+ Some(resume_session_id),
+ None,
+ None,
+ None,
+ "agent_panel",
+ window,
+ cx,
+ );
+ self.set_base_view(thread.into(), true, window, cx);
+ }
+
/// Returns the currently active thread view, if any.
///
/// This is a test-only accessor that exposes the private `active_thread_view()`
@@ -4060,14 +3812,6 @@ impl AgentPanel {
self.active_conversation_view()
}
- /// Opens the history view.
- ///
- /// This is a test-only helper that exposes the private `open_history()`
- /// method for visual tests.
- pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.open_history(window, cx);
- }
-
/// Creates a draft thread using a stub server and sets it as the active view.
#[cfg(any(test, feature = "test-support"))]
pub fn open_draft_with_server(
@@ -4453,6 +4197,115 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_serialize_preserves_session_id_in_load_error(cx: &mut TestAppContext) {
+ use crate::conversation_view::tests::FlakyAgentServer;
+ use crate::thread_metadata_store::{ThreadId, ThreadMetadata};
+ use chrono::Utc;
+ use project::{AgentId as ProjectAgentId, WorktreePaths};
+
+ init_test(cx);
+ cx.update(|cx| {
+ agent::ThreadStore::init_global(cx);
+ language_model::LanguageModelRegistry::test(cx);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+
+ let multi_workspace =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace
+ .read_with(cx, |mw, _cx| mw.workspace().clone())
+ .unwrap();
+ workspace.update(cx, |workspace, _cx| {
+ workspace.set_random_database_id();
+ });
+ let workspace_id = workspace
+ .read_with(cx, |workspace, _cx| workspace.database_id())
+ .expect("workspace should have a database id");
+
+ let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+ // Simulate a previous run that persisted metadata for this session.
+ let resume_session_id = acp::SessionId::new("persistent-session");
+ cx.update(|_window, cx| {
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ thread_id: ThreadId::new(),
+ session_id: Some(resume_session_id.clone()),
+ agent_id: ProjectAgentId::new("Flaky"),
+ title: Some("Persistent chat".into()),
+ updated_at: Utc::now(),
+ created_at: Some(Utc::now()),
+ interacted_at: None,
+ worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()),
+ remote_connection: None,
+ archived: false,
+ },
+ cx,
+ );
+ });
+ });
+
+ let panel = workspace.update_in(cx, |workspace, window, cx| {
+ cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
+ });
+
+ // Open a restored thread using a flaky server so the initial connect
+ // fails and the view lands in LoadError — mirroring the cold-start
+ // race against a custom agent over SSH.
+ let (server, _fail) =
+ FlakyAgentServer::new(StubAgentConnection::new().with_supports_load_session(true));
+ panel.update_in(cx, |panel, window, cx| {
+ panel.open_restored_thread_with_server(
+ Rc::new(server),
+ resume_session_id.clone(),
+ window,
+ cx,
+ );
+ });
+ cx.run_until_parked();
+
+ // Sanity: the view couldn't connect, so no live AcpThread exists.
+ panel.read_with(cx, |panel, cx| {
+ assert!(
+ panel.active_agent_thread(cx).is_none(),
+ "active_agent_thread should be None while the flaky server is failing"
+ );
+ let conversation_view = panel
+ .active_conversation_view()
+ .expect("panel should still have an active ConversationView");
+ assert_eq!(
+ conversation_view.read(cx).root_session_id.as_ref(),
+ Some(&resume_session_id),
+ "ConversationView should still hold the restored session id"
+ );
+ });
+
+ // Serialize while in LoadError. Before the fix this wrote
+ // `session_id=None` to the KVP and permanently lost the session.
+ panel.update(cx, |panel, cx| panel.serialize(cx));
+ cx.run_until_parked();
+
+ let kvp = cx.update(|_window, cx| KeyValueStore::global(cx));
+ let serialized: Option<SerializedAgentPanel> = cx
+ .background_spawn(async move { read_serialized_panel(workspace_id, &kvp) })
+ .await;
+ let serialized_session_id = serialized
+ .as_ref()
+ .and_then(|p| p.last_active_thread.as_ref())
+ .and_then(|t| t.session_id.clone());
+ assert_eq!(
+ serialized_session_id,
+ Some(resume_session_id.0.to_string()),
+ "serialize() must preserve the restored session id even while the \
+ ConversationView is in LoadError; otherwise the bug survives a \
+ restart because the KVP has been wiped"
+ );
+ }
+
/// Extracts the text from a Text content block, panicking if it's not Text.
fn expect_text_block(block: &acp::ContentBlock) -> &str {
match block {
@@ -27,8 +27,6 @@ mod terminal_codegen;
mod terminal_inline_assistant;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
-mod thread_history;
-mod thread_history_view;
mod thread_import;
pub mod thread_metadata_store;
pub mod thread_worktree_archive;
@@ -45,7 +43,7 @@ use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter;
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
-use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal as _, Window, actions};
+use gpui::{Action, App, Context, Entity, SharedString, Window, actions};
use language::{
LanguageRegistry,
language_settings::{AllLanguageSettings, EditPredictionProvider},
@@ -55,10 +53,9 @@ use language_model::{
};
use project::{AgentId, DisableAiSettings};
use prompt_store::PromptBuilder;
-use release_channel::ReleaseChannel;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use settings::{DockPosition, DockSide, LanguageModelSelection, Settings as _, SettingsStore};
+use settings::{LanguageModelSelection, Settings as _, SettingsStore};
use std::any::TypeId;
use workspace::Workspace;
@@ -73,8 +70,6 @@ pub use external_source_prompt::ExternalSourcePrompt;
pub(crate) use mode_selector::ModeSelector;
pub(crate) use model_selector::ModelSelector;
pub(crate) use model_selector_popover::ModelSelectorPopover;
-pub(crate) use thread_history::ThreadHistory;
-pub(crate) use thread_history_view::*;
pub use thread_import::{
AcpThreadImportOnboarding, CrossChannelImportOnboarding, ThreadImportModal,
channels_with_threads, import_threads_from_other_channels,
@@ -89,8 +84,6 @@ actions!(
[
/// Toggles the menu to create new agent threads.
ToggleNewThreadMenu,
- /// Toggles the navigation menu for switching between threads and views.
- ToggleNavigationMenu,
/// Toggles the options menu for agent settings and preferences.
ToggleOptionsMenu,
/// Toggles the profile or mode selector for switching between agent profiles.
@@ -101,10 +94,6 @@ actions!(
CycleFavoriteModels,
/// Expands the message editor to full size.
ExpandMessageEditor,
- /// Removes all thread history.
- RemoveHistory,
- /// Opens the conversation history view.
- OpenHistory,
/// Adds a context server to the configuration.
AddContextServer,
/// Archives the currently selected thread.
@@ -510,34 +499,7 @@ pub fn init(
})
.detach();
- let agent_v2_enabled = agent_v2_enabled(cx);
- if agent_v2_enabled {
- maybe_backfill_editor_layout(fs, is_new_install, cx);
- }
-
- SettingsStore::update_global(cx, |store, cx| {
- store.update_default_settings(cx, |defaults| {
- if agent_v2_enabled {
- defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left);
- defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right);
- defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right);
- defaults.collaboration_panel.get_or_insert_default().dock =
- Some(DockPosition::Right);
- defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right);
- } else {
- defaults.agent.get_or_insert_default().dock = Some(DockPosition::Right);
- defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Left);
- defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Left);
- defaults.collaboration_panel.get_or_insert_default().dock =
- Some(DockPosition::Left);
- defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Left);
- }
- });
- });
-}
-
-fn agent_v2_enabled(cx: &App) -> bool {
- !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable))
+ maybe_backfill_editor_layout(fs, is_new_install, cx);
}
fn maybe_backfill_editor_layout(fs: Arc<dyn Fs>, is_new_install: bool, cx: &mut App) {
@@ -565,7 +527,6 @@ fn maybe_backfill_editor_layout(fs: Arc<dyn Fs>, is_new_install: bool, cx: &mut
fn update_command_palette_filter(cx: &mut App) {
let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
let agent_enabled = AgentSettings::get_global(cx).enabled;
- let agent_v2_enabled = agent_v2_enabled(cx);
let edit_prediction_provider = AllLanguageSettings::get_global(cx)
.edit_predictions
@@ -633,12 +594,8 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_namespace("zed_predict_onboarding");
filter.show_action_types(&[TypeId::of::<zed_actions::OpenZedPredictOnboarding>()]);
- }
- if agent_v2_enabled {
filter.show_namespace("multi_workspace");
- } else {
- filter.hide_namespace("multi_workspace");
}
});
}
@@ -5,7 +5,7 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::DEFAULT_THREAD_TITLE;
-use crate::ThreadHistory;
+use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
use acp_thread::MentionUri;
use agent_client_protocol as acp;
use anyhow::Result;
@@ -222,7 +222,6 @@ pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
source: Arc<T>,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
}
@@ -232,7 +231,6 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
source: T,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
) -> Self {
@@ -241,7 +239,6 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
editor,
mention_set,
workspace,
- history,
prompt_store,
}
}
@@ -918,16 +915,8 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
Some(PromptContextType::Thread) => {
- if let Some(history) = self.history.as_ref().and_then(|h| h.upgrade()) {
- let sessions = history
- .read(cx)
- .sessions()
- .iter()
- .map(|session| SessionMatch {
- session_id: session.session_id.clone(),
- title: session_title(session.title.clone()),
- })
- .collect::<Vec<_>>();
+ let sessions = collect_session_matches(cx);
+ if !sessions.is_empty() {
let search_task =
filter_sessions_by_query(query, cancellation_flag, sessions, cx);
cx.spawn(async move |_cx| {
@@ -1144,29 +1133,21 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
return Task::ready(recent);
}
- if let Some(history) = self.history.as_ref().and_then(|h| h.upgrade()) {
- const RECENT_COUNT: usize = 2;
- recent.extend(
- history
- .read(cx)
- .sessions()
- .into_iter()
- .map(|session| SessionMatch {
- session_id: session.session_id.clone(),
- title: session_title(session.title.clone()),
- })
- .filter(|session| {
- let uri = MentionUri::Thread {
- id: session.session_id.clone(),
- name: session.title.to_string(),
- };
- !mentions.contains(&uri)
- })
- .take(RECENT_COUNT)
- .map(Match::RecentThread),
- );
- return Task::ready(recent);
- }
+ let sessions = collect_session_matches(cx);
+ const RECENT_COUNT: usize = 2;
+ recent.extend(
+ sessions
+ .into_iter()
+ .filter(|session| {
+ let uri = MentionUri::Thread {
+ id: session.session_id.clone(),
+ name: session.title.to_string(),
+ };
+ !mentions.contains(&uri)
+ })
+ .take(RECENT_COUNT)
+ .map(Match::RecentThread),
+ );
Task::ready(recent)
}
@@ -2030,6 +2011,28 @@ pub(crate) fn search_symbols(
})
}
+fn collect_session_matches(cx: &App) -> Vec<SessionMatch> {
+ let Some(store) = ThreadMetadataStore::try_global(cx) else {
+ return Vec::new();
+ };
+ let mut entries: Vec<&ThreadMetadata> = store
+ .read(cx)
+ .entries()
+ .filter(|t| !t.archived && t.agent_id == *agent::ZED_AGENT_ID)
+ .collect();
+ entries.sort_by_key(|t| Reverse(t.updated_at));
+ entries
+ .into_iter()
+ .map(|metadata| {
+ let info = acp_thread::AgentSessionInfo::from(metadata);
+ SessionMatch {
+ session_id: info.session_id,
+ title: session_title(info.title),
+ }
+ })
+ .collect()
+}
+
fn filter_sessions_by_query(
query: String,
cancellation_flag: Arc<AtomicBool>,
@@ -74,7 +74,6 @@ use zed_actions::assistant::OpenRulesLibrary;
use super::config_options::ConfigOptionsView;
use super::entry_view_state::EntryViewState;
-use super::thread_history::ThreadHistory;
use crate::ModeSelector;
use crate::ModelSelectorPopover;
use crate::agent_connection_store::{
@@ -85,7 +84,7 @@ use crate::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::profile_selector::{ProfileProvider, ProfileSelector};
-use crate::thread_metadata_store::ThreadId;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
use crate::ui::{AgentNotification, AgentNotificationEvent};
use crate::{
Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce,
@@ -556,7 +555,6 @@ pub struct ConnectedServerState {
active_id: Option<acp::SessionId>,
pub(crate) threads: HashMap<acp::SessionId, Entity<ThreadView>>,
connection: Rc<dyn AgentConnection>,
- history: Option<Entity<ThreadHistory>>,
conversation: Entity<Conversation>,
_connection_entry_subscription: Subscription,
}
@@ -701,7 +699,7 @@ impl ConversationView {
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let (resume_session_id, cwd, title) = self
+ let (resume_session_id, work_dirs, title) = self
.root_thread_view()
.map(|thread_view| {
let tv = thread_view.read(cx);
@@ -712,14 +710,25 @@ impl ConversationView {
thread.title(),
)
})
- .unwrap_or((None, None, None));
+ .unwrap_or_else(|| {
+ let session_id = self.root_session_id.clone();
+ let (work_dirs, title) = session_id
+ .as_ref()
+ .and_then(|id| {
+ let store = ThreadMetadataStore::try_global(cx)?;
+ let entry = store.read(cx).entry_by_session(id)?;
+ Some((Some(entry.folder_paths().clone()), entry.title.clone()))
+ })
+ .unwrap_or((None, None));
+ (session_id, work_dirs, title)
+ });
let state = Self::initial_state(
self.agent.clone(),
self.connection_store.clone(),
self.connection_key.clone(),
resume_session_id,
- cwd,
+ work_dirs,
title,
self.project.clone(),
None,
@@ -791,11 +800,8 @@ impl ConversationView {
};
let load_task = cx.spawn_in(window, async move |this, cx| {
- let (connection, history) = match connect_result.await {
- Ok(AgentConnectedState {
- connection,
- history,
- }) => (connection, history),
+ let connection = match connect_result.await {
+ Ok(AgentConnectedState { connection, .. }) => connection,
Err(err) => {
this.update_in(cx, |this, window, cx| {
this.handle_load_error(err, window, cx);
@@ -891,7 +897,6 @@ impl ConversationView {
conversation.clone(),
resumed_without_history,
initial_content,
- history.clone(),
window,
cx,
);
@@ -912,7 +917,6 @@ impl ConversationView {
active_id: Some(root_session_id.clone()),
threads: HashMap::from_iter([(root_session_id, current)]),
conversation,
- history,
_connection_entry_subscription: connection_entry_subscription,
}),
cx,
@@ -945,7 +949,6 @@ impl ConversationView {
conversation: Entity<Conversation>,
resumed_without_history: bool,
initial_content: Option<AgentInitialContent>,
- history: Option<Entity<ThreadHistory>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ThreadView> {
@@ -962,7 +965,6 @@ impl ConversationView {
self.workspace.clone(),
self.project.downgrade(),
self.thread_store.clone(),
- history.as_ref().map(|h| h.downgrade()),
self.prompt_store.clone(),
session_capabilities.clone(),
self.agent.agent_id(),
@@ -1130,7 +1132,6 @@ impl ConversationView {
resumed_without_history,
self.project.downgrade(),
self.thread_store.clone(),
- history,
self.prompt_store.clone(),
initial_content,
subscriptions,
@@ -1212,7 +1213,6 @@ impl ConversationView {
threads: HashMap::default(),
connection,
conversation: cx.new(|_cx| Conversation::default()),
- history: None,
_connection_entry_subscription: Subscription::new(|| {}),
}),
cx,
@@ -1787,9 +1787,9 @@ impl ConversationView {
cx.spawn_in(window, async move |this, cx| {
let subagent_thread = subagent_thread_task.await?;
this.update_in(cx, |this, window, cx| {
- let Some((conversation, history)) = this
+ let Some(conversation) = this
.as_connected()
- .map(|connected| (connected.conversation.clone(), connected.history.clone()))
+ .map(|connected| connected.conversation.clone())
else {
return;
};
@@ -1797,15 +1797,8 @@ impl ConversationView {
conversation.update(cx, |conversation, cx| {
conversation.register_thread(subagent_thread.clone(), cx);
});
- let view = this.new_thread_view(
- subagent_thread,
- conversation,
- false,
- None,
- history,
- window,
- cx,
- );
+ let view =
+ this.new_thread_view(subagent_thread, conversation, false, None, window, cx);
let Some(connected) = this.as_connected_mut() else {
return;
};
@@ -2264,7 +2257,6 @@ impl ConversationView {
let Some(connected) = self.as_connected() else {
return;
};
- let history = connected.history.as_ref().map(|h| h.downgrade());
let Some(thread) = connected.active_view() else {
return;
};
@@ -2305,7 +2297,6 @@ impl ConversationView {
workspace.clone(),
project.clone(),
None,
- history.clone(),
None,
session_capabilities.clone(),
agent_name.clone(),
@@ -2709,10 +2700,6 @@ impl ConversationView {
Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx);
})
}
-
- pub fn history(&self) -> Option<&Entity<ThreadHistory>> {
- self.as_connected().and_then(|c| c.history.as_ref())
- }
}
fn loading_contents_spinner(size: IconSize) -> AnyElement {
@@ -2862,9 +2849,7 @@ fn plan_label_markdown_style(
#[cfg(test)]
pub(crate) mod tests {
- use acp_thread::{
- AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
- };
+ use acp_thread::StubAgentConnection;
use action_log::ActionLog;
use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
use agent_client_protocol::SessionId;
@@ -3029,66 +3014,6 @@ pub(crate) mod tests {
);
}
- #[gpui::test]
- async fn test_recent_history_refreshes_when_history_cache_updated(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_a = AgentSessionInfo::new(SessionId::new("session-a"));
- let session_b = AgentSessionInfo::new(SessionId::new("session-b"));
-
- // Use a connection that provides a session list so ThreadHistory is created
- let (conversation_view, history, cx) = setup_thread_view_with_history(
- StubAgentServer::new(SessionHistoryConnection::new(vec![session_a.clone()])),
- cx,
- )
- .await;
-
- // Initially has session_a from the connection's session list
- active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
- assert_eq!(view.recent_history_entries.len(), 1);
- assert_eq!(
- view.recent_history_entries[0].session_id,
- session_a.session_id
- );
- });
-
- // Swap to a different session list
- let list_b: Rc<dyn AgentSessionList> =
- Rc::new(StubSessionList::new(vec![session_b.clone()]));
- history.update(cx, |history, cx| {
- history.set_session_list(list_b, cx);
- });
- cx.run_until_parked();
-
- active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
- assert_eq!(view.recent_history_entries.len(), 1);
- assert_eq!(
- view.recent_history_entries[0].session_id,
- session_b.session_id
- );
- });
- }
-
- #[gpui::test]
- async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session = AgentSessionInfo::new(SessionId::new("history-session"));
- let (conversation_view, _history, cx) = setup_thread_view_with_history(
- StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])),
- cx,
- )
- .await;
-
- active_thread(&conversation_view, cx).read_with(cx, |view, _cx| {
- assert_eq!(view.recent_history_entries.len(), 1);
- assert_eq!(
- view.recent_history_entries[0].session_id,
- session.session_id
- );
- });
- }
-
#[gpui::test]
async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
init_test(cx);
@@ -3415,6 +3340,122 @@ pub(crate) mod tests {
});
}
+ #[gpui::test]
+ async fn test_reset_preserves_session_id_after_load_error(cx: &mut TestAppContext) {
+ use crate::thread_metadata_store::{ThreadId, ThreadMetadata};
+ use chrono::Utc;
+ use project::{AgentId as ProjectAgentId, WorktreePaths};
+ use std::sync::atomic::Ordering;
+
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+ let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
+
+ // Simulate a previous run that persisted metadata for this session.
+ let resume_session_id = SessionId::new("persistent-session");
+ let stored_title: SharedString = "Persistent chat".into();
+ cx.update(|_window, cx| {
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.save(
+ ThreadMetadata {
+ thread_id: ThreadId::new(),
+ session_id: Some(resume_session_id.clone()),
+ agent_id: ProjectAgentId::new("Flaky"),
+ title: Some(stored_title.clone()),
+ updated_at: Utc::now(),
+ created_at: Some(Utc::now()),
+ interacted_at: None,
+ worktree_paths: WorktreePaths::from_folder_paths(&PathList::default()),
+ remote_connection: None,
+ archived: false,
+ },
+ cx,
+ );
+ });
+ });
+
+ let connection = StubAgentConnection::new().with_supports_load_session(true);
+ let (server, fail) = FlakyAgentServer::new(connection);
+
+ let conversation_view = cx.update(|window, cx| {
+ cx.new(|cx| {
+ ConversationView::new(
+ Rc::new(server),
+ connection_store,
+ Agent::Custom { id: "Flaky".into() },
+ Some(resume_session_id.clone()),
+ None,
+ None,
+ None,
+ None,
+ workspace.downgrade(),
+ project.clone(),
+ Some(thread_store),
+ None,
+ "agent_panel",
+ window,
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+
+ // The first connect() fails, so we land in LoadError.
+ conversation_view.read_with(cx, |view, _cx| {
+ assert!(
+ matches!(view.server_state, ServerState::LoadError { .. }),
+ "expected LoadError after failed initial connect"
+ );
+ assert_eq!(
+ view.root_session_id.as_ref(),
+ Some(&resume_session_id),
+ "root_session_id should still hold the original id while in LoadError"
+ );
+ });
+
+ // Now let the agent come online and emit AgentServersUpdated. This is
+ // the moment the bug would have stomped on root_session_id.
+ fail.store(false, Ordering::SeqCst);
+ project.update(cx, |project, cx| {
+ project
+ .agent_server_store()
+ .update(cx, |_store, cx| cx.emit(project::AgentServersUpdated));
+ });
+ cx.run_until_parked();
+
+ // The retry should have resumed the ORIGINAL session, not created a
+ // brand-new one.
+ conversation_view.read_with(cx, |view, cx| {
+ let connected = view
+ .as_connected()
+ .expect("should be Connected after flaky server comes online");
+ let active_id = connected
+ .active_id
+ .as_ref()
+ .expect("Connected state should have an active_id");
+ assert_eq!(
+ active_id, &resume_session_id,
+ "reset() must resume the original session id, not call new_session()"
+ );
+ let active_thread = view
+ .active_thread()
+ .expect("should have an active thread view");
+ let thread_session = active_thread.read(cx).thread.read(cx).session_id().clone();
+ assert_eq!(
+ thread_session, resume_session_id,
+ "the live AcpThread should hold the resumed session id"
+ );
+ });
+ }
+
#[gpui::test]
async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
init_test(cx);
@@ -4040,22 +4081,7 @@ pub(crate) mod tests {
agent: impl AgentServer + 'static,
cx: &mut TestAppContext,
) -> (Entity<ConversationView>, &mut VisualTestContext) {
- let (conversation_view, _history, cx) =
- setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
- (conversation_view, cx)
- }
-
- async fn setup_thread_view_with_history(
- agent: impl AgentServer + 'static,
- cx: &mut TestAppContext,
- ) -> (
- Entity<ConversationView>,
- Entity<ThreadHistory>,
- &mut VisualTestContext,
- ) {
- let (conversation_view, history, cx) =
- setup_conversation_view_with_history_and_initial_content(agent, None, cx).await;
- (conversation_view, history.expect("Missing history"), cx)
+ setup_conversation_view_with_initial_content_opt(agent, None, cx).await
}
async fn setup_conversation_view_with_initial_content(
@@ -4063,25 +4089,14 @@ pub(crate) mod tests {
initial_content: AgentInitialContent,
cx: &mut TestAppContext,
) -> (Entity<ConversationView>, &mut VisualTestContext) {
- let (conversation_view, _history, cx) =
- setup_conversation_view_with_history_and_initial_content(
- agent,
- Some(initial_content),
- cx,
- )
- .await;
- (conversation_view, cx)
+ setup_conversation_view_with_initial_content_opt(agent, Some(initial_content), cx).await
}
- async fn setup_conversation_view_with_history_and_initial_content(
+ async fn setup_conversation_view_with_initial_content_opt(
agent: impl AgentServer + 'static,
initial_content: Option<AgentInitialContent>,
cx: &mut TestAppContext,
- ) -> (
- Entity<ConversationView>,
- Option<Entity<ThreadHistory>>,
- &mut VisualTestContext,
- ) {
+ ) -> (Entity<ConversationView>, &mut VisualTestContext) {
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (multi_workspace, cx) =
@@ -4117,14 +4132,7 @@ pub(crate) mod tests {
});
cx.run_until_parked();
- let history = cx.update(|_window, cx| {
- connection_store
- .read(cx)
- .entry(&agent_key)
- .and_then(|e| e.read(cx).history().cloned())
- });
-
- (conversation_view, history, cx)
+ (conversation_view, cx)
}
fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
@@ -4256,24 +4264,51 @@ pub(crate) mod tests {
}
}
- #[derive(Clone)]
- struct StubSessionList {
- sessions: Vec<AgentSessionInfo>,
+ /// Agent server whose `connect()` fails while `fail` is `true` and
+ /// returns the wrapped connection otherwise. Used to simulate the
+ /// race where an external agent isn't yet registered at startup.
+ pub(crate) struct FlakyAgentServer {
+ connection: StubAgentConnection,
+ fail: Arc<std::sync::atomic::AtomicBool>,
}
- impl StubSessionList {
- fn new(sessions: Vec<AgentSessionInfo>) -> Self {
- Self { sessions }
+ impl FlakyAgentServer {
+ pub(crate) fn new(
+ connection: StubAgentConnection,
+ ) -> (Self, Arc<std::sync::atomic::AtomicBool>) {
+ let fail = Arc::new(std::sync::atomic::AtomicBool::new(true));
+ (
+ Self {
+ connection,
+ fail: fail.clone(),
+ },
+ fail,
+ )
}
}
- impl AgentSessionList for StubSessionList {
- fn list_sessions(
+ impl AgentServer for FlakyAgentServer {
+ fn logo(&self) -> ui::IconName {
+ ui::IconName::ZedAgent
+ }
+
+ fn agent_id(&self) -> AgentId {
+ "Flaky".into()
+ }
+
+ fn connect(
&self,
- _request: AgentSessionListRequest,
+ _delegate: AgentServerDelegate,
+ _project: Entity<Project>,
_cx: &mut App,
- ) -> Task<anyhow::Result<AgentSessionListResponse>> {
- Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
+ ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
+ if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
+ Task::ready(Err(anyhow!(
+ "Custom agent server `Flaky` is not registered"
+ )))
+ } else {
+ Task::ready(Ok(Rc::new(self.connection.clone())))
+ }
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
@@ -4281,17 +4316,6 @@ pub(crate) mod tests {
}
}
- #[derive(Clone)]
- struct SessionHistoryConnection {
- sessions: Vec<AgentSessionInfo>,
- }
-
- impl SessionHistoryConnection {
- fn new(sessions: Vec<AgentSessionInfo>) -> Self {
- Self { sessions }
- }
- }
-
fn build_test_thread(
connection: Rc<dyn AgentConnection>,
project: Entity<Project>,
@@ -4320,67 +4344,6 @@ pub(crate) mod tests {
})
}
- impl AgentConnection for SessionHistoryConnection {
- fn agent_id(&self) -> AgentId {
- AgentId::new("history-connection")
- }
-
- fn telemetry_id(&self) -> SharedString {
- "history-connection".into()
- }
-
- fn new_session(
- self: Rc<Self>,
- project: Entity<Project>,
- _work_dirs: PathList,
- cx: &mut App,
- ) -> Task<anyhow::Result<Entity<AcpThread>>> {
- let thread = build_test_thread(
- self,
- project,
- "SessionHistoryConnection",
- SessionId::new("history-session"),
- cx,
- );
- Task::ready(Ok(thread))
- }
-
- fn supports_load_session(&self) -> bool {
- true
- }
-
- fn session_list(&self, _cx: &mut App) -> Option<Rc<dyn AgentSessionList>> {
- Some(Rc::new(StubSessionList::new(self.sessions.clone())))
- }
-
- fn auth_methods(&self) -> &[acp::AuthMethod] {
- &[]
- }
-
- fn authenticate(
- &self,
- _method_id: acp::AuthMethodId,
- _cx: &mut App,
- ) -> Task<anyhow::Result<()>> {
- Task::ready(Ok(()))
- }
-
- fn prompt(
- &self,
- _id: acp_thread::UserMessageId,
- _params: acp::PromptRequest,
- _cx: &mut App,
- ) -> Task<anyhow::Result<acp::PromptResponse>> {
- Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
- }
-
- fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
-
- fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
- self
- }
- }
-
#[derive(Clone)]
struct ResumeOnlyAgentConnection;
@@ -326,14 +326,10 @@ pub struct ThreadView {
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
pub project: WeakEntity<Project>,
- pub recent_history_entries: Vec<AgentSessionInfo>,
- pub hovered_recent_history_item: Option<usize>,
pub show_external_source_prompt_warning: bool,
pub show_codex_windows_warning: bool,
pub multi_root_callout_dismissed: bool,
pub generating_indicator_in_list: bool,
- pub history: Option<Entity<ThreadHistory>>,
- pub _history_subscription: Option<Subscription>,
}
impl Focusable for ThreadView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
@@ -375,7 +371,6 @@ impl ThreadView {
resumed_without_history: bool,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- history: Option<Entity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
initial_content: Option<AgentInitialContent>,
mut subscriptions: Vec<Subscription>,
@@ -388,12 +383,6 @@ impl ThreadView {
let has_commands = !session_capabilities.read().available_commands().is_empty();
let placeholder = placeholder_text(agent_display_name.as_ref(), has_commands);
- let history_subscription = history.as_ref().map(|h| {
- cx.observe(h, |this, history, cx| {
- this.update_recent_history_from_cache(&history, cx);
- })
- });
-
let mut should_auto_submit = false;
let mut show_external_source_prompt_warning = false;
@@ -402,7 +391,6 @@ impl ThreadView {
workspace.clone(),
project.clone(),
thread_store,
- history.as_ref().map(|h| h.downgrade()),
prompt_store,
session_capabilities.clone(),
agent_id.clone(),
@@ -501,11 +489,6 @@ impl ThreadView {
}));
}));
- let recent_history_entries = history
- .as_ref()
- .map(|h| h.read(cx).get_recent_sessions(3))
- .unwrap_or_default();
-
let mut this = Self {
session_id,
parent_session_id,
@@ -568,11 +551,7 @@ impl ThreadView {
add_context_menu_handle: PopoverMenuHandle::default(),
thinking_effort_menu_handle: PopoverMenuHandle::default(),
project,
- recent_history_entries,
- hovered_recent_history_item: None,
show_external_source_prompt_warning,
- history,
- _history_subscription: history_subscription,
show_codex_windows_warning,
multi_root_callout_dismissed: false,
generating_indicator_in_list: false,
@@ -8666,16 +8645,6 @@ impl ThreadView {
.into_any_element()
}
- fn update_recent_history_from_cache(
- &mut self,
- history: &Entity<ThreadHistory>,
- cx: &mut Context<Self>,
- ) {
- self.recent_history_entries = history.read(cx).get_recent_sessions(3);
- self.hovered_recent_history_item = None;
- cx.notify();
- }
-
fn render_codex_windows_warning(&self, cx: &mut Context<Self>) -> Callout {
Callout::new()
.icon(IconName::Warning)
@@ -1,6 +1,5 @@
use std::ops::Range;
-use super::thread_history::ThreadHistory;
use acp_thread::{AcpThread, AgentThreadEntry};
use agent::ThreadStore;
use agent_client_protocol::ToolCallId;
@@ -26,7 +25,6 @@ pub struct EntryViewState {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
session_capabilities: SharedSessionCapabilities,
@@ -38,7 +36,6 @@ impl EntryViewState {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
@@ -47,7 +44,6 @@ impl EntryViewState {
workspace,
project,
thread_store,
- history,
prompt_store,
entries: Vec::new(),
session_capabilities,
@@ -90,7 +86,6 @@ impl EntryViewState {
self.workspace.clone(),
self.project.clone(),
self.thread_store.clone(),
- self.history.clone(),
self.prompt_store.clone(),
self.session_capabilities.clone(),
self.agent_id.clone(),
@@ -543,14 +538,12 @@ mod tests {
});
let thread_store = None;
- let history: Option<gpui::WeakEntity<crate::ThreadHistory>> = None;
let view_state = cx.new(|_cx| {
EntryViewState::new(
workspace.downgrade(),
project.downgrade(),
thread_store,
- history,
None,
Arc::new(RwLock::new(SessionCapabilities::default())),
"Test Agent".into(),
@@ -6,7 +6,6 @@ use std::ops::Range;
use std::sync::Arc;
use uuid::Uuid;
-use crate::ThreadHistory;
use crate::context::load_context;
use crate::mention_set::MentionSet;
use crate::{
@@ -231,11 +230,6 @@ impl InlineAssistant {
let prompt_store = agent_panel.prompt_store().as_ref().cloned();
let thread_store = agent_panel.thread_store().clone();
- let history = agent_panel
- .connection_store()
- .read(cx)
- .entry(&crate::Agent::NativeAgent)
- .and_then(|s| s.read(cx).history().cloned());
let handle_assist =
|window: &mut Window, cx: &mut Context<Workspace>| match inline_assist_target {
@@ -247,7 +241,6 @@ impl InlineAssistant {
workspace.project().downgrade(),
thread_store,
prompt_store,
- history.as_ref().map(|h| h.downgrade()),
action.prompt.clone(),
window,
cx,
@@ -262,7 +255,6 @@ impl InlineAssistant {
workspace.project().downgrade(),
thread_store,
prompt_store,
- history.as_ref().map(|h| h.downgrade()),
action.prompt.clone(),
window,
cx,
@@ -446,7 +438,6 @@ impl InlineAssistant {
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
initial_prompt: Option<String>,
window: &mut Window,
codegen_ranges: &[Range<Anchor>],
@@ -493,7 +484,6 @@ impl InlineAssistant {
self.fs.clone(),
thread_store.clone(),
prompt_store.clone(),
- history.clone(),
project.clone(),
workspace.clone(),
window,
@@ -585,7 +575,6 @@ impl InlineAssistant {
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut App,
@@ -604,7 +593,6 @@ impl InlineAssistant {
project,
thread_store,
prompt_store,
- history,
initial_prompt,
window,
&codegen_ranges,
@@ -630,7 +618,6 @@ impl InlineAssistant {
workspace: Entity<Workspace>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
window: &mut Window,
cx: &mut App,
) -> InlineAssistId {
@@ -650,7 +637,6 @@ impl InlineAssistant {
project,
thread_store,
prompt_store,
- history,
Some(initial_prompt),
window,
&[range],
@@ -1975,7 +1961,6 @@ pub mod evals {
project.downgrade(),
thread_store,
None,
- None,
Some(prompt),
window,
cx,
@@ -1,4 +1,3 @@
-use crate::ThreadHistory;
use agent::ThreadStore;
use agent_settings::AgentSettings;
use collections::{HashMap, VecDeque};
@@ -64,7 +63,6 @@ pub struct PromptEditor<T> {
pub editor: Entity<Editor>,
mode: PromptEditorMode,
mention_set: Entity<MentionSet>,
- history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
model_selector: Entity<AgentModelSelector>,
@@ -335,7 +333,6 @@ impl<T: 'static> PromptEditor<T> {
PromptEditorCompletionProviderDelegate,
cx.weak_entity(),
self.mention_set.clone(),
- self.history.clone(),
self.prompt_store.clone(),
self.workspace.clone(),
))));
@@ -1227,7 +1224,6 @@ impl PromptEditor<BufferCodegen> {
fs: Arc<dyn Fs>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
@@ -1274,7 +1270,6 @@ impl PromptEditor<BufferCodegen> {
let mut this: PromptEditor<BufferCodegen> = PromptEditor {
editor: prompt_editor.clone(),
mention_set,
- history,
prompt_store,
workspace,
model_selector: cx.new(|cx| {
@@ -1386,7 +1381,6 @@ impl PromptEditor<TerminalCodegen> {
fs: Arc<dyn Fs>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
@@ -1428,7 +1422,6 @@ impl PromptEditor<TerminalCodegen> {
let mut this = Self {
editor: prompt_editor.clone(),
mention_set,
- history,
prompt_store,
workspace,
model_selector: cx.new(|cx| {
@@ -1,6 +1,5 @@
use crate::DEFAULT_THREAD_TITLE;
use crate::SendImmediately;
-use crate::ThreadHistory;
use crate::{
ChatWithFollow,
completion_provider::{
@@ -394,7 +393,6 @@ impl MessageEditor {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
prompt_store: Option<Entity<PromptStore>>,
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
@@ -458,7 +456,6 @@ impl MessageEditor {
},
editor.downgrade(),
mention_set.clone(),
- history,
prompt_store.clone(),
workspace.clone(),
));
@@ -2053,7 +2050,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -2155,7 +2151,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
session_capabilities.clone(),
"Claude Agent".into(),
"Test",
@@ -2322,7 +2317,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
@@ -2549,7 +2543,6 @@ mod tests {
project.downgrade(),
Some(thread_store),
None,
- None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
@@ -3042,7 +3035,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3144,7 +3136,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3214,7 +3205,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3268,7 +3258,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3326,7 +3315,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3385,7 +3373,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3448,7 +3435,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3609,7 +3595,6 @@ mod tests {
project.downgrade(),
thread_store.clone(),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3724,7 +3709,6 @@ mod tests {
project.downgrade(),
Some(thread_store.clone()),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3804,7 +3788,6 @@ mod tests {
project.downgrade(),
Some(thread_store),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -3903,7 +3886,6 @@ mod tests {
project.downgrade(),
Some(thread_store),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -4159,7 +4141,6 @@ mod tests {
project.downgrade(),
Some(thread_store),
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -4253,7 +4234,6 @@ mod tests {
project.downgrade(),
None,
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -4403,7 +4383,6 @@ mod tests {
project.downgrade(),
None,
None,
- None,
Default::default(),
"Test Agent".into(),
"Test",
@@ -1,5 +1,4 @@
use crate::{
- ThreadHistory,
context::load_context,
inline_prompt_editor::{
CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId,
@@ -66,7 +65,6 @@ impl TerminalInlineAssistant {
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
- history: Option<WeakEntity<ThreadHistory>>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut App,
@@ -92,7 +90,6 @@ impl TerminalInlineAssistant {
self.fs.clone(),
thread_store.clone(),
prompt_store.clone(),
- history,
project.clone(),
workspace.clone(),
window,
@@ -1,772 +0,0 @@
-use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
-use agent_client_protocol as acp;
-use gpui::{App, Task};
-use std::rc::Rc;
-use ui::prelude::*;
-
-pub struct ThreadHistory {
- session_list: Rc<dyn AgentSessionList>,
- sessions: Vec<AgentSessionInfo>,
- _refresh_task: Task<()>,
- _watch_task: Option<Task<()>>,
-}
-
-impl ThreadHistory {
- pub fn new(session_list: Rc<dyn AgentSessionList>, cx: &mut Context<Self>) -> Self {
- let mut this = Self {
- session_list,
- sessions: Vec::new(),
- _refresh_task: Task::ready(()),
- _watch_task: None,
- };
-
- this.start_watching(cx);
- this
- }
-
- #[cfg(any(test, feature = "test-support"))]
- pub fn set_session_list(
- &mut self,
- session_list: Rc<dyn AgentSessionList>,
- cx: &mut Context<Self>,
- ) {
- if Rc::ptr_eq(&self.session_list, &session_list) {
- return;
- }
-
- self.session_list = session_list;
- self.sessions.clear();
- self._refresh_task = Task::ready(());
- self.start_watching(cx);
- }
-
- fn start_watching(&mut self, cx: &mut Context<Self>) {
- let Some(rx) = self.session_list.watch(cx) else {
- self._watch_task = None;
- self.refresh_sessions(false, cx);
- return;
- };
- self.session_list.notify_refresh();
-
- self._watch_task = Some(cx.spawn(async move |this, cx| {
- while let Ok(first_update) = rx.recv().await {
- let mut updates = vec![first_update];
- while let Ok(update) = rx.try_recv() {
- updates.push(update);
- }
-
- this.update(cx, |this, cx| {
- let needs_refresh = updates
- .iter()
- .any(|u| matches!(u, SessionListUpdate::Refresh));
-
- if needs_refresh {
- this.refresh_sessions(false, cx);
- } else {
- for update in updates {
- if let SessionListUpdate::SessionInfo { session_id, update } = update {
- this.apply_info_update(session_id, update, cx);
- }
- }
- }
- })
- .ok();
- }
- }));
- }
-
- pub(crate) fn refresh_full_history(&mut self, cx: &mut Context<Self>) {
- self.refresh_sessions(true, cx);
- }
-
- fn apply_info_update(
- &mut self,
- session_id: acp::SessionId,
- info_update: acp::SessionInfoUpdate,
- cx: &mut Context<Self>,
- ) {
- let Some(session) = self
- .sessions
- .iter_mut()
- .find(|s| s.session_id == session_id)
- else {
- return;
- };
-
- match info_update.title {
- acp::MaybeUndefined::Value(title) => {
- session.title = Some(title.into());
- }
- acp::MaybeUndefined::Null => {
- session.title = None;
- }
- acp::MaybeUndefined::Undefined => {}
- }
- match info_update.updated_at {
- acp::MaybeUndefined::Value(date_str) => {
- if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
- session.updated_at = Some(dt.with_timezone(&chrono::Utc));
- }
- }
- acp::MaybeUndefined::Null => {
- session.updated_at = None;
- }
- acp::MaybeUndefined::Undefined => {}
- }
- if let Some(meta) = info_update.meta {
- session.meta = Some(meta);
- }
-
- cx.notify();
- }
-
- fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context<Self>) {
- let session_list = self.session_list.clone();
-
- self._refresh_task = cx.spawn(async move |this, cx| {
- let mut cursor: Option<String> = None;
- let mut is_first_page = true;
-
- loop {
- let request = AgentSessionListRequest {
- cursor: cursor.clone(),
- ..Default::default()
- };
- let task = cx.update(|cx| session_list.list_sessions(request, cx));
- let response = match task.await {
- Ok(response) => response,
- Err(error) => {
- log::error!("Failed to load session history: {error:#}");
- return;
- }
- };
-
- let acp_thread::AgentSessionListResponse {
- sessions: page_sessions,
- next_cursor,
- ..
- } = response;
-
- this.update(cx, |this, cx| {
- if is_first_page {
- this.sessions = page_sessions;
- } else {
- this.sessions.extend(page_sessions);
- }
- cx.notify();
- })
- .ok();
-
- is_first_page = false;
- if !load_all_pages {
- break;
- }
-
- match next_cursor {
- Some(next_cursor) => {
- if cursor.as_ref() == Some(&next_cursor) {
- log::warn!(
- "Session list pagination returned the same cursor; stopping to avoid a loop."
- );
- break;
- }
- cursor = Some(next_cursor);
- }
- None => break,
- }
- }
- });
- }
-
- pub(crate) fn is_empty(&self) -> bool {
- self.sessions.is_empty()
- }
-
- pub fn refresh(&mut self, _cx: &mut Context<Self>) {
- self.session_list.notify_refresh();
- }
-
- pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
- self.sessions
- .iter()
- .find(|entry| &entry.session_id == session_id)
- .cloned()
- }
-
- pub(crate) fn sessions(&self) -> &[AgentSessionInfo] {
- &self.sessions
- }
-
- pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
- self.sessions.iter().take(limit).cloned().collect()
- }
-
- pub fn supports_delete(&self) -> bool {
- self.session_list.supports_delete()
- }
-
- pub(crate) fn delete_session(
- &self,
- session_id: &acp::SessionId,
- cx: &mut App,
- ) -> Task<anyhow::Result<()>> {
- self.session_list.delete_session(session_id, cx)
- }
-
- pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task<anyhow::Result<()>> {
- self.session_list.delete_sessions(cx)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use acp_thread::AgentSessionListResponse;
- use gpui::TestAppContext;
- use std::{
- any::Any,
- sync::{Arc, Mutex},
- };
-
- fn init_test(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = settings::SettingsStore::test(cx);
- cx.set_global(settings_store);
- theme_settings::init(theme::LoadThemes::JustBase, cx);
- });
- }
-
- #[derive(Clone)]
- struct TestSessionList {
- sessions: Vec<AgentSessionInfo>,
- updates_tx: smol::channel::Sender<SessionListUpdate>,
- updates_rx: smol::channel::Receiver<SessionListUpdate>,
- }
-
- impl TestSessionList {
- fn new(sessions: Vec<AgentSessionInfo>) -> Self {
- let (tx, rx) = smol::channel::unbounded();
- Self {
- sessions,
- updates_tx: tx,
- updates_rx: rx,
- }
- }
-
- fn send_update(&self, update: SessionListUpdate) {
- self.updates_tx.try_send(update).ok();
- }
- }
-
- impl AgentSessionList for TestSessionList {
- fn list_sessions(
- &self,
- _request: AgentSessionListRequest,
- _cx: &mut App,
- ) -> Task<anyhow::Result<AgentSessionListResponse>> {
- Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
- }
-
- fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
- Some(self.updates_rx.clone())
- }
-
- fn notify_refresh(&self) {
- self.send_update(SessionListUpdate::Refresh);
- }
-
- fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
- self
- }
- }
-
- #[derive(Clone)]
- struct PaginatedTestSessionList {
- first_page_sessions: Vec<AgentSessionInfo>,
- second_page_sessions: Vec<AgentSessionInfo>,
- requested_cursors: Arc<Mutex<Vec<Option<String>>>>,
- async_responses: bool,
- updates_tx: smol::channel::Sender<SessionListUpdate>,
- updates_rx: smol::channel::Receiver<SessionListUpdate>,
- }
-
- impl PaginatedTestSessionList {
- fn new(
- first_page_sessions: Vec<AgentSessionInfo>,
- second_page_sessions: Vec<AgentSessionInfo>,
- ) -> Self {
- let (tx, rx) = smol::channel::unbounded();
- Self {
- first_page_sessions,
- second_page_sessions,
- requested_cursors: Arc::new(Mutex::new(Vec::new())),
- async_responses: false,
- updates_tx: tx,
- updates_rx: rx,
- }
- }
-
- fn with_async_responses(mut self) -> Self {
- self.async_responses = true;
- self
- }
-
- fn requested_cursors(&self) -> Vec<Option<String>> {
- self.requested_cursors.lock().unwrap().clone()
- }
-
- fn clear_requested_cursors(&self) {
- self.requested_cursors.lock().unwrap().clear()
- }
-
- fn send_update(&self, update: SessionListUpdate) {
- self.updates_tx.try_send(update).ok();
- }
- }
-
- impl AgentSessionList for PaginatedTestSessionList {
- fn list_sessions(
- &self,
- request: AgentSessionListRequest,
- cx: &mut App,
- ) -> Task<anyhow::Result<AgentSessionListResponse>> {
- let requested_cursors = self.requested_cursors.clone();
- let first_page_sessions = self.first_page_sessions.clone();
- let second_page_sessions = self.second_page_sessions.clone();
-
- let respond = move || {
- requested_cursors
- .lock()
- .unwrap()
- .push(request.cursor.clone());
-
- match request.cursor.as_deref() {
- None => AgentSessionListResponse {
- sessions: first_page_sessions,
- next_cursor: Some("page-2".to_string()),
- meta: None,
- },
- Some("page-2") => AgentSessionListResponse::new(second_page_sessions),
- _ => AgentSessionListResponse::new(Vec::new()),
- }
- };
-
- if self.async_responses {
- cx.foreground_executor().spawn(async move {
- smol::future::yield_now().await;
- Ok(respond())
- })
- } else {
- Task::ready(Ok(respond()))
- }
- }
-
- fn watch(&self, _cx: &mut App) -> Option<smol::channel::Receiver<SessionListUpdate>> {
- Some(self.updates_rx.clone())
- }
-
- fn notify_refresh(&self) {
- self.send_update(SessionListUpdate::Refresh);
- }
-
- fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
- self
- }
- }
-
- fn test_session(session_id: &str, title: &str) -> AgentSessionInfo {
- AgentSessionInfo {
- session_id: acp::SessionId::new(session_id),
- work_dirs: None,
- title: Some(title.to_string().into()),
- updated_at: None,
- created_at: None,
- meta: None,
- }
- }
-
- #[gpui::test]
- async fn test_refresh_only_loads_first_page_by_default(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_list = Rc::new(PaginatedTestSessionList::new(
- vec![test_session("session-1", "First")],
- vec![test_session("session-2", "Second")],
- ));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 1);
- assert_eq!(
- history.sessions[0].session_id,
- acp::SessionId::new("session-1")
- );
- });
- assert_eq!(session_list.requested_cursors(), vec![None]);
- }
-
- #[gpui::test]
- async fn test_enabling_full_pagination_loads_all_pages(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_list = Rc::new(PaginatedTestSessionList::new(
- vec![test_session("session-1", "First")],
- vec![test_session("session-2", "Second")],
- ));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
- session_list.clear_requested_cursors();
-
- history.update(cx, |history, cx| history.refresh_full_history(cx));
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 2);
- assert_eq!(
- history.sessions[0].session_id,
- acp::SessionId::new("session-1")
- );
- assert_eq!(
- history.sessions[1].session_id,
- acp::SessionId::new("session-2")
- );
- });
- assert_eq!(
- session_list.requested_cursors(),
- vec![None, Some("page-2".to_string())]
- );
- }
-
- #[gpui::test]
- async fn test_standard_refresh_replaces_with_first_page_after_full_history_refresh(
- cx: &mut TestAppContext,
- ) {
- init_test(cx);
-
- let session_list = Rc::new(PaginatedTestSessionList::new(
- vec![test_session("session-1", "First")],
- vec![test_session("session-2", "Second")],
- ));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- history.update(cx, |history, cx| history.refresh_full_history(cx));
- cx.run_until_parked();
- session_list.clear_requested_cursors();
-
- history.update(cx, |history, cx| {
- history.refresh(cx);
- });
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 1);
- assert_eq!(
- history.sessions[0].session_id,
- acp::SessionId::new("session-1")
- );
- });
- assert_eq!(session_list.requested_cursors(), vec![None]);
- }
-
- #[gpui::test]
- async fn test_re_entering_full_pagination_reloads_all_pages(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_list = Rc::new(PaginatedTestSessionList::new(
- vec![test_session("session-1", "First")],
- vec![test_session("session-2", "Second")],
- ));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- history.update(cx, |history, cx| history.refresh_full_history(cx));
- cx.run_until_parked();
- session_list.clear_requested_cursors();
-
- history.update(cx, |history, cx| history.refresh_full_history(cx));
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 2);
- });
- assert_eq!(
- session_list.requested_cursors(),
- vec![None, Some("page-2".to_string())]
- );
- }
-
- #[gpui::test]
- async fn test_partial_refresh_batch_drops_non_first_page_sessions(cx: &mut TestAppContext) {
- init_test(cx);
-
- let second_page_session_id = acp::SessionId::new("session-2");
- let session_list = Rc::new(PaginatedTestSessionList::new(
- vec![test_session("session-1", "First")],
- vec![test_session("session-2", "Second")],
- ));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- history.update(cx, |history, cx| history.refresh_full_history(cx));
- cx.run_until_parked();
-
- session_list.clear_requested_cursors();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: second_page_session_id.clone(),
- update: acp::SessionInfoUpdate::new().title("Updated Second"),
- });
- session_list.send_update(SessionListUpdate::Refresh);
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 1);
- assert_eq!(
- history.sessions[0].session_id,
- acp::SessionId::new("session-1")
- );
- assert!(
- history
- .sessions
- .iter()
- .all(|session| session.session_id != second_page_session_id)
- );
- });
- assert_eq!(session_list.requested_cursors(), vec![None]);
- }
-
- #[gpui::test]
- async fn test_full_pagination_works_with_async_page_fetches(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_list = Rc::new(
- PaginatedTestSessionList::new(
- vec![test_session("session-1", "First")],
- vec![test_session("session-2", "Second")],
- )
- .with_async_responses(),
- );
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
- session_list.clear_requested_cursors();
-
- history.update(cx, |history, cx| history.refresh_full_history(cx));
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 2);
- });
- assert_eq!(
- session_list.requested_cursors(),
- vec![None, Some("page-2".to_string())]
- );
- }
-
- #[gpui::test]
- async fn test_apply_info_update_title(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_id = acp::SessionId::new("test-session");
- let sessions = vec![AgentSessionInfo {
- session_id: session_id.clone(),
- work_dirs: None,
- title: Some("Original Title".into()),
- updated_at: None,
- created_at: None,
- meta: None,
- }];
- let session_list = Rc::new(TestSessionList::new(sessions));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: session_id.clone(),
- update: acp::SessionInfoUpdate::new().title("New Title"),
- });
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- let session = history.sessions.iter().find(|s| s.session_id == session_id);
- assert_eq!(
- session.unwrap().title.as_ref().map(|s| s.as_ref()),
- Some("New Title")
- );
- });
- }
-
- #[gpui::test]
- async fn test_apply_info_update_clears_title_with_null(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_id = acp::SessionId::new("test-session");
- let sessions = vec![AgentSessionInfo {
- session_id: session_id.clone(),
- work_dirs: None,
- title: Some("Original Title".into()),
- updated_at: None,
- created_at: None,
- meta: None,
- }];
- let session_list = Rc::new(TestSessionList::new(sessions));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: session_id.clone(),
- update: acp::SessionInfoUpdate::new().title(None::<String>),
- });
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- let session = history.sessions.iter().find(|s| s.session_id == session_id);
- assert_eq!(session.unwrap().title, None);
- });
- }
-
- #[gpui::test]
- async fn test_apply_info_update_ignores_undefined_fields(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_id = acp::SessionId::new("test-session");
- let sessions = vec![AgentSessionInfo {
- session_id: session_id.clone(),
- work_dirs: None,
- title: Some("Original Title".into()),
- updated_at: None,
- created_at: None,
- meta: None,
- }];
- let session_list = Rc::new(TestSessionList::new(sessions));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: session_id.clone(),
- update: acp::SessionInfoUpdate::new(),
- });
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- let session = history.sessions.iter().find(|s| s.session_id == session_id);
- assert_eq!(
- session.unwrap().title.as_ref().map(|s| s.as_ref()),
- Some("Original Title")
- );
- });
- }
-
- #[gpui::test]
- async fn test_multiple_info_updates_applied_in_order(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_id = acp::SessionId::new("test-session");
- let sessions = vec![AgentSessionInfo {
- session_id: session_id.clone(),
- work_dirs: None,
- title: None,
- updated_at: None,
- created_at: None,
- meta: None,
- }];
- let session_list = Rc::new(TestSessionList::new(sessions));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: session_id.clone(),
- update: acp::SessionInfoUpdate::new().title("First Title"),
- });
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: session_id.clone(),
- update: acp::SessionInfoUpdate::new().title("Second Title"),
- });
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- let session = history.sessions.iter().find(|s| s.session_id == session_id);
- assert_eq!(
- session.unwrap().title.as_ref().map(|s| s.as_ref()),
- Some("Second Title")
- );
- });
- }
-
- #[gpui::test]
- async fn test_refresh_supersedes_info_updates(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_id = acp::SessionId::new("test-session");
- let sessions = vec![AgentSessionInfo {
- session_id: session_id.clone(),
- work_dirs: None,
- title: Some("Server Title".into()),
- updated_at: None,
- created_at: None,
- meta: None,
- }];
- let session_list = Rc::new(TestSessionList::new(sessions));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: session_id.clone(),
- update: acp::SessionInfoUpdate::new().title("Local Update"),
- });
- session_list.send_update(SessionListUpdate::Refresh);
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- let session = history.sessions.iter().find(|s| s.session_id == session_id);
- assert_eq!(
- session.unwrap().title.as_ref().map(|s| s.as_ref()),
- Some("Server Title")
- );
- });
- }
-
- #[gpui::test]
- async fn test_info_update_for_unknown_session_is_ignored(cx: &mut TestAppContext) {
- init_test(cx);
-
- let session_id = acp::SessionId::new("known-session");
- let sessions = vec![AgentSessionInfo {
- session_id,
- work_dirs: None,
- title: Some("Original".into()),
- updated_at: None,
- created_at: None,
- meta: None,
- }];
- let session_list = Rc::new(TestSessionList::new(sessions));
-
- let history = cx.new(|cx| ThreadHistory::new(session_list.clone(), cx));
- cx.run_until_parked();
-
- session_list.send_update(SessionListUpdate::SessionInfo {
- session_id: acp::SessionId::new("unknown-session"),
- update: acp::SessionInfoUpdate::new().title("Should Be Ignored"),
- });
- cx.run_until_parked();
-
- history.update(cx, |history, _cx| {
- assert_eq!(history.sessions.len(), 1);
- assert_eq!(
- history.sessions[0].title.as_ref().map(|s| s.as_ref()),
- Some("Original")
- );
- });
- }
-}
@@ -1,751 +0,0 @@
-use crate::thread_history::ThreadHistory;
-use crate::{DEFAULT_THREAD_TITLE, RemoveHistory, RemoveSelectedThread};
-use acp_thread::AgentSessionInfo;
-use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
-use editor::{Editor, EditorEvent};
-use fuzzy::StringMatchCandidate;
-use gpui::{
- AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
- UniformListScrollHandle, Window, uniform_list,
-};
-use std::{fmt::Display, ops::Range};
-use text::Bias;
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
- HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar,
- prelude::*,
-};
-
-pub(crate) fn thread_title(entry: &AgentSessionInfo) -> SharedString {
- entry
- .title
- .clone()
- .and_then(|title| if title.is_empty() { None } else { Some(title) })
- .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
-}
-
-pub struct ThreadHistoryView {
- history: Entity<ThreadHistory>,
- scroll_handle: UniformListScrollHandle,
- selected_index: usize,
- hovered_index: Option<usize>,
- search_editor: Entity<Editor>,
- search_query: SharedString,
- visible_items: Vec<ListItemType>,
- local_timezone: UtcOffset,
- confirming_delete_history: bool,
- _visible_items_task: Task<()>,
- _subscriptions: Vec<gpui::Subscription>,
-}
-
-enum ListItemType {
- BucketSeparator(TimeBucket),
- Entry {
- entry: AgentSessionInfo,
- format: EntryTimeFormat,
- },
- SearchResult {
- entry: AgentSessionInfo,
- positions: Vec<usize>,
- },
-}
-
-impl ListItemType {
- fn history_entry(&self) -> Option<&AgentSessionInfo> {
- match self {
- ListItemType::Entry { entry, .. } => Some(entry),
- ListItemType::SearchResult { entry, .. } => Some(entry),
- _ => None,
- }
- }
-}
-
-pub enum ThreadHistoryViewEvent {
- Open(AgentSessionInfo),
-}
-
-impl EventEmitter<ThreadHistoryViewEvent> for ThreadHistoryView {}
-
-impl ThreadHistoryView {
- pub fn new(
- history: Entity<ThreadHistory>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let search_editor = cx.new(|cx| {
- let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Search all threads…", window, cx);
- editor
- });
-
- let search_editor_subscription =
- cx.subscribe(&search_editor, |this, search_editor, event, cx| {
- if let EditorEvent::BufferEdited = event {
- let query = search_editor.read(cx).text(cx);
- if this.search_query != query {
- this.search_query = query.into();
- this.update_visible_items(false, cx);
- }
- }
- });
-
- let history_subscription = cx.observe(&history, |this, _, cx| {
- this.update_visible_items(true, cx);
- });
-
- let scroll_handle = UniformListScrollHandle::default();
-
- let mut this = Self {
- history,
- scroll_handle,
- selected_index: 0,
- hovered_index: None,
- visible_items: Default::default(),
- search_editor,
- local_timezone: UtcOffset::from_whole_seconds(
- chrono::Local::now().offset().local_minus_utc(),
- )
- .unwrap(),
- search_query: SharedString::default(),
- confirming_delete_history: false,
- _subscriptions: vec![search_editor_subscription, history_subscription],
- _visible_items_task: Task::ready(()),
- };
- this.update_visible_items(false, cx);
- this
- }
-
- pub fn history(&self) -> &Entity<ThreadHistory> {
- &self.history
- }
-
- fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
- let entries = self.history.read(cx).sessions().to_vec();
- let new_list_items = if self.search_query.is_empty() {
- self.add_list_separators(entries, cx)
- } else {
- self.filter_search_results(entries, cx)
- };
- let selected_history_entry = if preserve_selected_item {
- self.selected_history_entry().cloned()
- } else {
- None
- };
-
- self._visible_items_task = cx.spawn(async move |this, cx| {
- let new_visible_items = new_list_items.await;
- this.update(cx, |this, cx| {
- let new_selected_index = if let Some(history_entry) = selected_history_entry {
- new_visible_items
- .iter()
- .position(|visible_entry| {
- visible_entry
- .history_entry()
- .is_some_and(|entry| entry.session_id == history_entry.session_id)
- })
- .unwrap_or(0)
- } else {
- 0
- };
-
- this.visible_items = new_visible_items;
- this.set_selected_index(new_selected_index, Bias::Right, cx);
- cx.notify();
- })
- .ok();
- });
- }
-
- fn add_list_separators(
- &self,
- entries: Vec<AgentSessionInfo>,
- cx: &App,
- ) -> Task<Vec<ListItemType>> {
- cx.background_spawn(async move {
- let mut items = Vec::with_capacity(entries.len() + 1);
- let mut bucket = None;
- let today = Local::now().naive_local().date();
-
- for entry in entries.into_iter() {
- let entry_bucket = entry
- .updated_at
- .map(|timestamp| {
- let entry_date = timestamp.with_timezone(&Local).naive_local().date();
- TimeBucket::from_dates(today, entry_date)
- })
- .unwrap_or(TimeBucket::All);
-
- if Some(entry_bucket) != bucket {
- bucket = Some(entry_bucket);
- items.push(ListItemType::BucketSeparator(entry_bucket));
- }
-
- items.push(ListItemType::Entry {
- entry,
- format: entry_bucket.into(),
- });
- }
- items
- })
- }
-
- fn filter_search_results(
- &self,
- entries: Vec<AgentSessionInfo>,
- cx: &App,
- ) -> Task<Vec<ListItemType>> {
- let query = self.search_query.clone();
- cx.background_spawn({
- let executor = cx.background_executor().clone();
- async move {
- let mut candidates = Vec::with_capacity(entries.len());
-
- for (idx, entry) in entries.iter().enumerate() {
- candidates.push(StringMatchCandidate::new(idx, &thread_title(entry)));
- }
-
- const MAX_MATCHES: usize = 100;
-
- let matches = fuzzy::match_strings(
- &candidates,
- &query,
- false,
- true,
- MAX_MATCHES,
- &Default::default(),
- executor,
- )
- .await;
-
- matches
- .into_iter()
- .map(|search_match| ListItemType::SearchResult {
- entry: entries[search_match.candidate_id].clone(),
- positions: search_match.positions,
- })
- .collect()
- }
- })
- }
-
- fn search_produced_no_matches(&self) -> bool {
- self.visible_items.is_empty() && !self.search_query.is_empty()
- }
-
- fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
- self.get_history_entry(self.selected_index)
- }
-
- fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
- self.visible_items.get(visible_items_ix)?.history_entry()
- }
-
- fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
- if self.visible_items.len() == 0 {
- self.selected_index = 0;
- return;
- }
- while matches!(
- self.visible_items.get(index),
- None | Some(ListItemType::BucketSeparator(..))
- ) {
- index = match bias {
- Bias::Left => {
- if index == 0 {
- self.visible_items.len() - 1
- } else {
- index - 1
- }
- }
- Bias::Right => {
- if index >= self.visible_items.len() - 1 {
- 0
- } else {
- index + 1
- }
- }
- };
- }
- self.selected_index = index;
- self.scroll_handle
- .scroll_to_item(index, ScrollStrategy::Top);
- cx.notify()
- }
-
- fn select_previous(
- &mut self,
- _: &menu::SelectPrevious,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if self.selected_index == 0 {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- } else {
- self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
- }
- }
-
- fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
- if self.selected_index == self.visible_items.len() - 1 {
- self.set_selected_index(0, Bias::Right, cx);
- } else {
- self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
- }
- }
-
- fn select_first(
- &mut self,
- _: &menu::SelectFirst,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.set_selected_index(0, Bias::Right, cx);
- }
-
- fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- }
-
- fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirm_entry(self.selected_index, cx);
- }
-
- fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
- let Some(entry) = self.get_history_entry(ix) else {
- return;
- };
- cx.emit(ThreadHistoryViewEvent::Open(entry.clone()));
- }
-
- fn remove_selected_thread(
- &mut self,
- _: &RemoveSelectedThread,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.remove_thread(self.selected_index, cx)
- }
-
- fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
- let Some(entry) = self.get_history_entry(visible_item_ix) else {
- return;
- };
- if !self.history.read(cx).supports_delete() {
- return;
- }
- let session_id = entry.session_id.clone();
- self.history.update(cx, |history, cx| {
- history
- .delete_session(&session_id, cx)
- .detach_and_log_err(cx);
- });
- }
-
- fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- if !self.history.read(cx).supports_delete() {
- return;
- }
- self.history.update(cx, |history, cx| {
- history.delete_sessions(cx).detach_and_log_err(cx);
- });
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirming_delete_history = true;
- cx.notify();
- }
-
- fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn render_list_items(
- &mut self,
- range: Range<usize>,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Vec<AnyElement> {
- self.visible_items
- .get(range.clone())
- .into_iter()
- .flatten()
- .enumerate()
- .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
- .collect()
- }
-
- fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
- match item {
- ListItemType::Entry { entry, format } => self
- .render_history_entry(entry, *format, ix, Vec::default(), cx)
- .into_any(),
- ListItemType::SearchResult { entry, positions } => self.render_history_entry(
- entry,
- EntryTimeFormat::DateAndTime,
- ix,
- positions.clone(),
- cx,
- ),
- ListItemType::BucketSeparator(bucket) => div()
- .px(DynamicSpacing::Base06.rems(cx))
- .pt_2()
- .pb_1()
- .child(
- Label::new(bucket.to_string())
- .size(LabelSize::XSmall)
- .color(Color::Muted),
- )
- .into_any_element(),
- }
- }
-
- fn render_history_entry(
- &self,
- entry: &AgentSessionInfo,
- format: EntryTimeFormat,
- ix: usize,
- highlight_positions: Vec<usize>,
- cx: &Context<Self>,
- ) -> AnyElement {
- let selected = ix == self.selected_index;
- let hovered = Some(ix) == self.hovered_index;
- let entry_time = entry.updated_at;
- let display_text = match (format, entry_time) {
- (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
- let now = Utc::now();
- let duration = now.signed_duration_since(entry_time);
- let days = duration.num_days();
-
- format!("{}d", days)
- }
- (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
- format.format_timestamp(entry_time.timestamp(), self.local_timezone)
- }
- (_, None) => "—".to_string(),
- };
-
- let title = thread_title(entry);
- let full_date = entry_time
- .map(|time| {
- EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
- })
- .unwrap_or_else(|| "Unknown".to_string());
-
- let supports_delete = self.history.read(cx).supports_delete();
-
- h_flex()
- .w_full()
- .pb_1()
- .child(
- ListItem::new(ix)
- .rounded()
- .toggle_state(selected)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(
- h_flex()
- .w_full()
- .gap_2()
- .justify_between()
- .child(
- HighlightedLabel::new(thread_title(entry), highlight_positions)
- .size(LabelSize::Small)
- .truncate(),
- )
- .child(
- Label::new(display_text)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- ),
- )
- .tooltip(move |_, cx| {
- Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
- })
- .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
- if *is_hovered {
- this.hovered_index = Some(ix);
- } else if this.hovered_index == Some(ix) {
- this.hovered_index = None;
- }
-
- cx.notify();
- }))
- .end_slot::<IconButton>(if hovered && supports_delete {
- Some(
- IconButton::new("delete", IconName::Trash)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(move |_window, cx| {
- Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
- })
- .on_click(cx.listener(move |this, _, _, cx| {
- this.remove_thread(ix, cx);
- cx.stop_propagation()
- })),
- )
- } else {
- None
- })
- .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
- )
- .into_any_element()
- }
-}
-
-impl Focusable for ThreadHistoryView {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.search_editor.focus_handle(cx)
- }
-}
-
-impl Render for ThreadHistoryView {
- fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let has_no_history = self.history.read(cx).is_empty();
- let supports_delete = self.history.read(cx).supports_delete();
-
- v_flex()
- .key_context("ThreadHistory")
- .size_full()
- .bg(cx.theme().colors().panel_background)
- .on_action(cx.listener(Self::select_previous))
- .on_action(cx.listener(Self::select_next))
- .on_action(cx.listener(Self::select_first))
- .on_action(cx.listener(Self::select_last))
- .on_action(cx.listener(Self::confirm))
- .on_action(cx.listener(Self::remove_selected_thread))
- .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
- this.remove_history(window, cx);
- }))
- .child(
- h_flex()
- .h(Tab::container_height(cx))
- .w_full()
- .py_1()
- .px_2()
- .gap_2()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(
- Icon::new(IconName::MagnifyingGlass)
- .color(Color::Muted)
- .size(IconSize::Small),
- )
- .child(self.search_editor.clone()),
- )
- .child({
- let view = v_flex()
- .id("list-container")
- .relative()
- .overflow_hidden()
- .flex_grow();
-
- if has_no_history {
- view.justify_center().items_center().child(
- Label::new("You don't have any past threads yet.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- } else if self.search_produced_no_matches() {
- view.justify_center()
- .items_center()
- .child(Label::new("No threads match your search.").size(LabelSize::Small))
- } else {
- view.child(
- uniform_list(
- "thread-history",
- self.visible_items.len(),
- cx.processor(|this, range: Range<usize>, window, cx| {
- this.render_list_items(range, window, cx)
- }),
- )
- .p_1()
- .pr_4()
- .track_scroll(&self.scroll_handle)
- .flex_grow(),
- )
- .vertical_scrollbar_for(&self.scroll_handle, window, cx)
- }
- })
- .when(!has_no_history && supports_delete, |this| {
- this.child(
- h_flex()
- .p_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .when(!self.confirming_delete_history, |this| {
- this.child(
- Button::new("delete_history", "Delete All History")
- .full_width()
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.prompt_delete_history(window, cx);
- })),
- )
- })
- .when(self.confirming_delete_history, |this| {
- this.w_full()
- .gap_2()
- .flex_wrap()
- .justify_between()
- .child(
- h_flex()
- .flex_wrap()
- .gap_1()
- .child(
- Label::new("Delete all threads?")
- .size(LabelSize::Small),
- )
- .child(
- Label::new("You won't be able to recover them later.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- .child(
- h_flex()
- .gap_1()
- .child(
- Button::new("cancel_delete", "Cancel")
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.cancel_delete_history(window, cx);
- })),
- )
- .child(
- Button::new("confirm_delete", "Delete")
- .style(ButtonStyle::Tinted(ui::TintColor::Error))
- .color(Color::Error)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|_, _, window, cx| {
- window.dispatch_action(
- Box::new(RemoveHistory),
- cx,
- );
- })),
- ),
- )
- }),
- )
- })
- }
-}
-
-#[derive(Clone, Copy)]
-pub enum EntryTimeFormat {
- DateAndTime,
- TimeOnly,
-}
-
-impl EntryTimeFormat {
- fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
- let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
-
- match self {
- EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
- timestamp,
- OffsetDateTime::now_utc(),
- timezone,
- time_format::TimestampFormat::EnhancedAbsolute,
- ),
- EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
- }
- }
-}
-
-impl From<TimeBucket> for EntryTimeFormat {
- fn from(bucket: TimeBucket) -> Self {
- match bucket {
- TimeBucket::Today => EntryTimeFormat::TimeOnly,
- TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
- TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::All => EntryTimeFormat::DateAndTime,
- }
- }
-}
-
-#[derive(PartialEq, Eq, Clone, Copy, Debug)]
-enum TimeBucket {
- Today,
- Yesterday,
- ThisWeek,
- PastWeek,
- All,
-}
-
-impl TimeBucket {
- fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
- if date == reference {
- return TimeBucket::Today;
- }
-
- if date == reference - TimeDelta::days(1) {
- return TimeBucket::Yesterday;
- }
-
- let week = date.iso_week();
-
- if reference.iso_week() == week {
- return TimeBucket::ThisWeek;
- }
-
- let last_week = (reference - TimeDelta::days(7)).iso_week();
-
- if week == last_week {
- return TimeBucket::PastWeek;
- }
-
- TimeBucket::All
- }
-}
-
-impl Display for TimeBucket {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- TimeBucket::Today => write!(f, "Today"),
- TimeBucket::Yesterday => write!(f, "Yesterday"),
- TimeBucket::ThisWeek => write!(f, "This Week"),
- TimeBucket::PastWeek => write!(f, "Past Week"),
- TimeBucket::All => write!(f, "All"),
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use chrono::NaiveDate;
-
- #[test]
- fn test_time_bucket_from_dates() {
- let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
-
- assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today);
-
- let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap();
- assert_eq!(
- TimeBucket::from_dates(today, yesterday),
- TimeBucket::Yesterday
- );
-
- let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
- assert_eq!(
- TimeBucket::from_dates(today, this_week),
- TimeBucket::ThisWeek
- );
-
- let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap();
- assert_eq!(
- TimeBucket::from_dates(today, past_week),
- TimeBucket::PastWeek
- );
-
- let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
- assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All);
- }
-}
@@ -11,6 +11,7 @@ use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent,
Render, SharedString, Task, WeakEntity, Window,
};
+use itertools::Itertools as _;
use notifications::status_toast::StatusToast;
use project::{AgentId, AgentRegistryStore, AgentServerStore};
use release_channel::ReleaseChannel;
@@ -138,6 +139,7 @@ impl ThreadImportModal {
icon_path,
}
})
+ .sorted_unstable_by_key(|entry| entry.display_name.to_lowercase())
.collect::<Vec<_>>();
Self {
@@ -501,9 +503,10 @@ fn find_threads_to_import(
}
}
- let mut session_list_tasks = Vec::new();
cx.spawn(async move |cx| {
let results = futures::future::join_all(wait_for_connection_tasks).await;
+
+ let mut page_tasks = Vec::new();
for (agent_id, remote_connection, result) in results {
let Some(state) = result.log_err() else {
continue;
@@ -511,28 +514,17 @@ fn find_threads_to_import(
let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else {
continue;
};
- let task = cx.update(|cx| {
- list.list_sessions(AgentSessionListRequest::default(), cx)
- .map({
- let remote_connection = remote_connection.clone();
- move |response| (agent_id, remote_connection, response)
- })
- });
- session_list_tasks.push(task);
+ page_tasks.push(cx.spawn({
+ let list = list.clone();
+ async move |cx| collect_all_sessions(agent_id, remote_connection, list, cx).await
+ }));
}
- let mut sessions_by_agent = Vec::new();
- let results = futures::future::join_all(session_list_tasks).await;
- for (agent_id, remote_connection, result) in results {
- let Some(response) = result.log_err() else {
- continue;
- };
- sessions_by_agent.push(SessionByAgent {
- agent_id,
- remote_connection,
- sessions: response.sessions,
- });
- }
+ let sessions_by_agent = futures::future::join_all(page_tasks)
+ .await
+ .into_iter()
+ .filter_map(|result| result.log_err())
+ .collect();
Ok(collect_importable_threads(
sessions_by_agent,
@@ -541,6 +533,34 @@ fn find_threads_to_import(
})
}
+async fn collect_all_sessions(
+ agent_id: AgentId,
+ remote_connection: Option<RemoteConnectionOptions>,
+ list: std::rc::Rc<dyn acp_thread::AgentSessionList>,
+ cx: &mut gpui::AsyncApp,
+) -> anyhow::Result<SessionByAgent> {
+ let mut sessions = Vec::new();
+ let mut cursor: Option<String> = None;
+ loop {
+ let request = AgentSessionListRequest {
+ cursor: cursor.clone(),
+ ..Default::default()
+ };
+ let task = cx.update(|cx| list.list_sessions(request, cx));
+ let response = task.await?;
+ sessions.extend(response.sessions);
+ match response.next_cursor {
+ Some(next) if Some(&next) != cursor.as_ref() => cursor = Some(next),
+ _ => break,
+ }
+ }
+ Ok(SessionByAgent {
+ agent_id,
+ remote_connection,
+ sessions,
+ })
+}
+
struct SessionByAgent {
agent_id: AgentId,
remote_connection: Option<RemoteConnectionOptions>,
@@ -191,7 +191,7 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task<anyhow::
return Ok(());
}
- let recent_workspaces = workspace_db.recent_workspaces_on_disk(fs.as_ref()).await?;
+ let recent_workspaces = workspace_db.recent_project_workspaces(fs.as_ref()).await?;
let mut local_path_lists = HashSet::<PathList>::default();
let mut remote_path_lists = HashMap::<PathList, RemoteConnectionOptions>::default();
@@ -1079,7 +1079,7 @@ impl ProjectPickerModal {
let db = WorkspaceDb::global(cx);
cx.spawn_in(window, async move |this, cx| {
let workspaces = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@@ -16,18 +16,6 @@ impl FeatureFlag for PanicFeatureFlag {
}
register_feature_flag!(PanicFeatureFlag);
-pub struct AgentV2FeatureFlag;
-
-impl FeatureFlag for AgentV2FeatureFlag {
- const NAME: &'static str = "agent-v2";
- type Value = PresenceFlag;
-
- fn enabled_for_staff() -> bool {
- true
- }
-}
-register_feature_flag!(AgentV2FeatureFlag);
-
/// A feature flag for granting access to beta ACP features.
///
/// We reuse this feature flag for new betas, so don't delete it if it is not currently in use.
@@ -1217,10 +1217,10 @@ impl ProjectPanel {
.when(!is_collab && is_root, |menu| {
menu.separator()
.action(
- "Add Project to Workspace…",
+ "Add Folders to Project…",
Box::new(workspace::AddFolderToProject),
)
- .action("Remove from Workspace", Box::new(RemoveFromProject))
+ .action("Remove from Project", Box::new(RemoveFromProject))
})
.when(is_dir && !is_root, |menu| {
menu.separator().action(
@@ -96,7 +96,7 @@ pub async fn get_recent_projects(
db: &WorkspaceDb,
) -> Vec<RecentProjectEntry> {
let workspaces = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default();
@@ -610,7 +610,7 @@ impl RecentProjects {
cx.spawn_in(window, async move |this, cx| {
let Some(fs) = fs else { return };
let workspaces = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@@ -780,7 +780,7 @@ impl RecentProjects {
let paths_to_add = paths.paths().to_vec();
picker
.delegate
- .add_project_to_workspace(paths_to_add, window, cx);
+ .add_paths_to_project(paths_to_add, window, cx);
}
}
}
@@ -1527,7 +1527,7 @@ impl PickerDelegate for RecentProjectsDelegate {
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::with_meta(
- "Add Project to this Workspace",
+ "Add Folders to this Project",
None,
"As a multi-root folder project",
cx,
@@ -1538,7 +1538,7 @@ impl PickerDelegate for RecentProjectsDelegate {
cx.listener(move |picker, _event, window, cx| {
cx.stop_propagation();
window.prevent_default();
- picker.delegate.add_project_to_workspace(
+ picker.delegate.add_paths_to_project(
paths_to_add.clone(),
window,
cx,
@@ -1983,7 +1983,7 @@ fn open_local_project(
}
impl RecentProjectsDelegate {
- fn add_project_to_workspace(
+ fn add_paths_to_project(
&mut self,
paths: Vec<PathBuf>,
window: &mut Window,
@@ -2040,7 +2040,7 @@ impl RecentProjectsDelegate {
db.delete_workspace_by_id(workspace_id).await.log_err();
let Some(fs) = fs else { return };
let workspaces = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default();
let workspaces =
@@ -70,7 +70,7 @@ impl SidebarRecentProjects {
cx.spawn_in(window, async move |this, cx| {
let Some(fs) = fs else { return };
let workspaces = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@@ -1,6 +1,5 @@
use gpui::{Action as _, App};
use itertools::Itertools as _;
-use release_channel::ReleaseChannel;
use settings::{
AudioInputDeviceName, AudioOutputDeviceName, LanguageSettingsContent, SemanticTokens,
SettingsContent,
@@ -106,8 +105,8 @@ fn developer_page() -> SettingsPage {
}
fn general_page(cx: &App) -> SettingsPage {
- fn general_settings_section(cx: &App) -> Vec<SettingsPageItem> {
- let mut items = vec![
+ fn general_settings_section(_cx: &App) -> Vec<SettingsPageItem> {
+ vec![
SettingsPageItem::SectionHeader("General Settings"),
SettingsPageItem::SettingItem(SettingItem {
files: PROJECT,
@@ -225,11 +224,7 @@ fn general_page(cx: &App) -> SettingsPage {
metadata: None,
files: USER,
}),
- ];
-
- use feature_flags::FeatureFlagAppExt;
- if cx.has_flag::<feature_flags::AgentV2FeatureFlag>() {
- items.push(SettingsPageItem::SettingItem(SettingItem {
+ SettingsPageItem::SettingItem(SettingItem {
title: "CLI Default Open Behavior",
description: "How `zed <path>` opens directories when no flag is specified.",
field: Box::new(SettingField {
@@ -249,10 +244,8 @@ fn general_page(cx: &App) -> SettingsPage {
..Default::default()
})),
files: USER,
- }));
- }
-
- items
+ }),
+ ]
}
fn security_section() -> [SettingsPageItem; 2] {
[
@@ -7350,7 +7343,7 @@ fn ai_page(cx: &App) -> SettingsPage {
]
}
- fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> {
+ fn agent_configuration_section(_cx: &App) -> Box<[SettingsPageItem]> {
let mut items = vec![
SettingsPageItem::SectionHeader("Agent Configuration"),
SettingsPageItem::SubPageLink(SubPageLink {
@@ -7364,30 +7357,28 @@ fn ai_page(cx: &App) -> SettingsPage {
}),
];
- if !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable)) {
- items.push(SettingsPageItem::SettingItem(SettingItem {
- title: "New Thread Location",
- description: "Whether to start a new thread in the current local project or in a new Git worktree.",
- field: Box::new(SettingField {
- json_path: Some("agent.new_thread_location"),
- pick: |settings_content| {
- settings_content
- .agent
- .as_ref()?
- .new_thread_location
- .as_ref()
- },
- write: |settings_content, value| {
- settings_content
- .agent
- .get_or_insert_default()
- .new_thread_location = value;
- },
- }),
- metadata: None,
- files: USER,
- }));
- }
+ items.push(SettingsPageItem::SettingItem(SettingItem {
+ title: "New Thread Location",
+ description: "Whether to start a new thread in the current local project or in a new Git worktree.",
+ field: Box::new(SettingField {
+ json_path: Some("agent.new_thread_location"),
+ pick: |settings_content| {
+ settings_content
+ .agent
+ .as_ref()?
+ .new_thread_location
+ .as_ref()
+ },
+ write: |settings_content, value| {
+ settings_content
+ .agent
+ .get_or_insert_default()
+ .new_thread_location = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }));
items.extend([
SettingsPageItem::SettingItem(SettingItem {
@@ -335,6 +335,26 @@ struct WorkspaceMenuWorktreeLabel {
secondary_name: Option<SharedString>,
}
+impl WorkspaceMenuWorktreeLabel {
+ fn render(&self, color: Color) -> impl IntoElement {
+ h_flex()
+ .min_w_0()
+ .gap_0p5()
+ .when_some(self.icon, |this, icon| {
+ this.child(Icon::new(icon).size(IconSize::XSmall).color(color))
+ })
+ .child(
+ Label::new(self.primary_name.clone())
+ .color(color)
+ .truncate(),
+ )
+ .when_some(self.secondary_name.clone(), |this, secondary_name| {
+ this.child(Label::new(":").color(color).alpha(0.5))
+ .child(Label::new(secondary_name).color(color).truncate())
+ })
+ }
+}
+
fn workspace_menu_worktree_labels(
workspace: &Entity<Workspace>,
cx: &App,
@@ -1950,68 +1970,57 @@ impl Sidebar {
.w_full()
.gap_2()
.justify_between()
- .child(h_flex().min_w_0().gap_2().children(
- workspace_label.iter().map(|label| {
- h_flex()
- .min_w_0()
- .gap_0p5()
- .when_some(label.icon, |this, icon| {
- this.child(
- Icon::new(icon)
- .size(IconSize::XSmall)
- .color(label_color),
- )
- })
- .child(
- Label::new(label.primary_name.clone())
- .color(label_color)
- .truncate(),
- )
- .when_some(
- label.secondary_name.clone(),
- |this, secondary_name| {
+ .child(h_flex().min_w_0().gap_1().children(
+ workspace_label.iter().enumerate().map(
+ |(label_ix, label)| {
+ h_flex()
+ .gap_1()
+ .when(label_ix > 0, |this| {
this.child(
- Label::new(":")
- .color(label_color)
- .alpha(0.5),
- )
- .child(
- Label::new(secondary_name)
- .color(label_color)
- .truncate(),
+ Label::new("•").alpha(0.25),
)
- },
- )
- .into_any_element()
- }),
+ })
+ .child(label.render(label_color))
+ .into_any_element()
+ },
+ ),
))
- .child(
- IconButton::new(
- ("close-workspace", workspace_index),
- IconName::Close,
+ .when(!is_active_workspace, |this| {
+ let close_multi_workspace =
+ close_multi_workspace.clone();
+ let close_weak_menu = close_weak_menu.clone();
+ let close_workspace = close_workspace.clone();
+
+ this.child(
+ IconButton::new(
+ ("close-workspace", workspace_index),
+ IconName::Close,
+ )
+ .icon_size(IconSize::Small)
+ .visible_on_hover(&row_group_name)
+ .tooltip(Tooltip::text("Close Workspace"))
+ .on_click(move |_, window, cx| {
+ cx.stop_propagation();
+ window.prevent_default();
+ close_multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace
+ .close_workspace(
+ &close_workspace,
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ })
+ .ok();
+ close_weak_menu
+ .update(cx, |_, cx| {
+ cx.emit(DismissEvent)
+ })
+ .ok();
+ }),
)
- .shape(ui::IconButtonShape::Square)
- .visible_on_hover(&row_group_name)
- .tooltip(Tooltip::text("Close Workspace"))
- .on_click(move |_, window, cx| {
- cx.stop_propagation();
- window.prevent_default();
- close_multi_workspace
- .update(cx, |multi_workspace, cx| {
- multi_workspace
- .close_workspace(
- &close_workspace,
- window,
- cx,
- )
- .detach_and_log_err(cx);
- })
- .ok();
- close_weak_menu
- .update(cx, |_, cx| cx.emit(DismissEvent))
- .ok();
- }),
- )
+ })
.into_any_element()
},
move |window, cx| {
@@ -50,7 +50,6 @@ node_runtime.workspace = true
parking_lot.workspace = true
postage.workspace = true
project.workspace = true
-release_channel.workspace = true
remote.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -44,7 +44,7 @@ impl HistoryManager {
let db = WorkspaceDb::global(cx);
cx.spawn(async move |cx| {
let recent_folders = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default()
.into_iter()
@@ -8,7 +8,6 @@ use gpui::{
};
pub use project::ProjectGroupKey;
use project::{DisableAiSettings, Project};
-use release_channel::ReleaseChannel;
use remote::RemoteConnectionOptions;
use settings::Settings;
pub use settings::SidebarSide;
@@ -397,8 +396,7 @@ impl MultiWorkspace {
}
pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
- !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable))
- && !DisableAiSettings::get_global(cx).disable_ai
+ !DisableAiSettings::get_global(cx).disable_ai
}
pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -1932,15 +1930,55 @@ impl MultiWorkspace {
cx: &mut Context<Self>,
) -> Task<Result<Entity<Workspace>>> {
if self.multi_workspace_enabled(cx) {
- self.find_or_create_local_workspace(
- PathList::new(&paths),
- None,
- &[],
- None,
- OpenMode::Activate,
- window,
- cx,
- )
+ let empty_workspace = if self
+ .active_workspace
+ .read(cx)
+ .project()
+ .read(cx)
+ .visible_worktrees(cx)
+ .next()
+ .is_none()
+ {
+ Some(self.active_workspace.clone())
+ } else {
+ None
+ };
+
+ cx.spawn_in(window, async move |this, cx| {
+ if let Some(empty_workspace) = empty_workspace.as_ref() {
+ let should_continue = empty_workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
+ })?
+ .await?;
+ if !should_continue {
+ return Ok(empty_workspace.clone());
+ }
+ }
+
+ let create_task = this.update_in(cx, |this, window, cx| {
+ this.find_or_create_local_workspace(
+ PathList::new(&paths),
+ None,
+ empty_workspace.as_slice(),
+ None,
+ OpenMode::Activate,
+ window,
+ cx,
+ )
+ })?;
+ let new_workspace = create_task.await?;
+
+ if let Some(empty_workspace) = empty_workspace {
+ this.update(cx, |this, cx| {
+ if this.is_workspace_retained(&empty_workspace) {
+ this.detach_workspace(&empty_workspace, cx);
+ }
+ })?;
+ }
+
+ Ok(new_workspace)
+ })
} else {
let workspace = self.workspace().clone();
cx.spawn_in(window, async move |_this, cx| {
@@ -1,9 +1,10 @@
use std::path::PathBuf;
use super::*;
+use crate::item::test::TestItem;
use client::proto;
use fs::{FakeFs, Fs};
-use gpui::TestAppContext;
+use gpui::{TestAppContext, VisualTestContext};
use project::DisableAiSettings;
use serde_json::json;
use settings::SettingsStore;
@@ -767,3 +768,138 @@ async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppCont
);
});
}
+
+#[gpui::test]
+async fn test_open_project_closes_empty_workspace_but_not_non_empty_ones(cx: &mut TestAppContext) {
+ init_test(cx);
+ let app_state = cx.update(AppState::test);
+ let fs = app_state.fs.as_fake();
+ fs.insert_tree(path!("/project_a"), json!({ "file_a.txt": "" }))
+ .await;
+ fs.insert_tree(path!("/project_b"), json!({ "file_b.txt": "" }))
+ .await;
+
+ // Start with an empty (no-worktrees) workspace.
+ let project = Project::test(app_state.fs.clone(), [], cx).await;
+ let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.run_until_parked();
+
+ window
+ .update(cx, |mw, _window, cx| mw.open_sidebar(cx))
+ .unwrap();
+ cx.run_until_parked();
+
+ let empty_workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ let cx = &mut VisualTestContext::from_window(window.into(), cx);
+
+ // Add a dirty untitled item to the empty workspace.
+ let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
+ empty_workspace.update_in(cx, |workspace, window, cx| {
+ workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
+ });
+
+ // Opening a project while the lone empty workspace has unsaved
+ // changes prompts the user.
+ let open_task = window
+ .update(cx, |mw, window, cx| {
+ mw.open_project(
+ vec![PathBuf::from(path!("/project_a"))],
+ OpenMode::Activate,
+ window,
+ cx,
+ )
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Cancelling keeps the empty workspace.
+ assert!(cx.has_pending_prompt(),);
+ cx.simulate_prompt_answer("Cancel");
+ cx.run_until_parked();
+ assert_eq!(open_task.await.unwrap(), empty_workspace);
+ window
+ .read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().count(), 1);
+ assert_eq!(mw.workspace(), &empty_workspace);
+ assert_eq!(mw.project_group_keys(), vec![]);
+ })
+ .unwrap();
+
+ // Discarding the unsaved changes closes the empty workspace
+ // and opens the new project in its place.
+ let open_task = window
+ .update(cx, |mw, window, cx| {
+ mw.open_project(
+ vec![PathBuf::from(path!("/project_a"))],
+ OpenMode::Activate,
+ window,
+ cx,
+ )
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ assert!(cx.has_pending_prompt(),);
+ cx.simulate_prompt_answer("Don't Save");
+ cx.run_until_parked();
+
+ let workspace_a = open_task.await.unwrap();
+ assert_ne!(workspace_a, empty_workspace);
+
+ window
+ .read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().count(), 1);
+ assert_eq!(mw.workspace(), &workspace_a);
+ assert_eq!(
+ mw.project_group_keys(),
+ vec![ProjectGroupKey::new(
+ None,
+ PathList::new(&[path!("/project_a")])
+ )]
+ );
+ })
+ .unwrap();
+ assert!(
+ empty_workspace.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
+ "the detached empty workspace should no longer be attached to the session",
+ );
+
+ let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
+ workspace_a.update_in(cx, |workspace, window, cx| {
+ workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
+ });
+
+ // Opening another project does not close the existing project or prompt.
+ let workspace_b = window
+ .update(cx, |mw, window, cx| {
+ mw.open_project(
+ vec![PathBuf::from(path!("/project_b"))],
+ OpenMode::Activate,
+ window,
+ cx,
+ )
+ })
+ .unwrap()
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ assert!(!cx.has_pending_prompt());
+ assert_ne!(workspace_b, workspace_a);
+ window
+ .read_with(cx, |mw, _cx| {
+ assert_eq!(mw.workspaces().count(), 2);
+ assert_eq!(mw.workspace(), &workspace_b);
+ assert_eq!(
+ mw.project_group_keys(),
+ vec![
+ ProjectGroupKey::new(None, PathList::new(&[path!("/project_b")])),
+ ProjectGroupKey::new(None, PathList::new(&[path!("/project_a")]))
+ ]
+ );
+ })
+ .unwrap();
+ assert!(workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),);
+}
@@ -65,6 +65,14 @@ fn parse_timestamp(text: &str) -> DateTime<Utc> {
.unwrap_or_else(|_| Utc::now())
}
+fn contains_wsl_path(paths: &PathList) -> bool {
+ cfg!(windows)
+ && paths
+ .paths()
+ .iter()
+ .any(|path| util::paths::WslPath::from_path(path).is_some())
+}
+
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
@@ -1645,26 +1653,30 @@ impl WorkspaceDb {
WorkspaceId,
PathList,
Option<RemoteConnectionId>,
+ Option<String>,
DateTime<Utc>,
)>,
> {
Ok(self
.recent_workspaces_query()?
.into_iter()
- .map(|(id, paths, order, remote_connection_id, timestamp)| {
- (
- id,
- PathList::deserialize(&SerializedPathList { paths, order }),
- remote_connection_id.map(RemoteConnectionId),
- parse_timestamp(×tamp),
- )
- })
+ .map(
+ |(id, paths, order, remote_connection_id, session_id, timestamp)| {
+ (
+ id,
+ PathList::deserialize(&SerializedPathList { paths, order }),
+ remote_connection_id.map(RemoteConnectionId),
+ session_id,
+ parse_timestamp(×tamp),
+ )
+ },
+ )
.collect())
}
query! {
- fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, String)>> {
- SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp
+ fn recent_workspaces_query() -> Result<Vec<(WorkspaceId, String, String, Option<u64>, Option<String>, String)>> {
+ SELECT workspace_id, paths, paths_order, remote_connection_id, session_id, timestamp
FROM workspaces
WHERE
paths IS NOT NULL OR
@@ -1826,9 +1838,7 @@ impl WorkspaceDb {
let mut any_dir = false;
for path in paths {
match fs.metadata(path).await.ok().flatten() {
- None => {
- return false;
- }
+ None => return false,
Some(meta) => {
if meta.is_dir {
any_dir = true;
@@ -1839,9 +1849,10 @@ impl WorkspaceDb {
any_dir
}
- // Returns the recent locations which are still valid on disk and deletes ones which no longer
- // exist.
- pub async fn recent_workspaces_on_disk(
+ // Returns the recent project workspaces suitable for showing in the recent-projects UI.
+ // Scratch workspaces (no paths) are filtered out - they aren't really "projects" and
+ // are restored separately by `last_session_workspace_locations`.
+ pub async fn recent_project_workspaces(
&self,
fs: &dyn Fs,
) -> Result<
@@ -1852,11 +1863,9 @@ impl WorkspaceDb {
DateTime<Utc>,
)>,
> {
- let mut result = Vec::new();
- let mut workspaces_to_delete = Vec::new();
let remote_connections = self.remote_connections()?;
- let now = Utc::now();
- for (id, paths, remote_connection_id, timestamp) in self.recent_workspaces()? {
+ let mut result = Vec::new();
+ for (id, paths, remote_connection_id, _session_id, timestamp) in self.recent_workspaces()? {
if let Some(remote_connection_id) = remote_connection_id {
if let Some(connection_options) = remote_connections.get(&remote_connection_id) {
result.push((
@@ -1865,7 +1874,44 @@ impl WorkspaceDb {
paths,
timestamp,
));
- } else {
+ }
+ continue;
+ }
+
+ if paths.paths().is_empty() || contains_wsl_path(&paths) {
+ continue;
+ }
+
+ if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+ result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
+ }
+ }
+ Ok(result)
+ }
+
+ // Deletes workspace rows that can no longer be restored from. Remote workspaces whose
+ // connection was removed, and (on Windows) workspaces pointing at WSL paths, are cleaned
+ // up immediately. Local workspaces with no valid paths on disk are kept for seven days
+ // after going stale. Workspaces belonging to the current session or the last session are
+ // always preserved so that an in-progress restore can rehydrate them.
+ pub async fn garbage_collect_workspaces(
+ &self,
+ fs: &dyn Fs,
+ current_session_id: &str,
+ last_session_id: Option<&str>,
+ ) -> Result<()> {
+ let remote_connections = self.remote_connections()?;
+ let now = Utc::now();
+ let mut workspaces_to_delete = Vec::new();
+ for (id, paths, remote_connection_id, session_id, timestamp) in self.recent_workspaces()? {
+ if let Some(session_id) = session_id.as_deref() {
+ if session_id == current_session_id || Some(session_id) == last_session_id {
+ continue;
+ }
+ }
+
+ if let Some(remote_connection_id) = remote_connection_id {
+ if !remote_connections.contains_key(&remote_connection_id) {
workspaces_to_delete.push(id);
}
continue;
@@ -1876,20 +1922,14 @@ impl WorkspaceDb {
// will wait for the WSL VM and file server to boot up. This can
// block for many seconds. Supported scenarios use remote
// workspaces.
- if cfg!(windows) {
- let has_wsl_path = paths
- .paths()
- .iter()
- .any(|path| util::paths::WslPath::from_path(path).is_some());
- if has_wsl_path {
- workspaces_to_delete.push(id);
- continue;
- }
+ if contains_wsl_path(&paths) {
+ workspaces_to_delete.push(id);
+ continue;
}
- if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
- result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
- } else if now - timestamp >= chrono::Duration::days(7) {
+ if !Self::all_paths_exist_with_a_directory(paths.paths(), fs).await
+ && now - timestamp >= chrono::Duration::days(7)
+ {
workspaces_to_delete.push(id);
}
}
@@ -1900,7 +1940,7 @@ impl WorkspaceDb {
.map(|id| self.delete_workspace_by_id(id)),
)
.await;
- Ok(result)
+ Ok(())
}
pub async fn last_workspace(
@@ -1914,7 +1954,7 @@ impl WorkspaceDb {
DateTime<Utc>,
)>,
> {
- Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
+ Ok(self.recent_project_workspaces(fs).await?.into_iter().next())
}
// Returns the locations of the workspaces that were still opened when the last
@@ -1943,23 +1983,16 @@ impl WorkspaceDb {
paths,
window_id,
});
- } else if paths.is_empty() {
- // Empty workspace with items (drafts, files) - include for restoration
+ continue;
+ }
+
+ if paths.is_empty() || Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
workspaces.push(SessionWorkspace {
workspace_id,
location: SerializedWorkspaceLocation::Local,
paths,
window_id,
});
- } else {
- if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
- workspaces.push(SessionWorkspace {
- workspace_id,
- location: SerializedWorkspaceLocation::Local,
- paths,
- window_id,
- });
- }
}
}
@@ -2173,6 +2206,15 @@ impl WorkspaceDb {
}
}
+ #[cfg(test)]
+ query! {
+ pub(crate) async fn set_timestamp_for_tests(workspace_id: WorkspaceId, timestamp: String) -> Result<()> {
+ UPDATE workspaces
+ SET timestamp = ?2
+ WHERE workspace_id = ?1
+ }
+ }
+
query! {
pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> {
UPDATE workspaces
@@ -3468,6 +3510,227 @@ mod tests {
);
}
+ fn pane_with_items(item_ids: &[ItemId]) -> SerializedPaneGroup {
+ SerializedPaneGroup::Pane(SerializedPane::new(
+ item_ids
+ .iter()
+ .map(|id| SerializedItem::new("Terminal", *id, true, false))
+ .collect(),
+ true,
+ 0,
+ ))
+ }
+
+ fn empty_pane_group() -> SerializedPaneGroup {
+ SerializedPaneGroup::Pane(SerializedPane::default())
+ }
+
+ fn workspace_with(
+ id: u64,
+ paths: &[&Path],
+ center_group: SerializedPaneGroup,
+ session_id: Option<&str>,
+ ) -> SerializedWorkspace {
+ SerializedWorkspace {
+ id: WorkspaceId(id as i64),
+ paths: PathList::new(paths),
+ location: SerializedWorkspaceLocation::Local,
+ center_group,
+ window_bounds: Default::default(),
+ display: Default::default(),
+ docks: Default::default(),
+ breakpoints: Default::default(),
+ centered_layout: false,
+ session_id: session_id.map(|s| s.to_owned()),
+ window_id: Some(id),
+ user_toolchains: Default::default(),
+ }
+ }
+
+ #[gpui::test]
+ async fn test_scratch_only_workspace_restores_from_last_session(cx: &mut gpui::TestAppContext) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db =
+ WorkspaceDb::open_test_db("test_scratch_only_workspace_restores_from_last_session")
+ .await;
+
+ db.save_workspace(workspace_with(1, &[], pane_with_items(&[100]), Some("s1")))
+ .await;
+
+ let sessions = db
+ .last_session_workspace_locations("s1", None, fs.as_ref())
+ .await
+ .unwrap();
+ assert_eq!(sessions.len(), 1);
+ assert_eq!(sessions[0].workspace_id, WorkspaceId(1));
+ assert!(sessions[0].paths.is_empty());
+
+ let recents = db.recent_project_workspaces(fs.as_ref()).await.unwrap();
+ assert!(
+ recents.iter().all(|(id, ..)| *id != WorkspaceId(1)),
+ "scratch-only workspace must not appear in the recent-projects UI"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_gc_preserves_scratch_inside_window(cx: &mut gpui::TestAppContext) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db = WorkspaceDb::open_test_db("test_gc_preserves_scratch_inside_window").await;
+
+ db.save_workspace(workspace_with(1, &[], empty_pane_group(), None))
+ .await;
+
+ db.garbage_collect_workspaces(fs.as_ref(), "current", None)
+ .await
+ .unwrap();
+ assert!(
+ db.workspace_for_id(WorkspaceId(1)).is_some(),
+ "fresh stale workspace must not be deleted before the 7-day window"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_gc_deletes_stale_outside_window(cx: &mut gpui::TestAppContext) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db = WorkspaceDb::open_test_db("test_gc_deletes_stale_outside_window").await;
+
+ db.save_workspace(workspace_with(1, &[], empty_pane_group(), None))
+ .await;
+ db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned())
+ .await
+ .unwrap();
+
+ db.garbage_collect_workspaces(fs.as_ref(), "current", None)
+ .await
+ .unwrap();
+ assert!(
+ db.workspace_for_id(WorkspaceId(1)).is_none(),
+ "stale empty workspace older than the retention window must be deleted"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_gc_preserves_directory_workspace_with_missing_path(
+ cx: &mut gpui::TestAppContext,
+ ) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db =
+ WorkspaceDb::open_test_db("test_gc_preserves_directory_workspace_with_missing_path")
+ .await;
+
+ let missing_dir = PathBuf::from("/missing-project-dir");
+ db.save_workspace(workspace_with(
+ 1,
+ &[missing_dir.as_path()],
+ empty_pane_group(),
+ None,
+ ))
+ .await;
+
+ db.garbage_collect_workspaces(fs.as_ref(), "current", None)
+ .await
+ .unwrap();
+ assert!(
+ db.workspace_for_id(WorkspaceId(1)).is_some(),
+ "a stale workspace within the retention window must be kept"
+ );
+
+ db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned())
+ .await
+ .unwrap();
+ db.garbage_collect_workspaces(fs.as_ref(), "current", None)
+ .await
+ .unwrap();
+ assert!(
+ db.workspace_for_id(WorkspaceId(1)).is_none(),
+ "a stale workspace past the retention window must be deleted"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_gc_preserves_current_and_last_sessions(cx: &mut gpui::TestAppContext) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db = WorkspaceDb::open_test_db("test_gc_preserves_current_and_last_sessions").await;
+
+ db.save_workspace(workspace_with(1, &[], empty_pane_group(), Some("current")))
+ .await;
+ db.save_workspace(workspace_with(2, &[], empty_pane_group(), Some("last")))
+ .await;
+ db.save_workspace(workspace_with(3, &[], empty_pane_group(), Some("stale")))
+ .await;
+
+ for id in [1, 2, 3] {
+ db.set_timestamp_for_tests(WorkspaceId(id), "2000-01-01 00:00:00".to_owned())
+ .await
+ .unwrap();
+ }
+
+ db.garbage_collect_workspaces(fs.as_ref(), "current", Some("last"))
+ .await
+ .unwrap();
+
+ assert!(
+ db.workspace_for_id(WorkspaceId(1)).is_some(),
+ "GC must not delete workspaces belonging to the current session"
+ );
+ assert!(
+ db.workspace_for_id(WorkspaceId(2)).is_some(),
+ "GC must not delete workspaces belonging to the last session"
+ );
+ assert!(
+ db.workspace_for_id(WorkspaceId(3)).is_none(),
+ "GC should still delete stale workspaces from other sessions"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_gc_deletes_empty_workspace_with_items(cx: &mut gpui::TestAppContext) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db = WorkspaceDb::open_test_db("test_gc_deletes_empty_workspace_with_items").await;
+
+ db.save_workspace(workspace_with(1, &[], pane_with_items(&[100]), None))
+ .await;
+ db.set_timestamp_for_tests(WorkspaceId(1), "2000-01-01 00:00:00".to_owned())
+ .await
+ .unwrap();
+
+ db.garbage_collect_workspaces(fs.as_ref(), "current", None)
+ .await
+ .unwrap();
+ assert!(
+ db.workspace_for_id(WorkspaceId(1)).is_none(),
+ "a stale empty-path workspace must be deleted regardless of its items"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_last_session_restores_workspace_with_missing_paths(
+ cx: &mut gpui::TestAppContext,
+ ) {
+ let fs = fs::FakeFs::new(cx.executor());
+ let db =
+ WorkspaceDb::open_test_db("test_last_session_restores_workspace_with_missing_paths")
+ .await;
+
+ let missing = PathBuf::from("/gone/file.rs");
+ db.save_workspace(workspace_with(
+ 1,
+ &[missing.as_path()],
+ empty_pane_group(),
+ Some("s"),
+ ))
+ .await;
+
+ let sessions = db
+ .last_session_workspace_locations("s", None, fs.as_ref())
+ .await
+ .unwrap();
+ assert!(
+ sessions.is_empty(),
+ "workspaces whose paths no longer exist on disk must not restore"
+ );
+ }
+
#[gpui::test]
async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
let fs = fs::FakeFs::new(cx.executor());
@@ -271,7 +271,7 @@ impl WelcomePage {
cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
let Some(fs) = fs else { return };
let workspaces = db
- .recent_workspaces_on_disk(fs.as_ref())
+ .recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
@@ -2090,6 +2090,15 @@ impl Workspace {
});
})
.log_err();
+
+ if open_mode == OpenMode::NewWindow {
+ window
+ .update(cx, |_, window, _cx| {
+ window.activate_window();
+ })
+ .log_err();
+ }
+
Ok(OpenResult {
window,
workspace,
@@ -21,7 +21,9 @@ use fs::{Fs, RealFs};
use futures::{StreamExt, channel::oneshot, future};
use git::GitHostingProviderRegistry;
use git_ui::clone::clone_and_open;
-use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
+use gpui::{
+ App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, Task, UpdateGlobal as _,
+};
use gpui_platform;
use gpui_tokio::Tokio;
@@ -850,26 +852,47 @@ fn main() {
})
}
- match open_rx
+ let (current_session_id, last_session_id) = {
+ let session = app_state.session.read(cx);
+ (
+ session.id().to_owned(),
+ session.last_session_id().map(|id| id.to_owned()),
+ )
+ };
+
+ let restore_task = match open_rx
.try_recv()
.ok()
.and_then(|request| OpenRequest::parse(request, cx).log_err())
{
Some(request) => {
handle_open_request(request, app_state.clone(), cx);
+ Task::ready(())
}
- None => {
- cx.spawn({
- let app_state = app_state.clone();
- async move |cx| {
- if let Err(e) = restore_or_create_workspace(app_state, cx).await {
- fail_to_open_window_async(e, cx)
- }
+ None => cx.spawn({
+ let app_state = app_state.clone();
+ async move |cx| {
+ if let Err(e) = restore_or_create_workspace(app_state, cx).await {
+ fail_to_open_window_async(e, cx)
}
- })
- .detach();
+ }
+ }),
+ };
+
+ cx.spawn({
+ let db = workspace::WorkspaceDb::global(cx);
+ let fs = app_state.fs.clone();
+ async move |_cx| {
+ restore_task.await;
+ db.garbage_collect_workspaces(
+ fs.as_ref(),
+ ¤t_session_id,
+ last_session_id.as_deref(),
+ )
+ .await
}
- }
+ })
+ .detach_and_log_err(cx);
let app_state = app_state.clone();
@@ -3012,6 +3012,10 @@ mod tests {
let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
};
+ let workspace_database_id = |window: WindowHandle<MultiWorkspace>,
+ cx: &mut TestAppContext| {
+ cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).database_id())
+ };
let editor = window
.read_with(cx, |multi_workspace, cx| {
@@ -3026,6 +3030,11 @@ mod tests {
.unwrap();
assert!(!window_is_edited(window, cx));
+ let initial_database_id = workspace_database_id(window, cx);
+ assert!(
+ initial_database_id.is_some(),
+ "a restored workspace must have a stable database id"
+ );
// Editing a buffer marks the window as edited.
window
@@ -3071,6 +3080,11 @@ mod tests {
.unwrap()
});
assert!(window_is_edited(window, cx));
+ assert_eq!(
+ workspace_database_id(window, cx),
+ initial_database_id,
+ "the workspace must keep the same database id across a close/reopen cycle"
+ );
window
.update(cx, |multi_workspace, _, cx| {
@@ -12,7 +12,6 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use futures::channel::{mpsc, oneshot};
use futures::future;
-use feature_flags::FeatureFlagAppExt as _;
use futures::{FutureExt, StreamExt};
use git_ui::{file_diff_view::FileDiffView, multi_diff_view::MultiDiffView};
use gpui::{App, AsyncApp, Global, WindowHandle};
@@ -558,11 +557,6 @@ async fn resolve_open_behavior(
requests: &mut mpsc::UnboundedReceiver<CliRequest>,
cx: &mut AsyncApp,
) -> Option<settings::CliDefaultOpenBehavior> {
- let cli_prompt_enabled = cx.update(|cx| cx.has_flag::<feature_flags::AgentV2FeatureFlag>());
- if !cli_prompt_enabled {
- return Some(settings::CliDefaultOpenBehavior::NewWindow);
- }
-
let has_existing_windows = cx.update(|cx| {
cx.windows()
.iter()
@@ -71,12 +71,7 @@ In long conversations, use the scroll arrow buttons at the bottom of the panel t
When focus is in the message editor, you can also use {#kb agent::ScrollOutputPageUp}, {#kb agent::ScrollOutputPageDown}, {#kb agent::ScrollOutputToTop}, {#kb agent::ScrollOutputToBottom}, {#kb agent::ScrollOutputLineUp}, and {#kb agent::ScrollOutputLineDown} to navigate the thread, or {#kb agent::ScrollOutputToPreviousMessage} and {#kb agent::ScrollOutputToNextMessage} to jump between your prompts.
-### Navigating History {#navigating-history}
-
-To quickly navigate through recently updated threads, use the {#kb agent::ToggleNavigationMenu} binding when focused on the panel's editor, or click the menu icon button at the top right of the panel.
-Doing that will open a dropdown that shows you your six most recently updated threads.
-
-To view all historical conversations, reach for the `View All` option from within the same menu or via the {#kb agent::OpenHistory} binding.
+### Thread titles {#thread-titles}
Thread titles are auto-generated based on the content of the conversation.
But you can also edit them manually by clicking the title and typing, or regenerate them by clicking the "Regenerate Thread Title" button in the ellipsis menu in the top right of the panel.