sidebar: Experimental Terminal Mode (#56063)
Experiment with allowing users to manage terminal sessions along with threads in the sidebar. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
This commit is contained in:
1 parent
cb67f90134
commit
dfeeb3985e
13 files changed
+1626
-221
No files matched your search
Generated
+1
@@ -16329,6 +16329,7 @@ dependencies = [
|
||||
"git",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"language_model",
|
||||
"log",
|
||||
|
||||
@@ -1247,6 +1247,12 @@
|
||||
"ctrl->": "agent::AddSelectionToThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel && Terminal",
|
||||
"bindings": {
|
||||
"ctrl-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "ZedPredictModal",
|
||||
"bindings": {
|
||||
|
||||
@@ -1316,6 +1316,13 @@
|
||||
"cmd->": "agent::AddSelectionToThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "RatePredictionsModal",
|
||||
"use_key_equivalents": true,
|
||||
|
||||
@@ -1262,6 +1262,13 @@
|
||||
"ctrl-shift-.": "agent::AddSelectionToThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "Terminal && selection",
|
||||
"bindings": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -61,7 +61,9 @@ use workspace::Workspace;
|
||||
|
||||
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
|
||||
pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore};
|
||||
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads};
|
||||
pub use crate::agent_panel::{
|
||||
AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId,
|
||||
};
|
||||
use crate::agent_registry_ui::AgentRegistryPage;
|
||||
pub use crate::inline_assistant::InlineAssistant;
|
||||
pub use crate::thread_metadata_store::ThreadId;
|
||||
|
||||
@@ -2708,10 +2708,10 @@ impl ConversationView {
|
||||
&panel,
|
||||
window,
|
||||
move |this, _, event: &AgentPanelEvent, window, cx| match event {
|
||||
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => {
|
||||
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => {
|
||||
dismiss_if_visible(this, window, cx);
|
||||
}
|
||||
AgentPanelEvent::RetainedThreadChanged
|
||||
AgentPanelEvent::EntryChanged
|
||||
| AgentPanelEvent::ThreadInteracted { .. } => {}
|
||||
},
|
||||
));
|
||||
|
||||
@@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag {
|
||||
}
|
||||
register_feature_flag!(AgentSharingFeatureFlag);
|
||||
|
||||
pub struct AgentPanelTerminalFeatureFlag;
|
||||
|
||||
impl FeatureFlag for AgentPanelTerminalFeatureFlag {
|
||||
const NAME: &'static str = "agent-panel-terminal";
|
||||
type Value = PresenceFlag;
|
||||
|
||||
fn enabled_for_staff() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
register_feature_flag!(AgentPanelTerminalFeatureFlag);
|
||||
|
||||
pub struct DiffReviewFeatureFlag;
|
||||
|
||||
impl FeatureFlag for DiffReviewFeatureFlag {
|
||||
|
||||
@@ -2264,14 +2264,7 @@ impl Project {
|
||||
|
||||
#[inline]
|
||||
pub fn supports_terminal(&self, _cx: &App) -> bool {
|
||||
if self.is_local() {
|
||||
return true;
|
||||
}
|
||||
if self.is_via_remote_server() {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
self.is_local() || self.is_via_remote_server()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
@@ -29,6 +29,7 @@ feature_flags.workspace = true
|
||||
fs.workspace = true
|
||||
git.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
platform_title_bar.workspace = true
|
||||
|
||||
+637
-164
File diff suppressed because it is too large.
Load diff
@@ -33,13 +33,17 @@ fn init_test(cx: &mut TestAppContext) {
|
||||
});
|
||||
}
|
||||
|
||||
fn enable_agent_panel_terminal(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
|
||||
let active = sidebar.active_entry.as_ref();
|
||||
let matches = active.is_some_and(|entry| {
|
||||
// Match by session_id directly on active_entry.
|
||||
entry.session_id.as_ref() == Some(session_id)
|
||||
// Or match by finding the thread in sidebar entries.
|
||||
matches!(entry, ActiveEntry::Thread { session_id: Some(active_session_id), .. } if active_session_id == session_id)
|
||||
|| sidebar.contents.entries.iter().any(|list_entry| {
|
||||
matches!(list_entry, ListEntry::Thread(t)
|
||||
if t.metadata.session_id.as_ref() == Some(session_id)
|
||||
@@ -67,7 +71,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
|
||||
});
|
||||
match thread_id {
|
||||
Some(tid) => {
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid)
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { thread_id, .. }) if *thread_id == tid)
|
||||
}
|
||||
// Thread not in sidebar entries — can't confirm it's active.
|
||||
None => false,
|
||||
@@ -77,7 +81,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
|
||||
#[track_caller]
|
||||
fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == workspace),
|
||||
"{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
|
||||
workspace.entity_id(),
|
||||
sidebar.active_entry,
|
||||
@@ -147,6 +151,12 @@ fn assert_remote_project_integration_sidebar_state(
|
||||
title
|
||||
);
|
||||
}
|
||||
ListEntry::Terminal(terminal) => {
|
||||
panic!(
|
||||
"unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`",
|
||||
terminal.title
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +527,10 @@ fn visible_entries_as_strings(
|
||||
format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
|
||||
}
|
||||
}
|
||||
ListEntry::Terminal(terminal) => {
|
||||
let title = &terminal.title;
|
||||
format!(" {title}{selected}")
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -1408,6 +1422,150 @@ fn setup_sidebar_with_agent_panel(
|
||||
(sidebar, panel)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAppContext) {
|
||||
let project = init_test_project_with_agent_panel("/my-project", cx).await;
|
||||
enable_agent_panel_terminal(cx);
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
let terminal_id = panel
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
panel.insert_test_terminal("Dev Server", true, window, cx)
|
||||
})
|
||||
.expect("test terminal should be inserted");
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [my-project]", " Dev Server"]
|
||||
);
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) if *active_terminal_id == terminal_id),
|
||||
"expected active terminal entry, got {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
assert!(
|
||||
sidebar.contents.entries.iter().any(|entry| {
|
||||
matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server")
|
||||
}),
|
||||
"expected the inserted terminal to appear in sidebar contents",
|
||||
);
|
||||
});
|
||||
|
||||
type_in_search(&sidebar, "server", cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [my-project]", " Dev Server <== selected"]
|
||||
);
|
||||
|
||||
type_in_search(&sidebar, "missing", cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) {
|
||||
let project = init_test_project_with_agent_panel("/my-project", cx).await;
|
||||
enable_agent_panel_terminal(cx);
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
let build_terminal_id = panel
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
panel.insert_test_terminal("Build", true, window, cx)
|
||||
})
|
||||
.expect("build test terminal should be inserted");
|
||||
let server_terminal_id = panel
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
panel.insert_test_terminal("Server", true, window, cx)
|
||||
})
|
||||
.expect("server test terminal should be inserted");
|
||||
cx.run_until_parked();
|
||||
|
||||
panel.read_with(cx, |panel, _cx| {
|
||||
assert_eq!(panel.active_terminal_id(), Some(server_terminal_id));
|
||||
});
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.emit_test_terminal_bell(build_terminal_id, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, cx| {
|
||||
assert!(sidebar.has_notifications(cx));
|
||||
assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id));
|
||||
assert!(sidebar.contents.entries.iter().any(|entry| {
|
||||
matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification)
|
||||
}));
|
||||
});
|
||||
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.activate_terminal(build_terminal_id, true, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, cx| {
|
||||
assert!(!sidebar.has_notifications(cx));
|
||||
assert!(
|
||||
!sidebar
|
||||
.contents
|
||||
.notified_terminals
|
||||
.contains(&build_terminal_id)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) {
|
||||
let project = init_test_project_with_agent_panel("/my-project", cx).await;
|
||||
enable_agent_panel_terminal(cx);
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| {
|
||||
multi_workspace.workspace().clone()
|
||||
});
|
||||
|
||||
let build_terminal_id = panel
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
panel.insert_test_terminal("Build", true, window, cx)
|
||||
})
|
||||
.expect("build test terminal should be inserted");
|
||||
let server_terminal_id = panel
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
panel.insert_test_terminal("Server", true, window, cx)
|
||||
})
|
||||
.expect("server test terminal should be inserted");
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.close_terminal(&workspace, server_terminal_id, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
panel.read_with(cx, |panel, _cx| {
|
||||
assert!(!panel.has_terminal(server_terminal_id));
|
||||
assert_eq!(panel.active_terminal_id(), Some(build_terminal_id));
|
||||
});
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == build_terminal_id),
|
||||
"expected remaining terminal to become active, got {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [my-project]", " Build"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
|
||||
let project = init_test_project_with_agent_panel("/my-project", cx).await;
|
||||
@@ -2740,7 +2898,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
||||
// because the panel has a thread with messages.
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
|
||||
"Panel has a thread with messages, so active_entry should be Thread, got {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
@@ -2776,7 +2934,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
||||
// false — the panel still has the old thread with messages.
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { .. })),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
|
||||
"After adding a folder the panel still has a thread with messages, \
|
||||
so active_entry should be Thread, got {:?}",
|
||||
sidebar.active_entry,
|
||||
@@ -3873,6 +4031,12 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
|
||||
title, worktree_name
|
||||
);
|
||||
}
|
||||
ListEntry::Terminal(terminal) => {
|
||||
panic!(
|
||||
"unexpected sidebar terminal while opening linked worktree thread: title=`{}`",
|
||||
terminal.title
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6241,7 +6405,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
||||
// active_entry should still be a draft on workspace_b (the active one).
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b),
|
||||
"expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
@@ -6278,7 +6442,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
||||
// sidebar row but active_entry tracks it.
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b),
|
||||
"expected draft on workspace_b after archiving active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
@@ -9773,7 +9937,7 @@ mod property_test {
|
||||
// 3. The entry must match the agent panel's current state.
|
||||
if panel.read(cx).active_thread_id(cx).is_some() {
|
||||
anyhow::ensure!(
|
||||
matches!(entry, ActiveEntry { .. }),
|
||||
matches!(entry, ActiveEntry::Thread { .. }),
|
||||
"panel shows a tracked draft but active_entry is {:?}",
|
||||
entry,
|
||||
);
|
||||
@@ -9783,7 +9947,7 @@ mod property_test {
|
||||
.map(|cv| cv.read(cx).parent_id())
|
||||
{
|
||||
anyhow::ensure!(
|
||||
matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id),
|
||||
matches!(entry, ActiveEntry::Thread { thread_id: tid, .. } if *tid == thread_id),
|
||||
"panel has thread {:?} but active_entry is {:?}",
|
||||
thread_id,
|
||||
entry,
|
||||
@@ -9795,8 +9959,11 @@ mod property_test {
|
||||
// a draft, which is represented by the + button's active state
|
||||
// rather than a sidebar row.
|
||||
// TODO: Make this check more complete
|
||||
let is_draft = panel.read(cx).active_thread_is_draft(cx)
|
||||
|| panel.read(cx).active_conversation_view().is_none();
|
||||
// Active terminals must still match a row, so don't treat the absence
|
||||
// of a conversation view as "draft" when a terminal is active.
|
||||
let is_draft = panel.read(cx).active_terminal_id().is_none()
|
||||
&& (panel.read(cx).active_thread_is_draft(cx)
|
||||
|| panel.read(cx).active_conversation_view().is_none());
|
||||
if is_draft {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -2005,7 +2005,7 @@ impl SearchableItem for TerminalView {
|
||||
/// For remote projects, local-only resolution (home dir fallback, shell expansion,
|
||||
/// local `is_dir` checks) is skipped -- returning `None` lets the remote shell
|
||||
/// open in the remote user's home directory by default.
|
||||
pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
|
||||
pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
|
||||
let is_remote = workspace.project().read(cx).is_remote();
|
||||
let directory = match &TerminalSettings::get_global(cx).working_directory {
|
||||
WorkingDirectory::CurrentFileDirectory => workspace
|
||||
|
||||
Reference in new issue
Block a user