agent_ui: Require an open project for agent panel (#56577) (cherry-pick to preview) (#56716)

Cherry-pick of #56577 to preview

----
A bit brute force, but it works.

<img width="1106" height="988" alt="image"

src="https://github.com/user-attachments/assets/d23f9a80-01c5-4ad3-a280-faf8b8bc9dbe"
/>


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: Danilo Leal <daniloleal09@gmail.com>

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
zed-zippy[bot] 2026-05-14 11:08:56 +00:00 committed by GitHub
parent fac7b9245f
commit 3da868a4bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 513 additions and 175 deletions

View file

@ -78,8 +78,8 @@ use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings};
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use theme_settings::ThemeSettings;
use ui::{
Button, ContextMenu, ContextMenuEntry, GradientFade, IconButton, PopoverMenu,
PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
Button, ContextMenu, ContextMenuEntry, GradientFade, IconButton, KeyBinding, PopoverMenu,
PopoverMenuHandle, ProjectEmptyState, Tab, Tooltip, prelude::*, utils::WithRemSize,
};
use util::ResultExt as _;
use workspace::{
@ -936,36 +936,43 @@ impl AgentPanel {
})
.await;
let thread_to_restore = 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()))
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 {
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)
});
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
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}");
None
}
}
Err(err) => {
log::warn!("failed to look up last active thread metadata: {err}");
None
}
}
});
})
} else {
None
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
@ -1091,23 +1098,15 @@ impl AgentPanel {
None
};
let connection_store = cx.new(|cx| {
let mut store = AgentConnectionStore::new(project.clone(), cx);
// Register the native agent right away, so that it is available for
// the inline assistant etc.
store.request_connection(
Agent::NativeAgent,
Agent::NativeAgent.server(fs.clone(), thread_store.clone()),
cx,
);
store
});
let connection_store = cx.new(|cx| AgentConnectionStore::new(project.clone(), cx));
let _project_subscription =
cx.subscribe(&project, |this, _project, event, cx| match event {
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeOrderChanged => {
this.ensure_native_agent_connection(cx);
this.update_thread_work_dirs(cx);
cx.notify();
}
_ => {}
});
@ -1164,6 +1163,7 @@ impl AgentPanel {
// Initial sync of agent servers from extensions
panel.sync_agent_servers_from_extensions(cx);
panel.ensure_native_agent_connection(cx);
panel
}
@ -1323,6 +1323,10 @@ impl AgentPanel {
}
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
if !self.has_open_project(cx) {
return;
}
self.new_thread_with_workspace(None, window, cx);
}
@ -1346,6 +1350,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.has_open_project(cx) {
return;
}
self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx);
// If the user is viewing a *parked* draft and the ephemeral
@ -1424,6 +1432,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.has_open_project(cx) {
return;
}
let active_matching = match &self.base_view {
BaseView::AgentThread { conversation_view }
if conversation_view.read(cx).thread_id == thread_id =>
@ -1477,6 +1489,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.has_open_project(cx) {
return;
}
self.selected_agent = action.agent.clone().into();
self.activate_new_thread(true, "agent_panel", window, cx);
}
@ -1487,6 +1503,9 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.supports_terminal(cx) {
return;
}
let working_directory = self.terminal_working_directory(workspace, cx);
self.spawn_terminal(TerminalId::new(), working_directory, true, window, cx);
}
@ -1501,6 +1520,10 @@ impl AgentPanel {
.unwrap_or_else(|| self.default_terminal_working_directory(cx))
}
pub fn supports_terminal(&self, cx: &App) -> bool {
self.has_open_project(cx) && self.project.read(cx).supports_terminal(cx)
}
pub fn should_create_terminal_for_new_entry(&self, cx: &App) -> bool {
self.last_created_entry_kind == AgentPanelEntryKind::Terminal
&& self.project.read(cx).supports_terminal(cx)
@ -2015,6 +2038,26 @@ impl AgentPanel {
.and_then(|workspace| terminal_view::default_working_directory(workspace.read(cx), cx))
}
fn has_open_project(&self, cx: &App) -> bool {
self.project.read(cx).visible_worktrees(cx).next().is_some()
}
fn ensure_native_agent_connection(&self, cx: &mut Context<Self>) {
if !self.has_open_project(cx) {
return;
}
let fs = self.fs.clone();
let thread_store = self.thread_store.clone();
self.connection_store.update(cx, |store, cx| {
store.request_connection(
Agent::NativeAgent,
Agent::NativeAgent.server(fs, thread_store),
cx,
);
});
}
pub fn activate_draft(
&mut self,
focus: bool,
@ -2022,6 +2065,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.has_open_project(cx) {
return;
}
let draft = self.ensure_draft(source, window, cx);
if let BaseView::AgentThread { conversation_view } = &self.base_view {
if conversation_view.entity_id() == draft.entity_id() {
@ -2445,6 +2492,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if resume_thread_id.is_none() && !self.has_open_project(cx) {
return;
}
let agent = agent_choice.unwrap_or_else(|| self.selected_agent(cx));
let thread = self.create_agent_thread_with_server(
agent,
@ -2515,6 +2566,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.has_open_project(cx) {
return;
}
self.new_thread_menu_handle.toggle(window, cx);
}
@ -2729,6 +2784,11 @@ impl AgentPanel {
}
fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.has_open_project(cx) {
Self::show_deferred_toast(&self.workspace, "Open a project to load a thread", cx);
return;
}
let Some(clipboard) = cx.read_from_clipboard() else {
Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx);
return;
@ -3815,6 +3875,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if !self.has_open_project(cx) {
return false;
}
if self.destination_has_meaningful_state(cx) {
return false;
}
@ -4000,21 +4064,24 @@ impl AgentPanel {
.max_w_full()
.overflow_x_hidden()
.child(content)
.when(!self.is_title_editor_focused(window, cx), |this| {
this.child(gradient_overlay).child(
h_flex()
.visible_on_hover("title_editor")
.absolute()
.right_0()
.h_full()
.bg(cx.theme().colors().tab_bar_background)
.child(
IconButton::new("edit_tile", IconName::Pencil)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Edit Thread Title")),
),
)
})
.when(
self.has_open_project(cx) && !self.is_title_editor_focused(window, cx),
|this| {
this.child(gradient_overlay).child(
h_flex()
.visible_on_hover("title_editor")
.absolute()
.right_0()
.h_full()
.bg(cx.theme().colors().tab_bar_background)
.child(
IconButton::new("edit_tile", IconName::Pencil)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Edit Thread Title")),
),
)
},
)
.into_any()
}
@ -4147,12 +4214,31 @@ impl AgentPanel {
})
}
fn render_no_project_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
ProjectEmptyState::new(
"Agent Panel",
focus_handle.clone(),
KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx),
)
.on_open_project(|_, window, cx| {
telemetry::event!("Agent Panel Add Project Clicked");
window.dispatch_action(workspace::Open::default().boxed_clone(), cx);
})
.on_clone_repo(|_, window, cx| {
telemetry::event!("Agent Panel Clone Repo Clicked");
window.dispatch_action(git::Clone.boxed_clone(), cx);
})
}
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let agent_server_store = self.project.read(cx).agent_server_store().clone();
let focus_handle = self.focus_handle(cx);
let supports_terminal = self.project.read(cx).supports_terminal(cx);
let can_create_entries = self.has_open_project(cx);
let supports_terminal = self.supports_terminal(cx);
let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_));
let (selected_agent_custom_icon, selected_agent_label) = if showing_terminal {
@ -4483,7 +4569,7 @@ impl AgentPanel {
.flex_none()
.justify_between();
let toolbar_content = if matches!(mode, ToolbarMode::EmptyThread) {
let toolbar_content = if can_create_entries && matches!(mode, ToolbarMode::EmptyThread) {
let (chevron_icon, icon_color, label_color) =
if self.new_thread_menu_handle.is_deployed() {
(IconName::ChevronUp, Color::Accent, Color::Accent)
@ -4594,7 +4680,7 @@ impl AgentPanel {
.gap_1()
.pl_1()
.pr_1()
.child(new_thread_menu)
.when(can_create_entries, |this| this.child(new_thread_menu))
.child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
@ -4833,10 +4919,11 @@ impl Render for AgentPanel {
// - Scrolling in all views works as expected
// - Files can be dropped into the panel
let content = v_flex()
.key_context(self.key_context())
.relative()
.size_full()
.justify_between()
.key_context(self.key_context())
.bg(cx.theme().colors().panel_background)
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
this.new_thread(action, window, cx);
}))
@ -4861,6 +4948,9 @@ impl Render for AgentPanel {
.child(self.render_toolbar(window, cx))
.children(self.render_new_user_onboarding(window, cx))
.map(|parent| match self.visible_surface() {
VisibleSurface::Uninitialized if !self.has_open_project(cx) => {
parent.child(self.render_no_project_state(cx))
}
VisibleSurface::Uninitialized => parent,
VisibleSurface::AgentThread(conversation_view) => parent
.child(conversation_view.clone())
@ -6408,6 +6498,94 @@ mod tests {
});
}
#[gpui::test]
async fn test_empty_workspace_does_not_create_agent_entries(cx: &mut TestAppContext) {
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.clone(), [], 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();
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
panel.read_with(cx, |panel, cx| {
assert_eq!(
panel
.connection_store()
.read(cx)
.connection_status(&Agent::NativeAgent, cx),
crate::agent_connection_store::AgentConnectionStatus::Disconnected,
"empty workspaces should not start the native agent connection"
);
});
panel.update_in(cx, |panel, window, cx| {
panel.new_thread(&NewThread, window, cx);
panel.activate_draft(true, "agent_panel", window, cx);
panel.new_external_agent_thread(
&NewExternalAgentThread {
agent: AgentId::new("external-agent"),
},
window,
cx,
);
});
cx.run_until_parked();
panel.read_with(cx, |panel, cx| {
assert!(
panel.active_conversation_view().is_none(),
"empty workspaces should not create agent threads"
);
assert!(
panel.draft_thread.is_none(),
"empty workspaces should not create draft threads"
);
assert!(
panel.terminals(cx).is_empty(),
"empty workspaces should not create agent panel terminals"
);
});
cx.update(|_, cx| {
cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
});
panel.update_in(cx, |panel, window, cx| {
panel.new_terminal(None, window, cx);
});
cx.run_until_parked();
panel.read_with(cx, |panel, cx| {
assert!(
panel.terminals(cx).is_empty(),
"empty workspaces should not create terminals after the terminal feature is enabled"
);
assert_eq!(
panel
.connection_store()
.read(cx)
.connection_status(&Agent::NativeAgent, cx),
crate::agent_connection_store::AgentConnectionStatus::Disconnected,
"empty workspace actions should not start the native agent connection"
);
});
}
async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
init_test(cx);
cx.update(|cx| {
@ -6417,7 +6595,8 @@ mod tests {
let fs = FakeFs::new(cx.executor());
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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));
@ -7612,7 +7791,8 @@ mod tests {
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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));
@ -7672,7 +7852,8 @@ mod tests {
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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));
@ -7761,7 +7942,8 @@ mod tests {
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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));
@ -7855,7 +8037,8 @@ mod tests {
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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
@ -7964,7 +8147,8 @@ mod tests {
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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
@ -8062,7 +8246,8 @@ mod tests {
<dyn fs::Fs>::set_global(fs.clone(), cx);
});
let project = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
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));
@ -8876,8 +9061,9 @@ mod tests {
});
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(), [], cx).await;
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));
@ -9102,8 +9288,12 @@ mod tests {
});
let fs = FakeFs::new(cx.executor());
let project_a = Project::test(fs.clone(), [], cx).await;
let project_b = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project_a", json!({ "file.txt": "" }))
.await;
fs.insert_tree("/project_b", json!({ "file.txt": "" }))
.await;
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));
@ -9192,8 +9382,12 @@ mod tests {
});
let fs = FakeFs::new(cx.executor());
let project_a = Project::test(fs.clone(), [], cx).await;
let project_b = Project::test(fs.clone(), [], cx).await;
fs.insert_tree("/project_a", json!({ "file.txt": "" }))
.await;
fs.insert_tree("/project_b", json!({ "file.txt": "" }))
.await;
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));

