agent_ui: Restore last active agent panel entry (#57150)

Makes sure we can reload the last terminal, and also keeps track more
globally what your last agent type was so we can carry that over to new
workspaces

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
This commit is contained in:
Ben Brandt 2026-05-19 16:07:09 +02:00 committed by GitHub
parent 3a821765e5
commit 589dc95c87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 649 additions and 98 deletions

View file

@ -94,6 +94,7 @@ use workspace::{
const AGENT_PANEL_KEY: &str = "agent_panel";
const MIN_PANEL_WIDTH: Pixels = px(300.);
const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind";
const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal";
/// Maximum number of idle threads kept in the agent panel's retained list.
@ -145,6 +146,11 @@ struct LastUsedAgent {
agent: Agent,
}
#[derive(Serialize, Deserialize)]
struct LastCreatedEntryKind {
entry_kind: AgentPanelEntryKind,
}
/// Reads the most recently used agent across all workspaces. Used as a fallback
/// when opening a workspace that has no per-workspace agent preference yet.
fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option<Agent> {
@ -163,6 +169,22 @@ async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) {
}
}
fn read_global_last_created_entry_kind(kvp: &KeyValueStore) -> Option<AgentPanelEntryKind> {
kvp.read_kvp(LAST_CREATED_ENTRY_KIND_KEY)
.log_err()
.flatten()
.and_then(|json| serde_json::from_str::<LastCreatedEntryKind>(&json).log_err())
.map(|entry| entry.entry_kind)
}
async fn write_global_last_created_entry_kind(kvp: KeyValueStore, entry_kind: AgentPanelEntryKind) {
if let Some(json) = serde_json::to_string(&LastCreatedEntryKind { entry_kind }).log_err() {
kvp.write_kvp(LAST_CREATED_ENTRY_KIND_KEY.to_string(), json)
.await
.log_err();
}
}
fn read_serialized_panel(
workspace_id: workspace::WorkspaceId,
kvp: &KeyValueStore,
@ -211,6 +233,8 @@ struct SerializedAgentPanel {
#[serde(default)]
last_active_thread: Option<SerializedActiveThread>,
#[serde(default)]
last_active_terminal_id: Option<String>,
#[serde(default)]
new_draft_thread_id: Option<ThreadId>,
}
@ -844,6 +868,7 @@ pub struct AgentPanel {
draft_thread: Option<Entity<ConversationView>>,
retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
terminals: HashMap<TerminalId, AgentTerminal>,
pending_terminal_spawn: Option<TerminalId>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
_extension_subscription: Option<Subscription>,
@ -872,52 +897,58 @@ impl AgentPanel {
let selected_agent = self.selected_agent.clone();
let last_created_entry_kind = self.last_created_entry_kind;
let last_active_terminal_id = self
.active_terminal_id()
.map(|terminal_id| terminal_id.to_key_string());
let is_draft_active = self.active_thread_is_draft(cx);
let active_thread_id = self.active_thread_id(cx);
let active_thread_agent = self
.active_conversation_view()
.map(|cv| cv.read(cx).agent_key().clone())
.unwrap_or_else(|| self.selected_agent.clone());
let last_active_thread = self
.active_agent_thread(cx)
.map(|thread| {
let thread = thread.read(cx);
let last_active_thread = if last_active_terminal_id.is_some() {
None
} else {
let is_draft_active = self.active_thread_is_draft(cx);
let active_thread_id = self.active_thread_id(cx);
let active_thread_agent = self
.active_conversation_view()
.map(|cv| cv.read(cx).agent_key().clone())
.unwrap_or_else(|| self.selected_agent.clone());
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()),
thread_id: active_thread_id,
agent_type: active_thread_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()),
thread_id: active_thread_id,
agent_type: active_thread_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 title = thread.title();
let work_dirs = thread.work_dirs().cloned();
SerializedActiveThread {
session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()),
thread_id: active_thread_id,
agent_type: active_thread_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()),
thread_id: active_thread_id,
agent_type: active_thread_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 new_draft_thread_id = self
.draft_thread
@ -932,6 +963,7 @@ impl AgentPanel {
selected_agent: Some(selected_agent),
last_created_entry_kind,
last_active_thread,
last_active_terminal_id,
new_draft_thread_id,
},
kvp,
@ -957,7 +989,7 @@ impl AgentPanel {
.ok()
.flatten();
let (serialized_panel, global_last_used_agent) = cx
let (serialized_panel, global_last_used_agent, global_last_created_entry_kind) = cx
.background_spawn(async move {
match kvp {
Some(kvp) => {
@ -965,9 +997,10 @@ impl AgentPanel {
.and_then(|id| read_serialized_panel(id, &kvp))
.or_else(|| read_legacy_serialized_panel(&kvp));
let global_agent = read_global_last_used_agent(&kvp);
(panel, global_agent)
let global_entry_kind = read_global_last_created_entry_kind(&kvp);
(panel, global_agent, global_entry_kind)
}
None => (None, None),
None => (None, None, None),
}
})
.await;
@ -975,33 +1008,15 @@ impl AgentPanel {
let has_open_project = workspace
.read_with(cx, |workspace, cx| !workspace.root_paths(cx).is_empty())
.unwrap_or(false);
let thread_to_restore = if has_open_project {
let terminal_id_to_restore = if has_open_project {
serialized_panel
.as_ref()
.and_then(|panel| panel.last_active_thread.as_ref())
.and_then(|info| {
let lookup = cx.update(|_window, cx| {
let store = ThreadMetadataStore::global(cx);
let store = store.read(cx);
let primary = info.thread_id.and_then(|tid| store.entry(tid));
let fallback = info.session_id.as_ref().and_then(|sid| {
store.entry_by_session(&acp::SessionId::new(sid.clone()))
});
primary
.or(fallback)
.filter(|entry| !entry.archived)
.map(|entry| entry.thread_id)
});
match lookup {
Ok(Some(thread_id)) => Some((info, thread_id)),
Ok(None) => {
log::info!(
"last active thread is archived or missing, skipping restoration"
);
None
}
Err(err) => {
log::warn!("failed to look up last active thread metadata: {err}");
.and_then(|panel| panel.last_active_terminal_id.as_deref())
.and_then(|terminal_id| {
match TerminalId::from_key_string(terminal_id) {
Ok(terminal_id) => Some(terminal_id),
Err(error) => {
log::warn!("failed to parse last active terminal id: {error}");
None
}
}
@ -1009,6 +1024,88 @@ impl AgentPanel {
} else {
None
};
let terminal_to_restore = if let Some(terminal_id) = terminal_id_to_restore {
match cx.update(|_window, cx| {
TerminalThreadMetadataStore::try_global(cx).map(|store| {
let reload_task = store.read(cx).reload_task();
(store, reload_task)
})
}) {
Ok(Some((store, reload_task))) => {
reload_task.await;
match store
.read_with(cx, |store, _cx| store.entry(terminal_id).cloned())
{
Some(metadata) => Some(metadata),
None => {
log::info!(
"last active terminal is missing, skipping restoration"
);
None
}
}
}
Ok(None) => {
log::warn!("failed to restore active terminal: metadata store missing");
None
}
Err(err) => {
log::warn!("failed to access terminal metadata store: {err}");
None
}
}
} else {
None
};
let thread_to_restore = if has_open_project && terminal_to_restore.is_none() {
if let Some(info) = serialized_panel
.as_ref()
.and_then(|panel| panel.last_active_thread.as_ref())
{
match cx.update(|_window, cx| {
ThreadMetadataStore::try_global(cx).map(|store| {
let reload_task = store.read(cx).reload_task();
(store, reload_task)
})
}) {
Ok(Some((store, reload_task))) => {
reload_task.await;
let thread_id = store.read_with(cx, |store, _cx| {
let primary = info.thread_id.and_then(|tid| store.entry(tid));
let fallback = info.session_id.as_ref().and_then(|sid| {
store.entry_by_session(&acp::SessionId::new(sid.clone()))
});
primary
.or(fallback)
.filter(|entry| !entry.archived)
.map(|entry| entry.thread_id)
});
match thread_id {
Some(thread_id) => Some((info, thread_id)),
None => {
log::info!(
"last active thread is archived or missing, skipping restoration"
);
None
}
}
}
Ok(None) => {
log::warn!("failed to restore active thread: metadata store missing");
None
}
Err(err) => {
log::warn!("failed to access thread metadata store: {err}");
None
}
}
} else {
None
}
} else {
None
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
@ -1030,6 +1127,8 @@ impl AgentPanel {
if let Some(serialized_panel) = &serialized_panel {
panel.last_created_entry_kind = serialized_panel.last_created_entry_kind;
} else if let Some(entry_kind) = global_last_created_entry_kind {
panel.last_created_entry_kind = entry_kind;
}
// The thread being restored may have been bound to an
@ -1051,7 +1150,16 @@ impl AgentPanel {
panel.selected_agent = agent;
}
if let Some((info, thread_id)) = thread_to_restore {
if let Some(metadata) = terminal_to_restore {
panel.restore_terminal_for_panel_load(
metadata,
false,
AgentThreadSource::AgentPanel,
Some(workspace),
window,
cx,
);
} else if let Some((info, thread_id)) = thread_to_restore {
let agent = panel.selected_agent.clone();
panel.load_agent_thread(
agent,
@ -1178,6 +1286,7 @@ impl AgentPanel {
draft_thread: None,
retained_threads: HashMap::default(),
terminals: HashMap::default(),
pending_terminal_spawn: None,
new_thread_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
@ -1392,7 +1501,7 @@ impl AgentPanel {
return;
}
self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx);
self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Thread, cx);
// If the user is viewing a *parked* draft and the ephemeral
// new-draft slot is occupied, pressing `+` should just focus the
@ -1545,6 +1654,7 @@ impl AgentPanel {
if !self.supports_terminal(cx) {
return;
}
self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Terminal, cx);
let working_directory = self.terminal_working_directory(workspace, cx);
self.spawn_terminal(
TerminalId::new(),
@ -1579,7 +1689,7 @@ impl AgentPanel {
&& self.project.read(cx).supports_terminal(cx)
}
fn set_last_created_entry_kind(
fn set_last_created_entry_kind_from_user_action(
&mut self,
entry_kind: AgentPanelEntryKind,
cx: &mut Context<Self>,
@ -1588,6 +1698,14 @@ impl AgentPanel {
self.last_created_entry_kind = entry_kind;
self.serialize(cx);
}
cx.background_spawn({
let kvp = KeyValueStore::global(cx);
async move {
write_global_last_created_entry_kind(kvp, entry_kind).await;
}
})
.detach();
}
fn spawn_terminal(
@ -1619,6 +1737,13 @@ impl AgentPanel {
workspace
.update(cx, |workspace, cx| workspace.show_error(&error, cx))
.log_err();
this.update(cx, |this, cx| {
if this.pending_terminal_spawn == Some(terminal_id) {
this.pending_terminal_spawn = None;
cx.notify();
}
})
.log_err();
return anyhow::Ok(());
}
};
@ -1711,7 +1836,9 @@ impl AgentPanel {
notification_subscriptions: Vec::new(),
_subscriptions: vec![view_subscription, terminal_subscription],
};
self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx);
if self.pending_terminal_spawn == Some(terminal_id) {
self.pending_terminal_spawn = None;
}
terminal.refresh_metadata(cx);
self.terminals.insert(terminal_id, terminal);
self.persist_terminal_metadata(terminal_id, cx);
@ -1773,6 +1900,9 @@ impl AgentPanel {
) {
let was_active = self.active_terminal_id() == Some(terminal_id);
if self.pending_terminal_spawn == Some(terminal_id) {
self.pending_terminal_spawn = None;
}
self.dismiss_terminal_notifications(terminal_id, cx);
if self.terminals.remove(&terminal_id).is_none() {
return;
@ -1882,6 +2012,7 @@ impl AgentPanel {
return;
}
self.pending_terminal_spawn = Some(metadata.terminal_id);
let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx);
let initial_title = Self::terminal_restore_initial_title(&metadata);
self.spawn_terminal(
@ -1898,6 +2029,23 @@ impl AgentPanel {
);
}
fn restore_terminal_for_panel_load(
&mut self,
metadata: TerminalThreadMetadata,
focus: bool,
source: AgentThreadSource,
workspace: Option<&Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) {
#[cfg(test)]
self.restore_test_terminal(metadata, focus, source, workspace, window, cx)
.log_err();
#[cfg(not(test))]
self.restore_terminal(metadata, focus, source, workspace, window, cx);
}
fn terminal_restore_working_directory(
&self,
metadata: &TerminalThreadMetadata,
@ -4050,7 +4198,99 @@ impl Panel for AgentPanel {
impl AgentPanel {
fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if matches!(self.base_view, BaseView::Uninitialized) {
self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
if self.pending_terminal_spawn.is_some() {
return;
}
if self.should_create_terminal_for_new_entry(cx) {
let terminal_id = TerminalId::new();
self.pending_terminal_spawn = Some(terminal_id);
cx.defer_in(window, move |this, window, cx| {
if matches!(this.base_view, BaseView::Uninitialized)
&& this.pending_terminal_spawn == Some(terminal_id)
&& this.should_create_terminal_for_new_entry(cx)
{
this.create_initial_terminal(
terminal_id,
AgentThreadSource::AgentPanel,
window,
cx,
);
} else if this.pending_terminal_spawn == Some(terminal_id) {
this.pending_terminal_spawn = None;
}
});
} else {
self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
}
}
}
fn create_initial_terminal(
&mut self,
terminal_id: TerminalId,
source: AgentThreadSource,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.supports_terminal(cx) {
if self.pending_terminal_spawn == Some(terminal_id) {
self.pending_terminal_spawn = None;
}
return;
}
let working_directory = self.terminal_working_directory(None, cx);
self.spawn_initial_terminal(terminal_id, working_directory, source, window, cx);
}
#[cfg(not(test))]
fn spawn_initial_terminal(
&mut self,
terminal_id: TerminalId,
working_directory: Option<PathBuf>,
source: AgentThreadSource,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.spawn_terminal(
terminal_id,
working_directory,
None,
None,
None,
true,
false,
source,
window,
cx,
);
}
#[cfg(test)]
fn spawn_initial_terminal(
&mut self,
terminal_id: TerminalId,
working_directory: Option<PathBuf>,
source: AgentThreadSource,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Err(error) = self.insert_display_only_terminal(
terminal_id,
working_directory,
None,
None,
None,
true,
false,
source,
window,
cx,
) {
log::error!("failed to spawn test agent panel terminal: {error:#}");
if self.pending_terminal_spawn == Some(terminal_id) {
self.pending_terminal_spawn = None;
cx.notify();
}
}
}
@ -5528,6 +5768,7 @@ impl AgentPanel {
cx: &mut Context<Self>,
) -> Result<TerminalId> {
let terminal_id = TerminalId::new();
self.set_last_created_entry_kind_from_user_action(AgentPanelEntryKind::Terminal, cx);
self.insert_display_only_terminal(
terminal_id,
None,
@ -5909,6 +6150,27 @@ mod tests {
panel_b.update(cx, |panel, cx| panel.serialize(cx));
cx.run_until_parked();
let workspace_a_id = workspace_a
.read_with(cx, |workspace, _cx| workspace.database_id())
.expect("workspace A should have a database id");
let kvp = cx.update(|_window, cx| KeyValueStore::global(cx));
let serialized_a: SerializedAgentPanel = cx
.background_spawn(async move { read_serialized_panel(workspace_a_id, &kvp) })
.await
.expect("workspace A should serialize panel state");
assert!(
serialized_a.last_active_thread.is_some(),
"active thread should be the thread restore target"
);
assert!(
serialized_a.last_active_terminal_id.is_none(),
"active thread serialization should not also include a terminal restore target"
);
cx.update(|_window, cx| {
ThreadMetadataStore::init_global(cx);
});
// Load fresh panels for each workspace and verify independent state.
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded_a = AgentPanel::load(workspace_a.downgrade(), async_cx)
@ -5950,6 +6212,278 @@ mod tests {
});
}
#[gpui::test]
async fn test_active_terminal_serialize_and_load_round_trip(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
TerminalThreadMetadataStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel.update_in(cx, |panel, window, cx| {
panel.activate_new_thread(false, AgentThreadSource::AgentPanel, window, 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");
panel.update(cx, |panel, cx| panel.serialize(cx));
cx.run_until_parked();
let workspace_id = workspace
.read_with(cx, |workspace, _cx| workspace.database_id())
.expect("workspace should have a database id");
let kvp = cx.update(|_window, cx| KeyValueStore::global(cx));
let serialized: SerializedAgentPanel = cx
.background_spawn(async move { read_serialized_panel(workspace_id, &kvp) })
.await
.expect("workspace should serialize panel state");
assert_eq!(
serialized.last_active_terminal_id,
Some(terminal_id.to_key_string())
);
assert!(
serialized.last_active_thread.is_none(),
"active terminal serialization should not also include a thread restore target"
);
cx.update(|_window, cx| {
TerminalThreadMetadataStore::init_global(cx);
});
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded = AgentPanel::load(workspace.downgrade(), async_cx)
.await
.expect("panel load should succeed");
for _ in 0..8 {
cx.run_until_parked();
}
loaded.read_with(cx, |panel, cx| {
assert_eq!(panel.active_terminal_id(), Some(terminal_id));
assert!(
panel.active_conversation_view().is_none(),
"the restored terminal should remain active instead of falling back to a draft"
);
assert!(
panel
.terminals(cx)
.into_iter()
.any(|terminal| terminal.id == terminal_id),
"active terminal metadata should be restored into the loaded panel"
);
});
}
#[gpui::test]
async fn test_pending_terminal_restore_prevents_initial_terminal_creation(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_panel(cx).await;
panel.update_in(&mut cx, |panel, window, cx| {
panel.last_created_entry_kind = AgentPanelEntryKind::Terminal;
panel.pending_terminal_spawn = Some(TerminalId::new());
panel.set_active(true, window, cx);
});
for _ in 0..4 {
cx.run_until_parked();
}
panel.read_with(&cx, |panel, cx| {
assert!(
panel.terminals(cx).is_empty(),
"activation while a terminal restore is pending should not create a second terminal"
);
assert!(
panel.active_conversation_view().is_none(),
"activation while a terminal restore is pending should not fall back to a draft"
);
});
}
#[gpui::test]
async fn test_repeated_activation_only_creates_one_initial_terminal(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
panel.update_in(&mut cx, |panel, window, cx| {
panel.last_created_entry_kind = AgentPanelEntryKind::Terminal;
panel.set_active(true, window, cx);
panel.set_active(true, window, cx);
});
for _ in 0..8 {
cx.run_until_parked();
}
panel.read_with(&cx, |panel, cx| {
assert_eq!(
panel.terminals(cx).len(),
1,
"repeated activation should only enqueue one initial terminal"
);
assert!(
panel.active_terminal_id().is_some(),
"the single initial terminal should become active"
);
});
}
#[gpui::test]
async fn test_restored_terminal_does_not_update_global_entry_kind(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
cx.update(|_, cx| {
TerminalThreadMetadataStore::init_global(cx);
});
panel.update_in(&mut cx, |panel, window, cx| {
panel.activate_new_thread(false, AgentThreadSource::AgentPanel, window, cx);
});
cx.run_until_parked();
cx.update(|_, cx| {
assert_eq!(
read_global_last_created_entry_kind(&KeyValueStore::global(cx)),
Some(AgentPanelEntryKind::Thread)
);
});
let metadata = TerminalThreadMetadata {
terminal_id: TerminalId::new(),
title: "Restored Terminal".into(),
custom_title: None,
created_at: Utc::now(),
worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
"/project",
)])),
remote_connection: None,
working_directory: None,
};
panel
.update_in(&mut cx, |panel, window, cx| {
panel.restore_test_terminal(
metadata,
true,
AgentThreadSource::AgentPanel,
None,
window,
cx,
)
})
.expect("test terminal should be restored");
cx.run_until_parked();
cx.update(|_, cx| {
assert_eq!(
read_global_last_created_entry_kind(&KeyValueStore::global(cx)),
Some(AgentPanelEntryKind::Thread),
"restoring a terminal should not change the global new-entry default"
);
});
}
#[gpui::test]
async fn test_new_workspace_load_uses_global_terminal_entry_kind(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
TerminalThreadMetadataStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project-a", json!({ "file.txt": "" }))
.await;
fs.insert_tree("/project-b", json!({ "file.txt": "" }))
.await;
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let project_a = Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
let project_b = Project::test(fs.clone(), [Path::new("/project-b")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let multi_workspace_entity = multi_workspace.root(cx).unwrap();
let workspace_a = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
workspace_a.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
});
panel_a
.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();
cx.update(|_window, cx| {
assert_eq!(
read_global_last_created_entry_kind(&KeyValueStore::global(cx)),
Some(AgentPanelEntryKind::Terminal)
);
});
let workspace_b = multi_workspace_entity.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project_b.clone(), window, cx)
});
workspace_b.update(cx, |workspace, _cx| {
workspace.set_random_database_id();
});
let async_cx = cx.update(|window, cx| window.to_async(cx));
let loaded = AgentPanel::load(workspace_b.downgrade(), async_cx)
.await
.expect("panel load should succeed");
workspace_b.update_in(cx, |workspace, window, cx| {
workspace.add_panel(loaded.clone(), window, cx);
});
loaded.update_in(cx, |panel, window, cx| {
panel.set_active(true, window, cx);
});
for _ in 0..8 {
cx.run_until_parked();
}
loaded.read_with(cx, |panel, cx| {
assert!(
panel.active_terminal_id().is_some(),
"new workspace should initialize to a terminal when terminal was the globally last used entry kind"
);
assert!(
panel.active_conversation_view().is_none(),
"new workspace should not initialize to a draft when terminal is the global entry kind"
);
assert!(panel.should_create_terminal_for_new_entry(cx));
});
}
#[gpui::test]
async fn test_non_native_thread_without_metadata_is_not_restored(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -10,6 +10,7 @@ use db::{
},
sqlez_macros::sql,
};
use futures::{FutureExt, future::Shared};
use gpui::{AppContext as _, Entity, Global, Task};
use remote::{RemoteConnectionOptions, same_remote_connection_identity};
use ui::{App, Context, SharedString};
@ -69,6 +70,7 @@ pub struct TerminalThreadMetadataStore {
terminals: HashMap<TerminalId, TerminalThreadMetadata>,
terminals_by_paths: HashMap<PathList, HashSet<TerminalId>>,
terminals_by_main_paths: HashMap<PathList, HashSet<TerminalId>>,
reload_task: Option<Shared<Task<()>>>,
pending_terminal_ops_tx: async_channel::Sender<DbOperation>,
_db_operations_task: Task<()>,
}
@ -125,6 +127,12 @@ impl TerminalThreadMetadataStore {
self.terminals.values()
}
pub fn reload_task(&self) -> Shared<Task<()>> {
self.reload_task
.clone()
.unwrap_or_else(|| Task::ready(()).shared())
}
pub fn entries_for_path<'a>(
&'a self,
path_list: &PathList,
@ -312,6 +320,7 @@ impl TerminalThreadMetadataStore {
terminals: HashMap::default(),
terminals_by_paths: HashMap::default(),
terminals_by_main_paths: HashMap::default(),
reload_task: None,
pending_terminal_ops_tx: tx,
_db_operations_task,
};
@ -332,30 +341,32 @@ impl TerminalThreadMetadataStore {
fn reload(&mut self, cx: &mut Context<Self>) {
let db = self.db.clone();
cx.spawn(async move |this, cx| {
let rows = cx
.background_spawn(async move {
db.list()
.context("Failed to fetch terminal thread metadata")
self.reload_task = Some(
cx.spawn(async move |this, cx| {
let rows = cx
.background_spawn(async move {
db.list()
.context("Failed to fetch terminal thread metadata")
})
.await
.log_err()
.unwrap_or_default();
this.update(cx, |this, cx| {
this.terminals.clear();
this.terminals_by_paths.clear();
this.terminals_by_main_paths.clear();
for row in rows {
this.cache_terminal_metadata(row);
}
cx.notify();
})
.await
.log_err()
.unwrap_or_default();
this.update(cx, |this, cx| {
this.terminals.clear();
this.terminals_by_paths.clear();
this.terminals_by_main_paths.clear();
for row in rows {
this.cache_terminal_metadata(row);
}
cx.notify();
.ok();
})
.ok();
})
.detach();
.shared(),
);
}
}

View file

@ -587,6 +587,12 @@ impl ThreadMetadataStore {
self.threads.values()
}
pub fn reload_task(&self) -> Shared<Task<()>> {
self.reload_task
.clone()
.unwrap_or_else(|| Task::ready(()).shared())
}
/// Returns all archived threads.
pub fn archived_entries(&self) -> impl Iterator<Item = &ThreadMetadata> + '_ {
self.entries().filter(|t| t.archived)