diff --git a/Cargo.lock b/Cargo.lock index edd1676c5834fc299e4071148e3042f5f23af76b..4c692c4fc692b930ca063d721399c6e9a4b4636f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9ebace38d24163cf9c927311b3782200253b33c7..e7c1348928e5c879ecd9f36505423e86d4eb5def 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 94d7a0735c63809090c2c95cf26ed3585d8340dc..1f3e39d73e9d90f23aed6cd44647a185f8187f7b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 09a91523f3aa41941c80b21cb0bd226dd1f32afd..6d0a9461af803d2c613ccc716d439f6d6085b3fa 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 07dc04c3b6bff9fc7bd749c9685d01c766e48d91..8c27aef25ded57dfafb5e95fef25e7a124a7b371 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -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 diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index d903a6435d87690b7ffd5f06b89b57c680930a36..218347d5c57c2102136e9f8de097c414fb08f15e 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -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, - pub history: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -44,13 +43,6 @@ impl AgentConnectionEntry { } } - pub fn history(&self) -> Option<&Entity> { - 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::() { Ok(load_error) => Err(load_error), Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 6b5595a834b896ca0d8ee415970d5c0242e29874..5a189edfef9b6a34252fbfdfa9d14e36fc1bdb57 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| panel.open_history(window, cx)); - } - }) .register_action(|workspace, _: &OpenSettings, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(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::(cx) { - workspace.focus_panel::(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::(cx) { workspace.focus_panel::(window, cx); @@ -666,14 +647,12 @@ impl From for BaseView { } enum OverlayView { - History { view: Entity }, Configuration, } enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), - History(&'a Entity), Configuration(Option<&'a Entity>), } @@ -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>, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, - agent_navigation_menu_handle: PopoverMenuHandle, - agent_navigation_menu: Option>, _extension_subscription: Option, _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>, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> 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, - ) -> Option> { - 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, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - 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) { - 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) { 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, - ) { - 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, ) { - 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, - view: Entity, - cx: &mut Context, - ) -> ContextMenu { - let entries = view - .read(cx) - .history() - .read(cx) - .sessions() - .iter() - .take(RECENTLY_UPDATED_MENU_LIMIT) - .cloned() - .collect::>(); - - 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, 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, - ) -> 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) -> 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::(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, + resume_session_id: acp::SessionId, + window: &mut Window, + cx: &mut Context, + ) { + 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.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 = 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 { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 437627105c6a1837555518c33447c6797f89f098..4786800644d6cabc8b33767d39bd751c016e54c9 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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, is_new_install: bool, cx: &mut App) { @@ -565,7 +527,6 @@ fn maybe_backfill_editor_layout(fs: Arc, 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::()]); - } - if agent_v2_enabled { filter.show_namespace("multi_workspace"); - } else { - filter.hide_namespace("multi_workspace"); } }); } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 47fd7b0295adbcd2ecea768c3bd9e321a5f551b9..308494409bd9bf4a2cf4346dcd56d06ad13f20be 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -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 { source: Arc, editor: WeakEntity, mention_set: Entity, - history: Option>, prompt_store: Option>, workspace: WeakEntity, } @@ -232,7 +231,6 @@ impl PromptCompletionProvider { source: T, editor: WeakEntity, mention_set: Entity, - history: Option>, prompt_store: Option>, workspace: WeakEntity, ) -> Self { @@ -241,7 +239,6 @@ impl PromptCompletionProvider { editor, mention_set, workspace, - history, prompt_store, } } @@ -918,16 +915,8 @@ impl PromptCompletionProvider { } 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::>(); + 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 PromptCompletionProvider { 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 { + 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, diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index b796f6dcfcf26d054ba79079f8dc8a885b911991..4b3d77b53b2ba3337451b1e75abc62cc217c54c7 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -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, pub(crate) threads: HashMap>, connection: Rc, - history: Option>, conversation: Entity, _connection_entry_subscription: Subscription, } @@ -701,7 +699,7 @@ impl ConversationView { } fn reset(&mut self, window: &mut Window, cx: &mut Context) { - 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, resumed_without_history: bool, initial_content: Option, - history: Option>, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -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> { - 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 = - 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, &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, - Entity, - &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, &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, cx: &mut TestAppContext, - ) -> ( - Entity, - Option>, - &mut VisualTestContext, - ) { + ) -> (Entity, &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, cx: &mut VisualTestContext) { @@ -4256,24 +4264,51 @@ pub(crate) mod tests { } } - #[derive(Clone)] - struct StubSessionList { - sessions: Vec, + /// 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, } - impl StubSessionList { - fn new(sessions: Vec) -> Self { - Self { sessions } + impl FlakyAgentServer { + pub(crate) fn new( + connection: StubAgentConnection, + ) -> (Self, Arc) { + 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, _cx: &mut App, - ) -> Task> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) + ) -> Task>> { + 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) -> Rc { @@ -4281,17 +4316,6 @@ pub(crate) mod tests { } } - #[derive(Clone)] - struct SessionHistoryConnection { - sessions: Vec, - } - - impl SessionHistoryConnection { - fn new(sessions: Vec) -> Self { - Self { sessions } - } - } - fn build_test_thread( connection: Rc, project: Entity, @@ -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, - project: Entity, - _work_dirs: PathList, - cx: &mut App, - ) -> Task>> { - 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> { - 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> { - Task::ready(Ok(())) - } - - fn prompt( - &self, - _id: acp_thread::UserMessageId, - _params: acp::PromptRequest, - _cx: &mut App, - ) -> Task> { - Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) - } - - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} - - fn into_any(self: Rc) -> Rc { - self - } - } - #[derive(Clone)] struct ResumeOnlyAgentConnection; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b6bda7738d5424e842d66c4a625fe64684ce5c14..bb7f52558de2980c0fe43f7312a89ce1baa1a8cb 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -326,14 +326,10 @@ pub struct ThreadView { pub add_context_menu_handle: PopoverMenuHandle, pub thinking_effort_menu_handle: PopoverMenuHandle, pub project: WeakEntity, - pub recent_history_entries: Vec, - pub hovered_recent_history_item: Option, 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>, - pub _history_subscription: Option, } impl Focusable for ThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { @@ -375,7 +371,6 @@ impl ThreadView { resumed_without_history: bool, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, initial_content: Option, mut subscriptions: Vec, @@ -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, - cx: &mut Context, - ) { - 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) -> Callout { Callout::new() .icon(IconName::Warning) diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 415cd1f3db19df29895d7dd984e7ac4fb4a7b47b..cca6d235539b895098b27bf5a60af56635ec7312 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -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, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, entries: Vec, session_capabilities: SharedSessionCapabilities, @@ -38,7 +36,6 @@ impl EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, 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> = 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(), diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index ce74b7f78cda0ea14a79593f83e5666795f80e5e..71aa7baf7816b3ab62e11b3468579d13f79cebbe 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -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| 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, thread_store: Entity, prompt_store: Option>, - history: Option>, initial_prompt: Option, window: &mut Window, codegen_ranges: &[Range], @@ -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, thread_store: Entity, prompt_store: Option>, - history: Option>, initial_prompt: Option, 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, thread_store: Entity, prompt_store: Option>, - history: Option>, 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, diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 5d168d410476b1a367042d886715c2d57d50477e..8a41dc60e88eb525a4dd6b75d5a15a1fd2db7787 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -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 { pub editor: Entity, mode: PromptEditorMode, mention_set: Entity, - history: Option>, prompt_store: Option>, workspace: WeakEntity, model_selector: Entity, @@ -335,7 +333,6 @@ impl PromptEditor { PromptEditorCompletionProviderDelegate, cx.weak_entity(), self.mention_set.clone(), - self.history.clone(), self.prompt_store.clone(), self.workspace.clone(), )))); @@ -1227,7 +1224,6 @@ impl PromptEditor { fs: Arc, thread_store: Entity, prompt_store: Option>, - history: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1274,7 +1270,6 @@ impl PromptEditor { let mut this: PromptEditor = PromptEditor { editor: prompt_editor.clone(), mention_set, - history, prompt_store, workspace, model_selector: cx.new(|cx| { @@ -1386,7 +1381,6 @@ impl PromptEditor { fs: Arc, thread_store: Entity, prompt_store: Option>, - history: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1428,7 +1422,6 @@ impl PromptEditor { let mut this = Self { editor: prompt_editor.clone(), mention_set, - history, prompt_store, workspace, model_selector: cx.new(|cx| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 3b93439b62305f63596abcaebe562e7b3f2a65f3..3bd40fd2537ff881b1779ecd2d32c4fb029885f2 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -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, project: WeakEntity, thread_store: Option>, - history: Option>, prompt_store: Option>, 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", diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 89c1ec431386e548dc9188b46fe2f88ffef77668..c4db6a088da105fc3f73322fccc57b30f3d6d733 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -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, thread_store: Entity, prompt_store: Option>, - history: Option>, initial_prompt: Option, 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, diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs deleted file mode 100644 index 7b7a3e60211896bf717fb3dfb2670d92b7409281..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/thread_history.rs +++ /dev/null @@ -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, - sessions: Vec, - _refresh_task: Task<()>, - _watch_task: Option>, -} - -impl ThreadHistory { - pub fn new(session_list: Rc, cx: &mut Context) -> 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, - cx: &mut Context, - ) { - 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) { - 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.refresh_sessions(true, cx); - } - - fn apply_info_update( - &mut self, - session_id: acp::SessionId, - info_update: acp::SessionInfoUpdate, - cx: &mut Context, - ) { - 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) { - let session_list = self.session_list.clone(); - - self._refresh_task = cx.spawn(async move |this, cx| { - let mut cursor: Option = 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.session_list.notify_refresh(); - } - - pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option { - 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 { - 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> { - self.session_list.delete_session(session_id, cx) - } - - pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { - 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, - updates_tx: smol::channel::Sender, - updates_rx: smol::channel::Receiver, - } - - impl TestSessionList { - fn new(sessions: Vec) -> 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> { - Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) - } - - fn watch(&self, _cx: &mut App) -> Option> { - Some(self.updates_rx.clone()) - } - - fn notify_refresh(&self) { - self.send_update(SessionListUpdate::Refresh); - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - #[derive(Clone)] - struct PaginatedTestSessionList { - first_page_sessions: Vec, - second_page_sessions: Vec, - requested_cursors: Arc>>>, - async_responses: bool, - updates_tx: smol::channel::Sender, - updates_rx: smol::channel::Receiver, - } - - impl PaginatedTestSessionList { - fn new( - first_page_sessions: Vec, - second_page_sessions: Vec, - ) -> 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> { - 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> { - 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> { - Some(self.updates_rx.clone()) - } - - fn notify_refresh(&self) { - self.send_update(SessionListUpdate::Refresh); - } - - fn into_any(self: Rc) -> Rc { - 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::), - }); - 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") - ); - }); - } -} diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs deleted file mode 100644 index 1cebd175be46eaabd420853a3997ae1fd6ce7a50..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/thread_history_view.rs +++ /dev/null @@ -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, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _visible_items_task: Task<()>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -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 for ThreadHistoryView {} - -impl ThreadHistoryView { - pub fn new( - history: Entity, - window: &mut Window, - cx: &mut Context, - ) -> 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 { - &self.history - } - - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - 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, - cx: &App, - ) -> Task> { - 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, - cx: &App, - ) -> Task> { - 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) { - 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, - ) { - 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) { - 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.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - 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.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - 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.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - 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) { - 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.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - 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) -> 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, - cx: &Context, - ) -> 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::(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) -> 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, 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 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); - } -} diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index a8bd95916a7111afe4a7a75f11ff78f3547b4398..6044746bdeb2b768a66d31fc4803439d14bbd5fc 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -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::>(); 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, + list: std::rc::Rc, + cx: &mut gpui::AsyncApp, +) -> anyhow::Result { + let mut sessions = Vec::new(); + let mut cursor: Option = 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, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 8a65a682ac806384cc9048964377ada9c9df2e41..be0dff83fc73174ff3720de4a9b5e9d6ce4cbabb 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -191,7 +191,7 @@ fn migrate_thread_remote_connections(cx: &mut App, migration_task: Task::default(); let mut remote_path_lists = HashMap::::default(); diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 333a2d8e68c734a3bc440544901ccfd6b65e7043..dbe916d85f299bb0013c338084bc2ebda6b307f4 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -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(); diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 1665e6ffb6c0685370beab589c1bb88f714d70d0..aae8137a0a6e9d5273f1fca88ec4c81bae7f7e9b 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -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. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3a5047c0d7a6d40968ca7b5d10f65e317dbc92a8..ccba435f168346ccca85694cc835c2b44eb4c717 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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( diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 045815800286e5d3787241939d57159aabcc5b77..47d615862656483bbd4efa53cf98660e6a27982d 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -96,7 +96,7 @@ pub async fn get_recent_projects( db: &WorkspaceDb, ) -> Vec { 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, 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 = diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index f197ed3cead41e1fb3786e5b5a99727b5ebcf6b9..9fc58ded11743fcf0295f8e6e89600df7a480b05 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -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(); diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 83b65488995f3810e404bf52d2f4b700dc6881f0..d892ea09fa4bc59a1716bc1f499045d54134fc4a 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -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 { - let mut items = vec![ + fn general_settings_section(_cx: &App) -> Vec { + 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::() { - items.push(SettingsPageItem::SettingItem(SettingItem { + SettingsPageItem::SettingItem(SettingItem { title: "CLI Default Open Behavior", description: "How `zed ` 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 { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index ad03949035a1e91956db43e4d5345e3a36c7ea16..fd6c42fef030b5b46246a401679beca5fc4c19e4 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -335,6 +335,26 @@ struct WorkspaceMenuWorktreeLabel { secondary_name: Option, } +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, 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| { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 2014e7ad6f61bad8d2939c0232af92755eccea7f..00cd8ebbed433322ce96b01714c2fa1244d483d7 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -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 diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs index 9b03a3252d32793e12495817c2d9801d610d3ce4..8e60939a9c25bed03056102c2e1779e3e6c48d6a 100644 --- a/crates/workspace/src/history_manager.rs +++ b/crates/workspace/src/history_manager.rs @@ -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() diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 0752dd2e3f3e6d9a01656ad72b6ba5adc67edcc6..c1802e797c936789f6e7997f135fee4f5a088f47 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -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) { @@ -1932,15 +1930,55 @@ impl MultiWorkspace { cx: &mut Context, ) -> Task>> { 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| { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 37ebf691492a75f1db9a21a7f50d00a443291914..3b715fe80ca2b86598a5e0baba2acc9c31f1200c 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -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()),); +} diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index d7b486d736f3fde6a0f543fed0e7e4c9fbcfd177..28ee879b56d88c8c9c69dacfd8068629a4673956 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -65,6 +65,14 @@ fn parse_timestamp(text: &str) -> DateTime { .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, + Option, DateTime, )>, > { 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, String)>> { - SELECT workspace_id, paths, paths_order, remote_connection_id, timestamp + fn recent_workspaces_query() -> Result, Option, 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, )>, > { - 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, )>, > { - 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()); diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index a13ec56b2e07a81667fe096a8780393d93bf6f48..de189d89c9a21940dc848a535a83fff9aa33110f 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -271,7 +271,7 @@ impl WelcomePage { cx.spawn_in(window, async move |this: WeakEntity, 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(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f87ddfd73be20858d70ff2c0a72be3746d0274e7..448862732f659340a59b041e89a24e5cc8944edb 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f0bc7d557d5199d4b9599d09eebb4962e5eb6bbb..15c426b3b0db0707e7ba928d98ee2a28e0af00e8 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -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(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7d74ca478ab0de25fb7f50e04f0e6733c195e911..b0d2799d23f67b57dcf637d4e102f803f8023fa2 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3012,6 +3012,10 @@ mod tests { let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited()) }; + let workspace_database_id = |window: WindowHandle, + 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| { diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index e0094cb6556302de17deef1c397de5470128c333..4006647b0b7aab2a6218d44ced084b42604e98c4 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -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, cx: &mut AsyncApp, ) -> Option { - let cli_prompt_enabled = cx.update(|cx| cx.has_flag::()); - if !cli_prompt_enabled { - return Some(settings::CliDefaultOpenBehavior::NewWindow); - } - let has_existing_windows = cx.update(|cx| { cx.windows() .iter() diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 89b0126c55a12b08d4f21a01fea38758c4d509b7..5ac27c907356893dd686a11481091378c1b92f80 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -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.