View file

@ -75,9 +75,9 @@ use strum::{IntoEnumIterator, VariantNames};
use theme_settings::ThemeSettings;
use time::OffsetDateTime;
use ui::{
ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, PopoverMenu,
RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip,
WithScrollbar, prelude::*,
ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, KeyBinding,
PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab,
TintColor, Tooltip, WithScrollbar, prelude::*,
};
use util::paths::PathStyle;
use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
@ -5453,6 +5453,22 @@ impl GitPanel {
}),
)
.into_any_element()
} else if worktree_count == 0 {
let focus_handle = self.focus_handle.clone();
ProjectEmptyState::new(
"Git Panel",
focus_handle.clone(),
KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx),
)
.on_open_project(|_, window, cx| {
telemetry::event!("Git Panel Add Project Clicked");
window.dispatch_action(workspace::Open::default().boxed_clone(), cx);
})
.on_clone_repo(|_, window, cx| {
telemetry::event!("Git Panel Clone Repo Clicked");
window.dispatch_action(git::Clone.boxed_clone(), cx);
})
.into_any_element()
} else {
Empty.into_any_element()
}

View file

@ -60,10 +60,10 @@ use std::{
};
use theme_settings::ThemeSettings;
use ui::{
Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration,
IconDecorationKind, IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label,
LabelSize, ListItem, ListItemSpacing, ScrollAxes, ScrollableHandle, Scrollbars,
StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex,
Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Icon, IconDecoration, IconDecorationKind,
IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, LabelSize, ListItem,
ListItemSpacing, ProjectEmptyState, ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate,
Tooltip, WithScrollbar, prelude::*, v_flex,
};
use util::{
ResultExt, TakeUntilExt, TryFutureExt,
@ -7112,52 +7112,35 @@ impl Render for ProjectPanel {
}))
} else {
let focus_handle = self.focus_handle(cx);
let workspace = self.workspace.clone();
let workspace_clone = self.workspace.clone();
v_flex()
.id("empty-project_panel")
.p_4()
.id("empty-project_panel-wrapper")
.size_full()
.items_center()
.justify_center()
.gap_1()
.track_focus(&self.focus_handle(cx))
.child(
Button::new("open_project", "Open Project")
.full_width()
.key_binding(KeyBinding::for_action_in(
&workspace::Open::default(),
&focus_handle,
cx,
))
.on_click(cx.listener(|this, _, window, cx| {
this.workspace
.update(cx, |_, cx| {
window.dispatch_action(
workspace::Open::default().boxed_clone(),
cx,
);
})
.log_err();
})),
)
.child(
h_flex()
.w_1_2()
.gap_2()
.child(Divider::horizontal())
.child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
.child(Divider::horizontal()),
)
.child(
Button::new("clone_repo", "Clone Repository")
.full_width()
.on_click(cx.listener(|this, _, window, cx| {
this.workspace
.update(cx, |_, cx| {
window.dispatch_action(git::Clone.boxed_clone(), cx);
})
.log_err();
})),
ProjectEmptyState::new(
"Project Panel",
focus_handle.clone(),
KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx),
)
.on_open_project(move |_, window, cx| {
telemetry::event!("Project Panel Add Project Clicked");
workspace
.update(cx, |_, cx| {
window
.dispatch_action(workspace::Open::default().boxed_clone(), cx);
})
.log_err();
})
.on_clone_repo(move |_, window, cx| {
telemetry::event!("Project Panel Clone Repo Clicked");
workspace_clone
.update(cx, |_, cx| {
window.dispatch_action(git::Clone.boxed_clone(), cx);
})
.log_err();
}),
)
.when(is_local, |div| {
div.when(panel_settings.drag_and_drop, |div| {

View file

@ -48,8 +48,9 @@ use std::sync::Arc;
use theme::ActiveTheme;
use ui::{
AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel,
KeyBinding, PopoverMenu, PopoverMenuHandle, ScrollAxes, Scrollbars, Tab, ThreadItem,
ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, render_modifiers,
KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, Tab,
ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*,
render_modifiers,
};
use util::ResultExt as _;
use util::path_list::PathList;
@ -4719,6 +4720,10 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) {
if workspace_path_list(workspace, cx).paths().is_empty() {
return;
}
if self.should_create_terminal_for_workspace(workspace, cx) {
self.create_new_terminal(workspace, window, cx);
} else {
@ -4743,6 +4748,10 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) {
if workspace_path_list(workspace, cx).paths().is_empty() {
return;
}
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
@ -4776,6 +4785,10 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) {
if workspace_path_list(workspace, cx).paths().is_empty() {
return;
}
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
@ -5025,48 +5038,28 @@ impl Sidebar {
}
fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("sidebar-empty-state")
.p_4()
.size_full()
.items_center()
.justify_center()
.gap_1()
.track_focus(&self.focus_handle(cx))
.child(
Button::new("open_project", "Open Project")
.full_width()
.key_binding(KeyBinding::for_action(&workspace::Open::default(), cx))
.on_click(|_, window, cx| {
let side = match AgentSettings::get_global(cx).sidebar_side() {
SidebarSide::Left => "left",
SidebarSide::Right => "right",
};
telemetry::event!("Sidebar Add Project Clicked", side = side);
window.dispatch_action(
Open {
create_new_window: false,
}
.boxed_clone(),
cx,
);
}),
)
.child(
h_flex()
.w_1_2()
.gap_2()
.child(Divider::horizontal().color(ui::DividerColor::Border))
.child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
.child(Divider::horizontal().color(ui::DividerColor::Border)),
)
.child(
Button::new("clone_repo", "Clone Repository")
.full_width()
.on_click(|_, window, cx| {
window.dispatch_action(git::Clone.boxed_clone(), cx);
}),
)
ProjectEmptyState::new(
"Threads Sidebar",
self.focus_handle(cx),
KeyBinding::for_action(&workspace::Open::default(), cx),
)
.on_open_project(|_, window, cx| {
let side = match AgentSettings::get_global(cx).sidebar_side() {
SidebarSide::Left => "left",
SidebarSide::Right => "right",
};
telemetry::event!("Sidebar Add Project Clicked", side = side);
window.dispatch_action(
Open {
create_new_window: false,
}
.boxed_clone(),
cx,
);
})
.on_clone_repo(|_, window, cx| {
window.dispatch_action(git::Clone.boxed_clone(), cx);
})
}
fn render_sidebar_header(

View file

@ -1366,6 +1366,43 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
}
#[gpui::test]
async fn test_new_entry_noops_without_open_project(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
let project = project::Project::test(fs, [], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, 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()
});
assert!(
!sidebar.read_with(cx, |sidebar, _cx| sidebar.contents.has_open_projects),
"empty workspaces should be treated as having no open projects"
);
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_entry(&workspace, window, cx);
});
cx.run_until_parked();
panel.read_with(cx, |panel, _cx| {
assert!(
panel.active_conversation_view().is_none(),
"sidebar should not create an agent thread without an open project"
);
});
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
Vec::<String>::new()
);
}
#[gpui::test]
async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;

View file

@ -29,6 +29,7 @@ mod notification;
mod popover;
mod popover_menu;
mod progress;
mod project_empty_state;
mod redistributable_columns;
mod right_click_menu;
mod scrollbar;
@ -71,6 +72,7 @@ pub use notification::*;
pub use popover::*;
pub use popover_menu::*;
pub use progress::*;
pub use project_empty_state::*;
pub use redistributable_columns::*;
pub use right_click_menu::*;
pub use scrollbar::*;

View file

@ -0,0 +1,94 @@
use crate::{Divider, DividerColor, KeyBinding, prelude::*};
use gpui::{ClickEvent, FocusHandle};
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
#[derive(IntoElement)]
pub struct ProjectEmptyState {
label: SharedString,
focus_handle: FocusHandle,
open_project_key_binding: KeyBinding,
on_open_project: Option<ClickHandler>,
on_clone_repo: Option<ClickHandler>,
}
impl ProjectEmptyState {
pub fn new(
label: impl Into<SharedString>,
focus_handle: FocusHandle,
open_project_key_binding: KeyBinding,
) -> Self {
Self {
label: label.into(),
focus_handle,
open_project_key_binding,
on_open_project: None,
on_clone_repo: None,
}
}
pub fn on_open_project(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open_project = Some(Box::new(handler));
self
}
pub fn on_clone_repo(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_clone_repo = Some(Box::new(handler));
self
}
}
impl RenderOnce for ProjectEmptyState {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let id = format!("empty-state-{}", self.label);
let label = format!("Choose one of the options below to use the {}", self.label);
v_flex()
.id(id)
.p_4()
.size_full()
.items_center()
.justify_center()
.track_focus(&self.focus_handle)
.child(
v_flex()
.w_48()
.max_w_full()
.gap_1()
.child(
div()
.text_center()
.mb_2()
.child(Label::new(label).size(LabelSize::Small).color(Color::Muted)),
)
.child(
Button::new("open_project", "Open Project")
.full_width()
.key_binding(self.open_project_key_binding)
.when_some(self.on_open_project, |button, handler| {
button.on_click(handler)
}),
)
.child(
h_flex()
.gap_2()
.child(Divider::horizontal().color(DividerColor::Border))
.child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
.child(Divider::horizontal().color(DividerColor::Border)),
)
.child(
Button::new("clone_repo", "Clone Repository")
.full_width()
.when_some(self.on_clone_repo, |button, handler| {
button.on_click(handler)
}),
),
)
}
}

View file

@ -1025,16 +1025,35 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
let workspace =
multi_workspace.read_with(cx, |mw, _| mw.workspace().clone())?;
let (client, thread_store) =
multi_workspace.update(cx, |_, _window, cx| {
workspace.update(cx, |workspace, cx| {
let client = workspace.project().read(cx).client();
let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
.panel::<AgentPanel>(cx)
.map(|panel| panel.read(cx).thread_store().clone());
anyhow::Ok((client, thread_store))
})
})??;
let import_state = multi_workspace.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
if workspace.root_paths(cx).is_empty() {
workspace.focus_panel::<AgentPanel>(window, cx);
struct OpenProjectForSharedThreadToast;
workspace.show_toast(
Toast::new(
NotificationId::unique::<OpenProjectForSharedThreadToast>(),
"Open a project to import shared threads",
)
.autohide(),
cx,
);
return anyhow::Ok(None);
}
let client = workspace.project().read(cx).client();
let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
.panel::<AgentPanel>(cx)
.map(|panel| panel.read(cx).thread_store().clone());
anyhow::Ok(Some((client, thread_store)))
})
})??;
let Some((client, thread_store)) = import_state else {
return Ok(());
};
let Some(thread_store): Option<gpui::Entity<ThreadStore>> = thread_store else {
anyhow::bail!("Agent panel not available");