diff --git a/.github/workflows/opencode_review.yml b/.github/workflows/opencode_review.yml
new file mode 100644
index 00000000000000..32ea5b4dd33921
--- /dev/null
+++ b/.github/workflows/opencode_review.yml
@@ -0,0 +1,76 @@
+name: OpenCode Review
+
+on:
+ pull_request:
+ types: [opened, synchronize]
+
+jobs:
+ code-review:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Install opencode
+ run: curl -fsSL https://opencode.ai/install | bash
+
+ - name: Review PR
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
+ OPENCODE_MODEL: "zai-coding-plan/glm-4.6"
+ OPENCODE_PERMISSION: '{ "bash": { "./post_pr_comment*": "allow", "gh*": "allow", "gh api*": "deny", "gh pr review*": "deny", "*": "deny" } }'
+ run: |
+ # Create post_pr_comment script
+ cat << 'EOF' > $PWD/post_pr_comment
+ #!/bin/bash
+ # Use temporary file to store the comment so we can use full markdown format
+ printf '%s' "$1" > /tmp/comment.md
+ # Strip full path to make it relative to current working directory
+ diff_path=$(realpath --relative-to=. "$2")
+
+ gh api --method POST \
+ -H 'X-GitHub-Api-Version: 2022-11-28' \
+ -H "Accept: application/vnd.github+json" \
+ /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
+ --raw-field 'commit_id=${{ github.event.pull_request.head.sha }}' \
+ --raw-field 'side=RIGHT' \
+ --field "body=@/tmp/comment.md" --raw-field "path=$diff_path" --field "line=$3"
+ EOF
+ chmod +x $PWD/post_pr_comment
+
+ # Configure OpenCode auth
+ mkdir -p $HOME/.local/share/opencode/
+ echo '{"zai-coding-plan": { "type": "api", "key": "'$ZAI_API_KEY'"}}' > $HOME/.local/share/opencode/auth.json
+
+ # Start OpenCode with PR review prompt
+ opencode run -m $OPENCODE_MODEL 'A new pull request has been created: "${{ github.event.pull_request.title }}"
+
+
+ ${{ github.repository }}
+
+
+
+ ${{ github.event.pull_request.number }}
+
+
+
+ ${{ github.event.pull_request.body }}
+
+
+ You are a diligent senior engineer. Review the code changes in this pull request against the guidelines in the ".rules" file. Only review the changes found in diffs, but feel free to read the entire file to get more context. Make it clear the suggestions are merely suggestions and the human can decide what to do. Be concise and to the point. Start directly with the comment, no need to state e.g. "The guideline states...". Wrap codes in backticks (`) or triple backticks (```) for inline and multi-line code respectively.
+
+ How to post the comments:
+
+ - Use the `post_pr_comment` command to create comments on the files for the violations.
+ - Focus on changes in the diffs so you can leave the comment on the exact line number.
+ - If you have a suggested fix, wrap the suggestion in codeblock.
+ - Only create comments for actual violations. Don't run any `post_pr_comment` if no violations.
+ - Command format MUST be like this:
+ ./post_pr_comment '[comment]' '[path-to-file]' [line]
+ "
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 3620396e0ab013..c7fb279680fe21 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -815,6 +815,16 @@ impl AcpThreadView {
}
}
+ pub fn session_id(&self, cx: &App) -> Option {
+ if let Some(thread) = self.thread() {
+ Some(thread.read(cx).session_id().clone())
+ } else {
+ self.resume_thread_metadata
+ .as_ref()
+ .map(|metadata| metadata.id.clone())
+ }
+ }
+
pub fn mode_selector(&self) -> Option<&Entity> {
match &self.thread_state {
ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(),
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 795dd5b4ba1aae..b3ab2ddcf818fa 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -1,17 +1,20 @@
+use std::cmp::Ordering;
use std::collections::HashMap;
use std::ops::Range;
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
+use std::time::Duration;
-use acp_thread::{AcpThread, AcpThreadEvent};
+use acp_thread::{AcpThread, AcpThreadEvent, ThreadStatus};
use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
use agent_client_protocol as acp;
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use project::{
ExternalAgentServerName,
agent_server_store::{
- AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
+ AgentServerCommand, AgentServerStore, AllAgentServersSettings, CLAUDE_CODE_NAME,
+ CODEX_NAME, GEMINI_NAME,
},
};
use serde::{Deserialize, Serialize};
@@ -21,11 +24,12 @@ use settings::{
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
+use crate::agent_panel_tab::{AgentPanelTab, AgentPanelTabIdentity, TabId, TabLabelRender};
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
use crate::{
- AddContextServer, AgentDiffPane, DeleteRecentlyOpenThread, Follow, InlineAssistant,
- NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory,
- ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu,
+ AddContextServer, AgentDiffPane, CloseActiveThreadTab, DeleteRecentlyOpenThread, Follow,
+ InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
+ OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu,
ToggleOptionsMenu,
acp::AcpThreadView,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
@@ -48,28 +52,29 @@ use assistant_slash_command::SlashCommandWorkingSet;
use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
use client::{UserStore, zed_urls};
use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit};
-use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
+use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer, actions::Cancel};
use extension::ExtensionEvents;
use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
- Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter,
- ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, Task, UpdateGlobal,
- WeakEntity, prelude::*,
+ Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, Corner, DismissEvent,
+ Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, ScrollHandle,
+ SharedString, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{ConfigurationError, LanguageModelRegistry};
+use menu::Confirm;
use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
use rules_library::{RulesLibrary, open_rules_library};
use search::{BufferSearchBar, buffer_search};
use settings::{Settings, SettingsStore, update_settings_file};
use theme::ThemeSettings;
-use ui::utils::WithRemSize;
use ui::{
Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle,
- ProgressBar, Tab, Tooltip, prelude::*,
+ ProgressBar, Tab, TabBar, TabCloseSide, TabPosition, Tooltip, prelude::*,
};
+use ui::{IconButtonShape, utils::WithRemSize};
use util::ResultExt as _;
use workspace::{
CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
@@ -90,6 +95,7 @@ struct DetachedThread {
}
const AGENT_PANEL_KEY: &str = "agent_panel";
+const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
#[derive(Serialize, Deserialize, Debug)]
struct SerializedAgentPanel {
@@ -225,7 +231,7 @@ pub fn init(cx: &mut App) {
.detach();
}
-enum ActiveView {
+pub enum ActiveView {
ExternalAgentThread {
thread_view: Entity,
},
@@ -447,12 +453,13 @@ pub struct AgentPanel {
inline_assist_context_store: Entity,
configuration: Option>,
configuration_subscription: Option,
- active_view: ActiveView,
- previous_view: Option,
+ overlay_view: Option,
+ overlay_previous_tab_id: Option,
new_thread_menu_handle: PopoverMenuHandle,
agent_panel_menu_handle: PopoverMenuHandle,
agent_navigation_menu_handle: PopoverMenuHandle,
agent_navigation_menu: Option>,
+ panel_focus_handle: FocusHandle,
_extension_subscription: Option,
width: Option,
height: Option,
@@ -461,6 +468,11 @@ pub struct AgentPanel {
onboarding: Entity,
selected_agent: AgentType,
detached_threads: HashMap,
+ pending_tab_removal: Option,
+ tabs: Vec,
+ active_tab_id: TabId,
+ tab_bar_scroll_handle: ScrollHandle,
+ title_edit_overlay_tab_id: Option,
}
impl AgentPanel {
@@ -527,13 +539,11 @@ impl AgentPanel {
if let Some(selected_agent) = serialized_panel.selected_agent {
panel.selected_agent = selected_agent.clone();
panel.new_agent_thread(selected_agent, window, cx);
+ log::info!("Restore the default panel from serialized panel.");
+ panel.remove_tab_by_id(0, window, cx);
}
cx.notify();
});
- } else {
- panel.update(cx, |panel, cx| {
- panel.new_agent_thread(AgentType::NativeAgent, window, cx);
- });
}
panel.as_mut(cx).loading = false;
panel
@@ -585,15 +595,18 @@ impl AgentPanel {
.detach();
let panel_type = AgentSettings::get_global(cx).default_view;
- let active_view = match panel_type {
- DefaultView::Thread => ActiveView::native_agent(
- fs.clone(),
- prompt_store.clone(),
- history_store.clone(),
- project.clone(),
- workspace.clone(),
- window,
- cx,
+ let (active_view, selected_agent) = match panel_type {
+ DefaultView::Thread => (
+ ActiveView::native_agent(
+ fs.clone(),
+ prompt_store.clone(),
+ history_store.clone(),
+ project.clone(),
+ workspace.clone(),
+ window,
+ cx,
+ ),
+ AgentType::NativeAgent,
),
DefaultView::TextThread => {
let context = text_thread_store.update(cx, |store, cx| store.create(cx));
@@ -611,12 +624,15 @@ impl AgentPanel {
editor.insert_default_prompt(window, cx);
editor
});
- ActiveView::text_thread(
- text_thread_editor,
- history_store.clone(),
- language_registry.clone(),
- window,
- cx,
+ (
+ ActiveView::text_thread(
+ text_thread_editor,
+ history_store.clone(),
+ language_registry.clone(),
+ window,
+ cx,
+ ),
+ AgentType::TextThread,
)
}
};
@@ -682,8 +698,18 @@ impl AgentPanel {
None
};
+ let panel_focus_handle = cx.focus_handle();
+ cx.on_focus_in(&panel_focus_handle, window, |_, _, cx| {
+ cx.notify();
+ })
+ .detach();
+ cx.on_focus_out(&panel_focus_handle, window, |_, _, _, cx| {
+ cx.notify();
+ })
+ .detach();
+
let mut panel = Self {
- active_view,
+ overlay_view: None,
workspace,
user_store,
project: project.clone(),
@@ -695,7 +721,7 @@ impl AgentPanel {
configuration_subscription: None,
context_server_registry,
inline_assist_context_store,
- previous_view: None,
+ overlay_previous_tab_id: None,
new_thread_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
@@ -711,6 +737,12 @@ impl AgentPanel {
selected_agent: AgentType::default(),
detached_threads: HashMap::new(),
loading: false,
+ pending_tab_removal: None,
+ panel_focus_handle,
+ tabs: vec![AgentPanelTab::new(active_view, selected_agent)],
+ active_tab_id: 0,
+ tab_bar_scroll_handle: ScrollHandle::new(),
+ title_edit_overlay_tab_id: None,
};
// Initial sync of agent servers from extensions
@@ -767,8 +799,14 @@ impl AgentPanel {
.unwrap_or(true)
}
+ fn active_view(&self) -> &ActiveView {
+ self.overlay_view
+ .as_ref()
+ .unwrap_or_else(|| self.active_tab().view())
+ }
+
fn active_thread_view(&self) -> Option<&Entity> {
- match &self.active_view {
+ match self.active_view() {
ActiveView::ExternalAgentThread { thread_view, .. } => Some(thread_view),
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
}
@@ -830,7 +868,7 @@ impl AgentPanel {
self.serialize(cx);
}
- self.set_active_view(
+ self.push_tab(
ActiveView::text_thread(
text_thread_editor.clone(),
self.history_store.clone(),
@@ -838,6 +876,7 @@ impl AgentPanel {
window,
cx,
),
+ AgentType::TextThread,
window,
cx,
);
@@ -915,7 +954,7 @@ impl AgentPanel {
this.update_in(cx, |this, window, cx| {
let selected_agent = ext_agent.into();
if this.selected_agent != selected_agent {
- this.selected_agent = selected_agent;
+ this.selected_agent = selected_agent.clone();
this.serialize(cx);
}
@@ -983,7 +1022,12 @@ impl AgentPanel {
);
}
- this.set_active_view(ActiveView::ExternalAgentThread { thread_view }, window, cx);
+ this.push_tab(
+ ActiveView::ExternalAgentThread { thread_view },
+ selected_agent,
+ window,
+ cx,
+ );
})
})
.detach_and_log_err(cx);
@@ -1023,12 +1067,12 @@ impl AgentPanel {
}
fn open_history(&mut self, window: &mut Window, cx: &mut Context) {
- if matches!(self.active_view, ActiveView::History) {
- if let Some(previous_view) = self.previous_view.take() {
- self.set_active_view(previous_view, window, cx);
+ if matches!(self.active_view(), ActiveView::History) {
+ if let Some(previous_tab_id) = self.overlay_previous_tab_id.take() {
+ self.set_active_tab_by_id(previous_tab_id, window, cx);
}
} else {
- self.set_active_view(ActiveView::History, window, cx);
+ self.set_tab_overlay_view(ActiveView::History, window, cx);
}
cx.notify();
}
@@ -1076,7 +1120,7 @@ impl AgentPanel {
self.serialize(cx);
}
- self.set_active_view(
+ self.push_tab(
ActiveView::text_thread(
editor,
self.history_store.clone(),
@@ -1084,30 +1128,25 @@ impl AgentPanel {
window,
cx,
),
+ AgentType::TextThread,
window,
cx,
);
}
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) {
- match self.active_view {
- ActiveView::Configuration | ActiveView::History => {
- if let Some(previous_view) = self.previous_view.take() {
- self.active_view = previous_view;
+ if self.title_edit_overlay_tab_id.take().is_some() {
+ self.focus_active_panel_thread(window, cx);
+ return;
+ }
- match &self.active_view {
- ActiveView::ExternalAgentThread { thread_view } => {
- thread_view.focus_handle(cx).focus(window);
- }
- ActiveView::TextThread {
- text_thread_editor, ..
- } => {
- text_thread_editor.focus_handle(cx).focus(window);
- }
- ActiveView::History | ActiveView::Configuration => {}
- }
+ match self.active_view() {
+ ActiveView::Configuration | ActiveView::History => {
+ if let Some(previous_tab_id) = self.overlay_previous_tab_id.take() {
+ self.active_tab_id = previous_tab_id;
+ self.overlay_view = None;
+ self.focus_active_panel_thread(window, cx);
}
- cx.notify();
}
_ => {}
}
@@ -1159,7 +1198,7 @@ impl AgentPanel {
}
fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context) {
- match self.active_view.which_font_size_used() {
+ match self.active_view().which_font_size_used() {
WhichFontSize::AgentFont => {
if persist {
update_settings_file(self.fs.clone(), cx, move |settings, cx| {
@@ -1229,7 +1268,7 @@ impl AgentPanel {
let context_server_store = self.project.read(cx).context_server_store();
let fs = self.fs.clone();
- self.set_active_view(ActiveView::Configuration, window, cx);
+ self.set_tab_overlay_view(ActiveView::Configuration, window, cx);
self.configuration = Some(cx.new(|cx| {
AgentConfiguration::new(
fs,
@@ -1264,7 +1303,7 @@ impl AgentPanel {
return;
};
- match &self.active_view {
+ match self.active_view() {
ActiveView::ExternalAgentThread { thread_view } => {
thread_view
.update(cx, |thread_view, cx| {
@@ -1317,7 +1356,7 @@ impl AgentPanel {
}
pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> {
- match &self.active_view {
+ match self.active_view() {
ActiveView::ExternalAgentThread { thread_view, .. } => {
thread_view.read(cx).thread().cloned()
}
@@ -1326,7 +1365,7 @@ impl AgentPanel {
}
pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> {
- match &self.active_view {
+ match self.active_view() {
ActiveView::ExternalAgentThread { thread_view, .. } => {
thread_view.read(cx).as_native_thread(cx)
}
@@ -1335,7 +1374,7 @@ impl AgentPanel {
}
pub(crate) fn active_text_thread_editor(&self) -> Option> {
- match &self.active_view {
+ match self.active_view() {
ActiveView::TextThread {
text_thread_editor, ..
} => Some(text_thread_editor.clone()),
@@ -1343,50 +1382,6 @@ impl AgentPanel {
}
}
- fn set_active_view(
- &mut self,
- new_view: ActiveView,
- window: &mut Window,
- cx: &mut Context,
- ) {
- let current_is_history = matches!(self.active_view, ActiveView::History);
- let new_is_history = matches!(new_view, ActiveView::History);
-
- let current_is_config = matches!(self.active_view, ActiveView::Configuration);
- let new_is_config = matches!(new_view, ActiveView::Configuration);
-
- let current_is_special = current_is_history || current_is_config;
- let new_is_special = new_is_history || new_is_config;
-
- match &new_view {
- ActiveView::TextThread {
- text_thread_editor, ..
- } => self.history_store.update(cx, |store, cx| {
- if let Some(path) = text_thread_editor.read(cx).text_thread().read(cx).path() {
- store.push_recently_opened_entry(
- agent::HistoryEntryId::TextThread(path.clone()),
- cx,
- )
- }
- }),
- ActiveView::ExternalAgentThread { .. } => {}
- ActiveView::History | ActiveView::Configuration => {}
- }
-
- if current_is_special && !new_is_special {
- self.active_view = new_view;
- } else if !current_is_special && new_is_special {
- self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
- } else {
- if !new_is_special {
- self.previous_view = None;
- }
- self.active_view = new_view;
- }
-
- self.focus_handle(cx).focus(window);
- }
-
fn populate_recently_opened_menu_section(
mut menu: ContextMenu,
panel: Entity,
@@ -1543,11 +1538,54 @@ impl AgentPanel {
cx,
);
}
+
+ fn focus_active_panel_thread(&self, window: &mut Window, cx: &mut Context) {
+ match self.active_view() {
+ ActiveView::ExternalAgentThread { thread_view } => {
+ thread_view.focus_handle(cx).focus(window);
+ }
+ ActiveView::TextThread {
+ text_thread_editor, ..
+ } => {
+ text_thread_editor.focus_handle(cx).focus(window);
+ }
+ ActiveView::History | ActiveView::Configuration => {}
+ }
+ cx.notify();
+ }
+
+ fn focus_title_editor(&mut self, window: &mut Window, cx: &mut Context) {
+ if self.overlay_view.is_some()
+ || self.title_edit_overlay_tab_id.is_some()
+ || !matches!(
+ self.tabs.get(self.active_tab_id).map(|tab| tab.view()),
+ Some(ActiveView::ExternalAgentThread { .. } | ActiveView::TextThread { .. })
+ )
+ {
+ return;
+ }
+
+ self.title_edit_overlay_tab_id = Some(self.active_tab_id);
+ if let Some(tab) = self.tabs.get(self.active_tab_id) {
+ match tab.view() {
+ ActiveView::ExternalAgentThread { thread_view } => {
+ if let Some(editor) = thread_view.read(cx).title_editor() {
+ editor.focus_handle(cx).focus(window);
+ }
+ }
+ ActiveView::TextThread { title_editor, .. } => {
+ title_editor.focus_handle(cx).focus(window);
+ }
+ ActiveView::History | ActiveView::Configuration => {}
+ }
+ }
+ cx.notify();
+ }
}
impl Focusable for AgentPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
- match &self.active_view {
+ match self.active_view() {
ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx),
ActiveView::History => self.acp_history.focus_handle(cx),
ActiveView::TextThread {
@@ -1652,36 +1690,26 @@ impl Panel for AgentPanel {
}
impl AgentPanel {
- fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement {
- const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
-
- let content = match &self.active_view {
+ fn render_overlay_title_editor(&self, cx: &mut Context) -> Option {
+ let tab_id = self.title_edit_overlay_tab_id?;
+ let tab = self.tabs.get(tab_id)?;
+ let content = match tab.view() {
ActiveView::ExternalAgentThread { thread_view } => {
if let Some(title_editor) = thread_view.read(cx).title_editor() {
- div()
- .w_full()
- .on_action({
- let thread_view = thread_view.downgrade();
- move |_: &menu::Confirm, window, cx| {
- if let Some(thread_view) = thread_view.upgrade() {
- thread_view.focus_handle(cx).focus(window);
- }
- }
- })
- .on_action({
- let thread_view = thread_view.downgrade();
- move |_: &editor::actions::Cancel, window, cx| {
- if let Some(thread_view) = thread_view.upgrade() {
- thread_view.focus_handle(cx).focus(window);
- }
- }
- })
- .child(title_editor)
+ h_flex()
+ .flex_grow()
+ .items_center()
+ .child(title_editor.clone())
.into_any_element()
} else {
- Label::new(thread_view.read(cx).title(cx))
- .color(Color::Muted)
- .truncate()
+ h_flex()
+ .flex_grow()
+ .items_center()
+ .child(
+ Label::new(thread_view.read(cx).title(cx))
+ .color(Color::Muted)
+ .truncate(),
+ )
.into_any_element()
}
}
@@ -1693,25 +1721,38 @@ impl AgentPanel {
let summary = text_thread_editor.read(cx).text_thread().read(cx).summary();
match summary {
- TextThreadSummary::Pending => Label::new(TextThreadSummary::DEFAULT)
- .color(Color::Muted)
- .truncate()
+ TextThreadSummary::Pending => h_flex()
+ .flex_grow()
+ .items_center()
+ .child(
+ Label::new(TextThreadSummary::DEFAULT)
+ .color(Color::Muted)
+ .truncate(),
+ )
.into_any_element(),
TextThreadSummary::Content(summary) => {
if summary.done {
- div()
- .w_full()
+ h_flex()
+ .flex_grow()
+ .items_center()
.child(title_editor.clone())
.into_any_element()
} else {
- Label::new(LOADING_SUMMARY_PLACEHOLDER)
- .truncate()
- .color(Color::Muted)
+ h_flex()
+ .flex_grow()
+ .items_center()
+ .child(
+ Label::new(LOADING_SUMMARY_PLACEHOLDER)
+ .color(Color::Muted)
+ .truncate(),
+ )
.into_any_element()
}
}
TextThreadSummary::Error => h_flex()
- .w_full()
+ .flex_grow()
+ .items_center()
+ .gap(DynamicSpacing::Base04.rems(cx))
.child(title_editor.clone())
.child(
IconButton::new("retry-summary-generation", IconName::RotateCcw)
@@ -1719,9 +1760,8 @@ impl AgentPanel {
.on_click({
let text_thread_editor = text_thread_editor.clone();
move |_, _window, cx| {
- text_thread_editor.update(cx, |text_thread_editor, cx| {
- text_thread_editor.regenerate_summary(cx);
- });
+ text_thread_editor
+ .update(cx, |editor, cx| editor.regenerate_summary(cx));
}
})
.tooltip(move |_window, cx| {
@@ -1735,19 +1775,37 @@ impl AgentPanel {
.into_any_element(),
}
}
- ActiveView::History => Label::new("History").truncate().into_any_element(),
- ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
+ ActiveView::History | ActiveView::Configuration => {
+ return None;
+ }
};
- h_flex()
- .key_context("TitleEditor")
- .id("TitleEditor")
- .flex_grow()
- .w_full()
- .max_w_full()
- .overflow_x_scroll()
- .child(content)
- .into_any()
+ Some(
+ h_flex()
+ .flex_grow()
+ .h(Tab::content_height(cx))
+ .px(DynamicSpacing::Base04.px(cx))
+ .gap(DynamicSpacing::Base04.rems(cx))
+ .border_0()
+ .child(h_flex().flex_grow().items_center().pl_1().child(content))
+ .child(
+ Icon::new(IconName::Return)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .pr_2()
+ .on_action(cx.listener(|this, _: &Confirm, window, cx| {
+ if this.title_edit_overlay_tab_id.take().is_some() {
+ this.focus_active_panel_thread(window, cx);
+ }
+ }))
+ .on_action(cx.listener(|this, _: &Cancel, window, cx| {
+ if this.title_edit_overlay_tab_id.take().is_some() {
+ this.focus_active_panel_thread(window, cx);
+ }
+ }))
+ .into_any_element(),
+ )
}
fn render_panel_options_menu(
@@ -1913,95 +1971,832 @@ impl AgentPanel {
})
}
- fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let agent_server_store = self.project.read(cx).agent_server_store().clone();
- let focus_handle = self.focus_handle(cx);
+ fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool {
+ if TrialEndUpsell::dismissed() {
+ return false;
+ }
- // Get custom icon path for selected agent before building menu (to avoid borrow issues)
- let selected_agent_custom_icon =
- if let AgentType::Custom { name, .. } = &self.selected_agent {
- agent_server_store
+ match self.active_view() {
+ ActiveView::TextThread { .. } => {
+ if LanguageModelRegistry::global(cx)
.read(cx)
- .agent_icon(&ExternalAgentServerName(name.clone()))
- } else {
- None
- };
+ .default_model()
+ .is_some_and(|model| {
+ model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
+ })
+ {
+ return false;
+ }
+ }
+ ActiveView::ExternalAgentThread { .. }
+ | ActiveView::History
+ | ActiveView::Configuration => return false,
+ }
- let active_thread = match &self.active_view {
- ActiveView::ExternalAgentThread { thread_view } => {
- thread_view.read(cx).as_native_thread(cx)
+ let plan = self.user_store.read(cx).plan();
+ let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
+
+ matches!(
+ plan,
+ Some(Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree))
+ ) && has_previous_trial
+ }
+
+ fn should_render_onboarding(&self, cx: &mut Context) -> bool {
+ if OnboardingUpsell::dismissed() {
+ return false;
+ }
+
+ let user_store = self.user_store.read(cx);
+
+ if user_store
+ .plan()
+ .is_some_and(|plan| matches!(plan, Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)))
+ && user_store
+ .subscription_period()
+ .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
+ .is_some_and(|date| date < chrono::Utc::now())
+ {
+ OnboardingUpsell::set_dismissed(true, cx);
+ return false;
+ }
+
+ match self.active_view() {
+ ActiveView::History | ActiveView::Configuration => false,
+ ActiveView::ExternalAgentThread { thread_view, .. }
+ if thread_view.read(cx).as_native_thread(cx).is_none() =>
+ {
+ false
}
- ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
- };
+ _ => {
+ let history_is_empty = self.history_store.read(cx).is_empty(cx);
- let new_thread_menu = PopoverMenu::new("new_thread_menu")
- .trigger_with_tooltip(
- IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
- {
- let focus_handle = focus_handle.clone();
- move |_window, cx| {
- Tooltip::for_action_in(
- "New Thread…",
- &ToggleNewThreadMenu,
- &focus_handle,
- cx,
- )
- }
- },
- )
- .anchor(Corner::TopRight)
- .with_handle(self.new_thread_menu_handle.clone())
- .menu({
- let workspace = self.workspace.clone();
- let is_via_collab = workspace
- .update(cx, |workspace, cx| {
- workspace.project().read(cx).is_via_collab()
- })
- .unwrap_or_default();
+ let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
+ .providers()
+ .iter()
+ .any(|provider| {
+ provider.is_authenticated(cx)
+ && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
+ });
- move |window, cx| {
- telemetry::event!("New Thread Clicked");
+ history_is_empty || !has_configured_non_zed_providers
+ }
+ }
+ }
- let active_thread = active_thread.clone();
- Some(ContextMenu::build(window, cx, |menu, _window, cx| {
- menu.context(focus_handle.clone())
- .header("Zed Agent")
- .when_some(active_thread, |this, active_thread| {
- let thread = active_thread.read(cx);
+ fn render_onboarding(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ if !self.should_render_onboarding(cx) {
+ return None;
+ }
- if !thread.is_empty() {
- let session_id = thread.id().clone();
- this.item(
- ContextMenuEntry::new("New From Summary")
- .icon(IconName::ThreadFromSummary)
- .icon_color(Color::Muted)
- .handler(move |window, cx| {
- window.dispatch_action(
- Box::new(NewNativeAgentThreadFromSummary {
- from_session_id: session_id.clone(),
- }),
- cx,
- );
- }),
- )
- } else {
- this
- }
- })
- .item(
- ContextMenuEntry::new("New Thread")
- .action(NewThread.boxed_clone())
- .icon(IconName::Thread)
- .icon_color(Color::Muted)
- .handler({
- let workspace = workspace.clone();
- move |window, cx| {
- if let Some(workspace) = workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- if let Some(panel) =
- workspace.panel::(cx)
- {
- panel.update(cx, |panel, cx| {
+ let text_thread_view = matches!(self.active_view(), ActiveView::TextThread { .. });
+
+ Some(
+ div()
+ .when(text_thread_view, |this| {
+ this.bg(cx.theme().colors().editor_background)
+ })
+ .child(self.onboarding.clone()),
+ )
+ }
+
+ fn render_trial_end_upsell(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ if !self.should_render_trial_end_upsell(cx) {
+ return None;
+ }
+
+ let plan = self.user_store.read(cx).plan()?;
+
+ Some(
+ v_flex()
+ .absolute()
+ .inset_0()
+ .size_full()
+ .bg(cx.theme().colors().panel_background)
+ .opacity(0.85)
+ .block_mouse_except_scroll()
+ .child(EndTrialUpsell::new(
+ plan,
+ Arc::new({
+ let this = cx.entity();
+ move |_, cx| {
+ this.update(cx, |_this, cx| {
+ TrialEndUpsell::set_dismissed(true, cx);
+ cx.notify();
+ });
+ }
+ }),
+ )),
+ )
+ }
+
+ fn render_configuration_error(
+ &self,
+ border_bottom: bool,
+ configuration_error: &ConfigurationError,
+ focus_handle: &FocusHandle,
+ cx: &mut App,
+ ) -> impl IntoElement {
+ let zed_provider_configured = AgentSettings::get_global(cx)
+ .default_model
+ .as_ref()
+ .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev");
+
+ let callout = if zed_provider_configured {
+ Callout::new()
+ .icon(IconName::Warning)
+ .severity(Severity::Warning)
+ .when(border_bottom, |this| {
+ this.border_position(ui::BorderPosition::Bottom)
+ })
+ .title("Sign in to continue using Zed as your LLM provider.")
+ .actions_slot(
+ Button::new("sign_in", "Sign In")
+ .style(ButtonStyle::Tinted(ui::TintColor::Warning))
+ .label_size(LabelSize::Small)
+ .on_click({
+ let workspace = self.workspace.clone();
+ move |_, _, cx| {
+ let Ok(client) =
+ workspace.update(cx, |workspace, _| workspace.client().clone())
+ else {
+ return;
+ };
+
+ cx.spawn(async move |cx| {
+ client.sign_in_with_optional_connect(true, cx).await
+ })
+ .detach_and_log_err(cx);
+ }
+ }),
+ )
+ } else {
+ Callout::new()
+ .icon(IconName::Warning)
+ .severity(Severity::Warning)
+ .when(border_bottom, |this| {
+ this.border_position(ui::BorderPosition::Bottom)
+ })
+ .title(configuration_error.to_string())
+ .actions_slot(
+ Button::new("settings", "Configure")
+ .style(ButtonStyle::Tinted(ui::TintColor::Warning))
+ .label_size(LabelSize::Small)
+ .key_binding(
+ KeyBinding::for_action_in(&OpenSettings, focus_handle, cx)
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(|_event, window, cx| {
+ window.dispatch_action(OpenSettings.boxed_clone(), cx)
+ }),
+ )
+ };
+
+ match configuration_error {
+ ConfigurationError::ModelNotFound
+ | ConfigurationError::ProviderNotAuthenticated(_)
+ | ConfigurationError::NoProvider => callout.into_any_element(),
+ }
+ }
+
+ fn render_text_thread(
+ &self,
+ text_thread_editor: &Entity,
+ buffer_search_bar: &Entity,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Div {
+ let mut registrar = buffer_search::DivRegistrar::new(
+ |this, _, _cx| match this.active_view() {
+ ActiveView::TextThread {
+ buffer_search_bar, ..
+ } => Some(buffer_search_bar.clone()),
+ _ => None,
+ },
+ cx,
+ );
+ BufferSearchBar::register(&mut registrar);
+ registrar
+ .into_div()
+ .size_full()
+ .relative()
+ .map(|parent| {
+ buffer_search_bar.update(cx, |buffer_search_bar, cx| {
+ if buffer_search_bar.is_dismissed() {
+ return parent;
+ }
+ parent.child(
+ div()
+ .p(DynamicSpacing::Base08.rems(cx))
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().editor_background)
+ .child(buffer_search_bar.render(window, cx)),
+ )
+ })
+ })
+ .child(text_thread_editor.clone())
+ .child(self.render_drag_target(cx))
+ }
+
+ fn render_drag_target(&self, cx: &Context) -> Div {
+ let is_local = self.project.read(cx).is_local();
+ div()
+ .invisible()
+ .absolute()
+ .top_0()
+ .right_0()
+ .bottom_0()
+ .left_0()
+ .bg(cx.theme().colors().drop_target_background)
+ .drag_over::(|this, _, _, _| this.visible())
+ .drag_over::(|this, _, _, _| this.visible())
+ .when(is_local, |this| {
+ this.drag_over::(|this, _, _, _| this.visible())
+ })
+ .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
+ let item = tab.pane.read(cx).item_for_index(tab.ix);
+ let project_paths = item
+ .and_then(|item| item.project_path(cx))
+ .into_iter()
+ .collect::>();
+ this.handle_drop(project_paths, vec![], window, cx);
+ }))
+ .on_drop(
+ cx.listener(move |this, selection: &DraggedSelection, window, cx| {
+ let project_paths = selection
+ .items()
+ .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
+ .collect::>();
+ this.handle_drop(project_paths, vec![], window, cx);
+ }),
+ )
+ .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
+ let tasks = paths
+ .paths()
+ .iter()
+ .map(|path| {
+ Workspace::project_path_for_path(this.project.clone(), path, false, cx)
+ })
+ .collect::>();
+ cx.spawn_in(window, async move |this, cx| {
+ let mut paths = vec![];
+ let mut added_worktrees = vec![];
+ let opened_paths = futures::future::join_all(tasks).await;
+ for entry in opened_paths {
+ if let Some((worktree, project_path)) = entry.log_err() {
+ added_worktrees.push(worktree);
+ paths.push(project_path);
+ }
+ }
+ this.update_in(cx, |this, window, cx| {
+ this.handle_drop(paths, added_worktrees, window, cx);
+ })
+ .ok();
+ })
+ .detach();
+ }))
+ }
+
+ fn handle_drop(
+ &mut self,
+ paths: Vec,
+ added_worktrees: Vec>,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ match self.active_view() {
+ ActiveView::ExternalAgentThread { thread_view } => {
+ thread_view.update(cx, |thread_view, cx| {
+ thread_view.insert_dragged_files(paths, added_worktrees, window, cx);
+ });
+ }
+ ActiveView::TextThread {
+ text_thread_editor, ..
+ } => {
+ text_thread_editor.update(cx, |text_thread_editor, cx| {
+ TextThreadEditor::insert_dragged_files(
+ text_thread_editor,
+ paths,
+ added_worktrees,
+ window,
+ cx,
+ );
+ });
+ }
+ ActiveView::History | ActiveView::Configuration => {}
+ }
+ }
+
+ fn key_context(&self) -> KeyContext {
+ let mut key_context = KeyContext::new_with_defaults();
+ key_context.add("AgentPanel");
+ match self.active_view() {
+ ActiveView::ExternalAgentThread { .. } => key_context.add("acp_thread"),
+ ActiveView::TextThread { .. } => key_context.add("text_thread"),
+ ActiveView::History | ActiveView::Configuration => {}
+ }
+ key_context
+ }
+}
+
+// Methods to manage tabs in AgentPanel
+impl AgentPanel {
+ fn active_tab(&self) -> &AgentPanelTab {
+ self.tabs
+ .get(self.active_tab_id)
+ .unwrap_or_else(|| &self.tabs[0])
+ }
+
+ fn find_tab_by_identity(
+ &self,
+ identity: &AgentPanelTabIdentity,
+ cx: &mut Context,
+ ) -> Option {
+ for (index, tab) in self.tabs.iter().enumerate() {
+ if Self::tab_view_identity(tab.view(), cx).is_some_and(|existing| existing == *identity)
+ {
+ return Some(index);
+ }
+ }
+ None
+ }
+
+ fn set_active_tab_by_id(&mut self, new_id: TabId, window: &mut Window, cx: &mut Context) {
+ // TODO: need to check the total items in the list, if it is equal to 1, we should overlay it
+ let Some((tab_agent, text_thread_editor)) = self.tabs.get(new_id).map(|tab| {
+ let editor = match tab.view() {
+ ActiveView::TextThread {
+ text_thread_editor, ..
+ } => Some(text_thread_editor.clone()),
+ _ => None,
+ };
+ (tab.agent().clone(), editor)
+ }) else {
+ log::info!("The input new_id is not in the list views!");
+ return;
+ };
+
+ self.overlay_view = None;
+ self.overlay_previous_tab_id = None;
+ self.title_edit_overlay_tab_id = None;
+ self.active_tab_id = new_id;
+ self.tab_bar_scroll_handle.scroll_to_item(new_id);
+
+ if self.selected_agent != tab_agent {
+ self.selected_agent = tab_agent.clone();
+ self.serialize(cx);
+ }
+
+ if let Some(text_thread_editor) = text_thread_editor {
+ self.history_store.update(cx, |store, cx| {
+ if let Some(path) = text_thread_editor.read(cx).text_thread().read(cx).path() {
+ store.push_recently_opened_entry(
+ agent::HistoryEntryId::TextThread(path.clone()),
+ cx,
+ )
+ }
+ });
+ }
+
+ self.focus_handle(cx).focus(window);
+ }
+
+ fn set_tab_overlay_view(
+ &mut self,
+ view: ActiveView,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.title_edit_overlay_tab_id = None;
+ self.overlay_previous_tab_id = Some(self.active_tab_id);
+ self.overlay_view = Some(view);
+ self.focus_handle(cx).focus(window);
+ }
+
+ fn push_tab(
+ &mut self,
+ new_view: ActiveView,
+ agent: AgentType,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let tab_view_identity = Self::tab_view_identity(&new_view, cx);
+
+ if let Some(identity) = tab_view_identity.as_ref() {
+ if let Some(existing_id) = self.find_tab_by_identity(identity, cx) {
+ self.set_active_tab_by_id(existing_id, window, cx);
+ return;
+ }
+ }
+
+ match &new_view {
+ ActiveView::TextThread { .. } | ActiveView::ExternalAgentThread { .. } => {
+ self.tabs.push(AgentPanelTab::new(new_view, agent));
+ let new_id = self.tabs.len() - 1;
+ self.set_active_tab_by_id(new_id, window, cx);
+
+ if let Some(pending_id) = self.pending_tab_removal.take() {
+ // Now that we have more than one tab, try removing the deferred one.
+ if self.tabs.len() > 1 {
+ self.remove_tab_by_id(pending_id, window, cx);
+ } else {
+ self.pending_tab_removal = Some(pending_id);
+ }
+ }
+ }
+ ActiveView::History | ActiveView::Configuration => {
+ self.set_tab_overlay_view(new_view, window, cx);
+ }
+ }
+ }
+
+ fn remove_tab_by_id(&mut self, id: TabId, window: &mut Window, cx: &mut Context) {
+ // Guardrail - ensure we have at least one item in the list
+ if self.tabs.len() == 1 {
+ if self.loading && self.tabs.get(id).is_some() {
+ self.pending_tab_removal = Some(id);
+ log::info!(
+ "Deferring removal of tab {id} until another tab is available (panel loading)."
+ );
+ } else {
+ log::info!("Failed to remove the tab! The tabs list only has one item left.");
+ }
+ return;
+ }
+
+ if self.tabs.get(id).is_some() {
+ let removed_id = id;
+ self.tabs.remove(removed_id);
+ let new_id = if self.active_tab_id == removed_id {
+ removed_id.min(self.tabs.len() - 1)
+ } else if self.active_tab_id > removed_id {
+ self.active_tab_id - 1
+ } else {
+ self.active_tab_id
+ };
+
+ if let Some(edit_id) = self.title_edit_overlay_tab_id {
+ if edit_id == removed_id {
+ self.title_edit_overlay_tab_id = None;
+ } else if edit_id > removed_id {
+ self.title_edit_overlay_tab_id = Some(edit_id - 1);
+ }
+ }
+
+ if new_id == self.active_tab_id {
+ self.tab_bar_scroll_handle.scroll_to_item(new_id);
+ } else {
+ self.set_active_tab_by_id(new_id, window, cx);
+ }
+ } else {
+ log::info!("View id is not valid.");
+ }
+ }
+
+ fn display_tab_label(
+ title: impl Into,
+ is_active: bool,
+ ) -> (SharedString, Option) {
+ const MAX_CHARS: usize = 20;
+
+ let title: SharedString = title.into();
+
+ if is_active || title.chars().count() <= MAX_CHARS {
+ (title, None)
+ } else {
+ let preview: String = title.chars().take(MAX_CHARS).collect();
+ (format!("{preview}...").into(), Some(title))
+ }
+ }
+
+ fn tab_view_identity(
+ view: &ActiveView,
+ cx: &mut Context,
+ ) -> Option {
+ match view {
+ ActiveView::ExternalAgentThread { thread_view, .. } => thread_view
+ .read(cx)
+ .session_id(cx)
+ .map(AgentPanelTabIdentity::AcpThread),
+ ActiveView::TextThread {
+ text_thread_editor, ..
+ } => {
+ let text_thread = {
+ let editor = text_thread_editor.read(cx);
+ editor.text_thread().clone()
+ };
+ text_thread
+ .read(cx)
+ .path()
+ .cloned()
+ .map(AgentPanelTabIdentity::TextThread)
+ }
+ ActiveView::History | ActiveView::Configuration => None,
+ }
+ }
+
+ fn render_tab_label(
+ &self,
+ view: &ActiveView,
+ is_active: bool,
+ cx: &mut Context,
+ ) -> TabLabelRender {
+ match view {
+ ActiveView::ExternalAgentThread { thread_view } => {
+ let text = thread_view
+ .read(cx)
+ .title_editor()
+ .as_ref()
+ .map(|editor| editor.read(cx).text(cx))
+ .filter(|text| !text.is_empty())
+ .unwrap_or_else(|| thread_view.read(cx).title(cx).to_string().into());
+
+ let (label_text, tooltip) = Self::display_tab_label(text, is_active);
+
+ let is_generating = thread_view
+ .read(cx)
+ .thread()
+ .map(|thread| thread.read(cx).status() == ThreadStatus::Generating)
+ .unwrap_or(false);
+
+ let label = if is_generating {
+ Label::new(label_text)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .with_animation(
+ "pulsating-tab-label",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ )
+ .into_any_element()
+ } else {
+ Label::new(label_text)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .into_any_element()
+ };
+
+ TabLabelRender {
+ element: label,
+ tooltip,
+ }
+ }
+ ActiveView::TextThread {
+ title_editor,
+ text_thread_editor,
+ ..
+ } => {
+ let summary = text_thread_editor.read(cx).text_thread().read(cx).summary();
+
+ let is_generating = text_thread_editor
+ .read(cx)
+ .text_thread()
+ .read(cx)
+ .messages(cx)
+ .any(|message| message.status == assistant_text_thread::MessageStatus::Pending);
+
+ match summary {
+ TextThreadSummary::Pending => {
+ let label = if is_generating {
+ Label::new(TextThreadSummary::DEFAULT)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .with_animation(
+ "pulsating-tab-label",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ )
+ .into_any_element()
+ } else {
+ Label::new(TextThreadSummary::DEFAULT)
+ .color(Color::Muted)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .into_any_element()
+ };
+
+ TabLabelRender {
+ element: label,
+ tooltip: None,
+ }
+ }
+ TextThreadSummary::Content(summary) => {
+ if summary.done {
+ let mut text = title_editor.read(cx).text(cx);
+ if text.is_empty() {
+ text = summary.text.clone().into();
+ }
+ let (label_text, tooltip) = Self::display_tab_label(text, is_active);
+
+ let label = if is_generating {
+ Label::new(label_text)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .with_animation(
+ "pulsating-tab-label",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ )
+ .into_any_element()
+ } else {
+ Label::new(label_text)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .into_any_element()
+ };
+
+ TabLabelRender {
+ element: label,
+ tooltip,
+ }
+ } else {
+ TabLabelRender {
+ element: Label::new(LOADING_SUMMARY_PLACEHOLDER)
+ .truncate()
+ .color(Color::Muted)
+ .into_any_element(),
+ tooltip: None,
+ }
+ }
+ }
+ TextThreadSummary::Error => {
+ let text = title_editor.read(cx).text(cx);
+ let (label_text, tooltip) = Self::display_tab_label(text, is_active);
+
+ let label = if is_generating {
+ Label::new(label_text)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .with_animation(
+ "pulsating-tab-label",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |label, delta| label.alpha(delta),
+ )
+ .into_any_element()
+ } else {
+ Label::new(label_text)
+ .truncate()
+ .when(!is_active, |label| label.color(Color::Muted))
+ .into_any_element()
+ };
+
+ TabLabelRender {
+ element: label,
+ tooltip,
+ }
+ }
+ }
+ }
+ ActiveView::History => TabLabelRender {
+ element: Label::new("History").truncate().into_any_element(),
+ tooltip: None,
+ },
+ ActiveView::Configuration => TabLabelRender {
+ element: Label::new("Settings").truncate().into_any_element(),
+ tooltip: None,
+ },
+ }
+ }
+
+ fn render_tab_agent_icon(
+ &self,
+ index: usize,
+ agent: &AgentType,
+ agent_server_store: &Entity,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let agent_label = agent.label();
+ let tooltip_title = "Selected Agent";
+ let agent_custom_icon = if let AgentType::Custom { name, .. } = agent {
+ agent_server_store
+ .read(cx)
+ .agent_icon(&ExternalAgentServerName(name.clone()))
+ } else {
+ None
+ };
+
+ let has_custom_icon = agent_custom_icon.is_some();
+ div()
+ .id(("agent-tab-agent-icon", index))
+ .when_some(agent_custom_icon, |this, icon_path| {
+ let label = agent_label.clone();
+ this.px(DynamicSpacing::Base02.rems(cx))
+ .child(Icon::from_path(icon_path).color(Color::Muted))
+ .tooltip(move |_window, cx| {
+ Tooltip::with_meta(label.clone(), None, tooltip_title, cx)
+ })
+ })
+ .when(!has_custom_icon, |this| {
+ this.when_some(agent.icon(), |this, icon| {
+ let label = agent_label.clone();
+ this.px(DynamicSpacing::Base02.rems(cx))
+ .child(Icon::new(icon).color(Color::Muted))
+ .tooltip(move |_window, cx| {
+ Tooltip::with_meta(label.clone(), None, tooltip_title, cx)
+ })
+ })
+ })
+ .into_any_element()
+ }
+
+ fn render_tab_bar(&self, window: &mut Window, cx: &mut Context) -> AnyElement {
+ let agent_server_store = self.project.read(cx).agent_server_store().clone();
+ let focus_handle = self.focus_handle(cx);
+
+ let active_thread = match self.active_view() {
+ ActiveView::ExternalAgentThread { thread_view } => {
+ thread_view.read(cx).as_native_thread(cx)
+ }
+ ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None,
+ };
+
+ let new_thread_menu_store = agent_server_store.clone();
+ let new_thread_menu = PopoverMenu::new("new_thread_menu")
+ .trigger_with_tooltip(
+ IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
+ {
+ let focus_handle = focus_handle.clone();
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ "New Thread…",
+ &ToggleNewThreadMenu,
+ &focus_handle,
+ cx,
+ )
+ }
+ },
+ )
+ .anchor(Corner::TopRight)
+ .with_handle(self.new_thread_menu_handle.clone())
+ .menu({
+ let workspace = self.workspace.clone();
+ let is_via_collab = workspace
+ .update(cx, |workspace, cx| {
+ workspace.project().read(cx).is_via_collab()
+ })
+ .unwrap_or_default();
+ let agent_server_store = new_thread_menu_store.clone();
+
+ move |window, cx| {
+ telemetry::event!("New Thread Clicked");
+
+ let active_thread = active_thread.clone();
+ Some(ContextMenu::build(window, cx, |menu, _window, cx| {
+ menu.context(focus_handle.clone())
+ .header("Zed Agent")
+ .when_some(active_thread, |this, active_thread| {
+ let thread = active_thread.read(cx);
+
+ if !thread.is_empty() {
+ let session_id = thread.id().clone();
+ this.item(
+ ContextMenuEntry::new("New From Summary")
+ .icon(IconName::ThreadFromSummary)
+ .icon_color(Color::Muted)
+ .handler(move |window, cx| {
+ window.dispatch_action(
+ Box::new(NewNativeAgentThreadFromSummary {
+ from_session_id: session_id.clone(),
+ }),
+ cx,
+ );
+ }),
+ )
+ } else {
+ this
+ }
+ })
+ .item(
+ ContextMenuEntry::new("New Thread")
+ .action(NewThread.boxed_clone())
+ .icon(IconName::Thread)
+ .icon_color(Color::Muted)
+ .handler({
+ let workspace = workspace.clone();
+ move |window, cx| {
+ if let Some(workspace) = workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) =
+ workspace.panel::(cx)
+ {
+ panel.update(cx, |panel, cx| {
panel.new_agent_thread(
AgentType::NativeAgent,
window,
@@ -2143,471 +2938,162 @@ impl AgentPanel {
if let Some(icon_path) = icon_path {
entry = entry.custom_icon_svg(icon_path);
} else {
- entry = entry.icon(IconName::Terminal);
- }
- entry = entry
- .icon_color(Color::Muted)
- .disabled(is_via_collab)
- .handler({
- let workspace = workspace.clone();
- let agent_name = agent_name.clone();
- let custom_settings = custom_settings.clone();
- move |window, cx| {
- if let Some(workspace) = workspace.upgrade() {
- workspace.update(cx, |workspace, cx| {
- if let Some(panel) =
- workspace.panel::(cx)
- {
- panel.update(cx, |panel, cx| {
- panel.new_agent_thread(
- AgentType::Custom {
- name: agent_name
- .clone()
- .into(),
- command: custom_settings
- .get(&agent_name.0)
- .map(|settings| {
- settings
- .command
- .clone()
- })
- .unwrap_or(
- placeholder_command(
- ),
- ),
- },
- window,
- cx,
- );
- });
- }
- });
- }
- }
- });
- menu = menu.item(entry);
- }
-
- menu
- })
- .separator()
- .item(
- ContextMenuEntry::new("Add More Agents")
- .icon(IconName::Plus)
- .icon_color(Color::Muted)
- .handler({
- move |window, cx| {
- window.dispatch_action(Box::new(zed_actions::Extensions {
- category_filter: Some(
- zed_actions::ExtensionCategoryFilter::AgentServers,
- ),
- id: None,
- }), cx)
- }
- }),
- )
- }))
- }
- });
-
- let selected_agent_label = self.selected_agent.label();
-
- let has_custom_icon = selected_agent_custom_icon.is_some();
- let selected_agent = div()
- .id("selected_agent_icon")
- .when_some(selected_agent_custom_icon, |this, icon_path| {
- let label = selected_agent_label.clone();
- this.px(DynamicSpacing::Base02.rems(cx))
- .child(Icon::from_external_svg(icon_path).color(Color::Muted))
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
- })
- })
- .when(!has_custom_icon, |this| {
- this.when_some(self.selected_agent.icon(), |this, icon| {
- let label = selected_agent_label.clone();
- this.px(DynamicSpacing::Base02.rems(cx))
- .child(Icon::new(icon).color(Color::Muted))
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
- })
- })
- })
- .into_any_element();
-
- h_flex()
- .id("agent-panel-toolbar")
- .h(Tab::container_height(cx))
- .max_w_full()
- .flex_none()
- .justify_between()
- .gap_2()
- .bg(cx.theme().colors().tab_bar_background)
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(
- h_flex()
- .size_full()
- .gap(DynamicSpacing::Base04.rems(cx))
- .pl(DynamicSpacing::Base04.rems(cx))
- .child(match &self.active_view {
- ActiveView::History | ActiveView::Configuration => {
- self.render_toolbar_back_button(cx).into_any_element()
- }
- _ => selected_agent.into_any_element(),
- })
- .child(self.render_title_view(window, cx)),
- )
- .child(
- h_flex()
- .flex_none()
- .gap(DynamicSpacing::Base02.rems(cx))
- .pl(DynamicSpacing::Base04.rems(cx))
- .pr(DynamicSpacing::Base06.rems(cx))
- .child(new_thread_menu)
- .child(self.render_recent_entries_menu(
- IconName::MenuAltTemp,
- Corner::TopRight,
- cx,
- ))
- .child(self.render_panel_options_menu(window, cx)),
- )
- }
-
- fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool {
- if TrialEndUpsell::dismissed() {
- return false;
- }
-
- match &self.active_view {
- ActiveView::TextThread { .. } => {
- if LanguageModelRegistry::global(cx)
- .read(cx)
- .default_model()
- .is_some_and(|model| {
- model.provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
- })
- {
- return false;
- }
- }
- ActiveView::ExternalAgentThread { .. }
- | ActiveView::History
- | ActiveView::Configuration => return false,
- }
-
- let plan = self.user_store.read(cx).plan();
- let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
-
- matches!(
- plan,
- Some(Plan::V1(PlanV1::ZedFree) | Plan::V2(PlanV2::ZedFree))
- ) && has_previous_trial
- }
-
- fn should_render_onboarding(&self, cx: &mut Context) -> bool {
- if OnboardingUpsell::dismissed() {
- return false;
- }
-
- let user_store = self.user_store.read(cx);
-
- if user_store
- .plan()
- .is_some_and(|plan| matches!(plan, Plan::V1(PlanV1::ZedPro) | Plan::V2(PlanV2::ZedPro)))
- && user_store
- .subscription_period()
- .and_then(|period| period.0.checked_add_days(chrono::Days::new(1)))
- .is_some_and(|date| date < chrono::Utc::now())
- {
- OnboardingUpsell::set_dismissed(true, cx);
- return false;
- }
-
- match &self.active_view {
- ActiveView::History | ActiveView::Configuration => false,
- ActiveView::ExternalAgentThread { thread_view, .. }
- if thread_view.read(cx).as_native_thread(cx).is_none() =>
- {
- false
- }
- _ => {
- let history_is_empty = self.history_store.read(cx).is_empty(cx);
-
- let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
- .providers()
- .iter()
- .any(|provider| {
- provider.is_authenticated(cx)
- && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
- });
-
- history_is_empty || !has_configured_non_zed_providers
- }
- }
- }
-
- fn render_onboarding(
- &self,
- _window: &mut Window,
- cx: &mut Context,
- ) -> Option {
- if !self.should_render_onboarding(cx) {
- return None;
- }
-
- let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. });
-
- Some(
- div()
- .when(text_thread_view, |this| {
- this.bg(cx.theme().colors().editor_background)
- })
- .child(self.onboarding.clone()),
- )
- }
-
- fn render_trial_end_upsell(
- &self,
- _window: &mut Window,
- cx: &mut Context,
- ) -> Option {
- if !self.should_render_trial_end_upsell(cx) {
- return None;
- }
-
- let plan = self.user_store.read(cx).plan()?;
-
- Some(
- v_flex()
- .absolute()
- .inset_0()
- .size_full()
- .bg(cx.theme().colors().panel_background)
- .opacity(0.85)
- .block_mouse_except_scroll()
- .child(EndTrialUpsell::new(
- plan,
- Arc::new({
- let this = cx.entity();
- move |_, cx| {
- this.update(cx, |_this, cx| {
- TrialEndUpsell::set_dismissed(true, cx);
- cx.notify();
- });
- }
- }),
- )),
- )
- }
-
- fn render_configuration_error(
- &self,
- border_bottom: bool,
- configuration_error: &ConfigurationError,
- focus_handle: &FocusHandle,
- cx: &mut App,
- ) -> impl IntoElement {
- let zed_provider_configured = AgentSettings::get_global(cx)
- .default_model
- .as_ref()
- .is_some_and(|selection| selection.provider.0.as_str() == "zed.dev");
+ entry = entry.icon(IconName::Terminal);
+ }
+ entry = entry
+ .icon_color(Color::Muted)
+ .disabled(is_via_collab)
+ .handler({
+ let workspace = workspace.clone();
+ let agent_name = agent_name.clone();
+ let custom_settings = custom_settings.clone();
+ move |window, cx| {
+ if let Some(workspace) = workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) =
+ workspace.panel::(cx)
+ {
+ panel.update(cx, |panel, cx| {
+ panel.new_agent_thread(
+ AgentType::Custom {
+ name: agent_name
+ .clone()
+ .into(),
+ command: custom_settings
+ .get(&agent_name.0)
+ .map(|settings| {
+ settings
+ .command
+ .clone()
+ })
+ .unwrap_or(
+ placeholder_command(
+ ),
+ ),
+ },
+ window,
+ cx,
+ );
+ });
+ }
+ });
+ }
+ }
+ });
+ menu = menu.item(entry);
+ }
- let callout = if zed_provider_configured {
- Callout::new()
- .icon(IconName::Warning)
- .severity(Severity::Warning)
- .when(border_bottom, |this| {
- this.border_position(ui::BorderPosition::Bottom)
- })
- .title("Sign in to continue using Zed as your LLM provider.")
- .actions_slot(
- Button::new("sign_in", "Sign In")
- .style(ButtonStyle::Tinted(ui::TintColor::Warning))
- .label_size(LabelSize::Small)
- .on_click({
- let workspace = self.workspace.clone();
- move |_, _, cx| {
- let Ok(client) =
- workspace.update(cx, |workspace, _| workspace.client().clone())
- else {
- return;
- };
+ menu
+ })
+ .separator()
+ .item(
+ ContextMenuEntry::new("Add More Agents")
+ .icon(IconName::Plus)
+ .icon_color(Color::Muted)
+ .handler({
+ move |window, cx| {
+ window.dispatch_action(Box::new(zed_actions::Extensions {
+ category_filter: Some(
+ zed_actions::ExtensionCategoryFilter::AgentServers,
+ ),
+ id: None,
+ }), cx)
+ }
+ }),
+ )
+ }))
+ }
+ });
- cx.spawn(async move |cx| {
- client.sign_in_with_optional_connect(true, cx).await
- })
- .detach_and_log_err(cx);
- }
- }),
- )
- } else {
- Callout::new()
- .icon(IconName::Warning)
- .severity(Severity::Warning)
- .when(border_bottom, |this| {
- this.border_position(ui::BorderPosition::Bottom)
- })
- .title(configuration_error.to_string())
- .actions_slot(
- Button::new("settings", "Configure")
- .style(ButtonStyle::Tinted(ui::TintColor::Warning))
- .label_size(LabelSize::Small)
- .key_binding(
- KeyBinding::for_action_in(&OpenSettings, focus_handle, cx)
- .map(|kb| kb.size(rems_from_px(12.))),
- )
- .on_click(|_event, window, cx| {
- window.dispatch_action(OpenSettings.boxed_clone(), cx)
- }),
- )
- };
+ let end_slot = h_flex()
+ .gap(DynamicSpacing::Base02.rems(cx))
+ .pl(DynamicSpacing::Base04.rems(cx))
+ .pr(DynamicSpacing::Base06.rems(cx))
+ .child(new_thread_menu)
+ .child(self.render_recent_entries_menu(IconName::MenuAltTemp, Corner::TopRight, cx))
+ .child(self.render_panel_options_menu(window, cx));
+
+ let mut tab_bar = TabBar::new("agent-tab-bar")
+ .track_scroll(self.tab_bar_scroll_handle.clone())
+ .end_child(end_slot);
+
+ if let Some(overlay_view) = &self.overlay_view {
+ let TabLabelRender {
+ element: overlay_label,
+ ..
+ } = self.render_tab_label(&overlay_view, true, cx);
+
+ let overlay_title = h_flex()
+ .flex_grow()
+ .h(Tab::content_height(cx))
+ .px(DynamicSpacing::Base04.px(cx))
+ .gap(DynamicSpacing::Base04.rems(cx))
+ .bg(cx.theme().colors().tab_bar_background)
+ .child(self.render_toolbar_back_button(cx).into_any_element())
+ .child(overlay_label)
+ .into_any_element();
+
+ return tab_bar.child(overlay_title).into_any_element();
+ }
- match configuration_error {
- ConfigurationError::ModelNotFound
- | ConfigurationError::ProviderNotAuthenticated(_)
- | ConfigurationError::NoProvider => callout.into_any_element(),
+ if let Some(overlay_editor) = self.render_overlay_title_editor(cx) {
+ return tab_bar.child(overlay_editor).into_any_element();
}
- }
- fn render_text_thread(
- &self,
- text_thread_editor: &Entity,
- buffer_search_bar: &Entity,
- window: &mut Window,
- cx: &mut Context,
- ) -> Div {
- let mut registrar = buffer_search::DivRegistrar::new(
- |this, _, _cx| match &this.active_view {
- ActiveView::TextThread {
- buffer_search_bar, ..
- } => Some(buffer_search_bar.clone()),
- _ => None,
- },
- cx,
- );
- BufferSearchBar::register(&mut registrar);
- registrar
- .into_div()
- .size_full()
- .relative()
- .map(|parent| {
- buffer_search_bar.update(cx, |buffer_search_bar, cx| {
- if buffer_search_bar.is_dismissed() {
- return parent;
- }
- parent.child(
- div()
- .p(DynamicSpacing::Base08.rems(cx))
- .border_b_1()
- .border_color(cx.theme().colors().border_variant)
- .bg(cx.theme().colors().editor_background)
- .child(buffer_search_bar.render(window, cx)),
- )
- })
- })
- .child(text_thread_editor.clone())
- .child(self.render_drag_target(cx))
- }
+ let active_index = self.active_tab_id;
+ for (index, tab) in self.tabs.iter().enumerate() {
+ let is_active = index == active_index;
+ let position = if index == 0 {
+ TabPosition::First
+ } else if index == self.tabs.len() - 1 {
+ TabPosition::Last
+ } else {
+ let ordering = if index < active_index {
+ Ordering::Less
+ } else if index > active_index {
+ Ordering::Greater
+ } else {
+ Ordering::Equal
+ };
+ TabPosition::Middle(ordering)
+ };
- fn render_drag_target(&self, cx: &Context) -> Div {
- let is_local = self.project.read(cx).is_local();
- div()
- .invisible()
- .absolute()
- .top_0()
- .right_0()
- .bottom_0()
- .left_0()
- .bg(cx.theme().colors().drop_target_background)
- .drag_over::(|this, _, _, _| this.visible())
- .drag_over::(|this, _, _, _| this.visible())
- .when(is_local, |this| {
- this.drag_over::(|this, _, _, _| this.visible())
- })
- .on_drop(cx.listener(move |this, tab: &DraggedTab, window, cx| {
- let item = tab.pane.read(cx).item_for_index(tab.ix);
- let project_paths = item
- .and_then(|item| item.project_path(cx))
- .into_iter()
- .collect::>();
- this.handle_drop(project_paths, vec![], window, cx);
- }))
- .on_drop(
- cx.listener(move |this, selection: &DraggedSelection, window, cx| {
- let project_paths = selection
- .items()
- .filter_map(|item| this.project.read(cx).path_for_entry(item.entry_id, cx))
- .collect::>();
- this.handle_drop(project_paths, vec![], window, cx);
- }),
- )
- .on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
- let tasks = paths
- .paths()
- .iter()
- .map(|path| {
- Workspace::project_path_for_path(this.project.clone(), path, false, cx)
- })
- .collect::>();
- cx.spawn_in(window, async move |this, cx| {
- let mut paths = vec![];
- let mut added_worktrees = vec![];
- let opened_paths = futures::future::join_all(tasks).await;
- for entry in opened_paths {
- if let Some((worktree, project_path)) = entry.log_err() {
- added_worktrees.push(worktree);
- paths.push(project_path);
- }
+ let TabLabelRender {
+ element: tab_label,
+ tooltip,
+ } = self.render_tab_label(tab.view(), is_active, cx);
+
+ let mut tab_component = Tab::new(("agent-tab", index))
+ .position(position)
+ .close_side(TabCloseSide::End)
+ .toggle_state(is_active)
+ .on_click(cx.listener(move |this: &mut Self, _, window, cx| {
+ if is_active {
+ this.focus_title_editor(window, cx);
+ } else {
+ this.set_active_tab_by_id(index, window, cx);
}
- this.update_in(cx, |this, window, cx| {
- this.handle_drop(paths, added_worktrees, window, cx);
- })
- .ok();
- })
- .detach();
- }))
- }
-
- fn handle_drop(
- &mut self,
- paths: Vec,
- added_worktrees: Vec>,
- window: &mut Window,
- cx: &mut Context,
- ) {
- match &self.active_view {
- ActiveView::ExternalAgentThread { thread_view } => {
- thread_view.update(cx, |thread_view, cx| {
- thread_view.insert_dragged_files(paths, added_worktrees, window, cx);
- });
- }
- ActiveView::TextThread {
- text_thread_editor, ..
- } => {
- text_thread_editor.update(cx, |text_thread_editor, cx| {
- TextThreadEditor::insert_dragged_files(
- text_thread_editor,
- paths,
- added_worktrees,
- window,
- cx,
- );
- });
+ }))
+ .child(tab_label)
+ .start_slot(self.render_tab_agent_icon(index, tab.agent(), &agent_server_store, cx))
+ .end_slot(
+ IconButton::new(("close-agent-tab", index), IconName::Close)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ .visible_on_hover("")
+ .on_click(cx.listener(move |this: &mut Self, _, window, cx| {
+ this.remove_tab_by_id(index, window, cx);
+ }))
+ .tooltip(|_window, cx| cx.new(|_| Tooltip::new("Close Thread")).into()),
+ );
+
+ if let Some(tooltip_text) = tooltip {
+ tab_component = tab_component.tooltip(Tooltip::text(tooltip_text));
}
- ActiveView::History | ActiveView::Configuration => {}
+ tab_bar = tab_bar.child(tab_component);
}
- }
- fn key_context(&self) -> KeyContext {
- let mut key_context = KeyContext::new_with_defaults();
- key_context.add("AgentPanel");
- match &self.active_view {
- ActiveView::ExternalAgentThread { .. } => key_context.add("acp_thread"),
- ActiveView::TextThread { .. } => key_context.add("text_thread"),
- ActiveView::History | ActiveView::Configuration => {}
- }
- key_context
+ tab_bar.into_any_element()
}
}
@@ -2627,6 +3113,7 @@ impl Render for AgentPanel {
.size_full()
.justify_between()
.key_context(self.key_context())
+ .track_focus(&self.panel_focus_handle)
.on_action(cx.listener(|this, action: &NewThread, window, cx| {
this.new_thread(action, window, cx);
}))
@@ -2641,6 +3128,9 @@ impl Render for AgentPanel {
.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(|this, _: &CloseActiveThreadTab, window, cx| {
+ this.remove_tab_by_id(this.active_tab_id, window, cx);
+ }))
.on_action(cx.listener(Self::increase_font_size))
.on_action(cx.listener(Self::decrease_font_size))
.on_action(cx.listener(Self::reset_font_size))
@@ -2650,9 +3140,9 @@ impl Render for AgentPanel {
thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx))
}
}))
- .child(self.render_toolbar(window, cx))
+ .child(self.render_tab_bar(window, cx))
.children(self.render_onboarding(window, cx))
- .map(|parent| match &self.active_view {
+ .map(|parent| match self.active_view() {
ActiveView::ExternalAgentThread { thread_view, .. } => parent
.child(thread_view.clone())
.child(self.render_drag_target(cx)),
@@ -2691,7 +3181,7 @@ impl Render for AgentPanel {
})
.children(self.render_trial_end_upsell(window, cx));
- match self.active_view.which_font_size_used() {
+ match self.active_view().which_font_size_used() {
WhichFontSize::AgentFont => {
WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
.size_full()
diff --git a/crates/agent_ui/src/agent_panel_tab.rs b/crates/agent_ui/src/agent_panel_tab.rs
new file mode 100644
index 00000000000000..6f2c03c5ffd7a1
--- /dev/null
+++ b/crates/agent_ui/src/agent_panel_tab.rs
@@ -0,0 +1,38 @@
+use std::path::Path;
+use std::sync::Arc;
+
+use crate::agent_panel::{ActiveView, AgentType};
+use agent_client_protocol as acp;
+use gpui::{AnyElement, SharedString};
+
+pub type TabId = usize;
+
+pub struct AgentPanelTab {
+ pub view: ActiveView,
+ pub agent: AgentType,
+}
+
+impl AgentPanelTab {
+ pub fn new(view: ActiveView, agent: AgentType) -> Self {
+ Self { view, agent }
+ }
+
+ pub fn view(&self) -> &ActiveView {
+ &self.view
+ }
+
+ pub fn agent(&self) -> &AgentType {
+ &self.agent
+ }
+}
+
+pub struct TabLabelRender {
+ pub element: AnyElement,
+ pub tooltip: Option,
+}
+
+#[derive(Clone, PartialEq, Eq)]
+pub enum AgentPanelTabIdentity {
+ AcpThread(acp::SessionId),
+ TextThread(Arc),
+}
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 781374f117d24b..059c731bf52b2e 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -3,6 +3,7 @@ mod agent_configuration;
mod agent_diff;
mod agent_model_selector;
mod agent_panel;
+mod agent_panel_tab;
mod buffer_codegen;
mod context;
mod context_picker;
@@ -78,6 +79,8 @@ actions!(
AddContextServer,
/// Removes the currently selected thread.
RemoveSelectedThread,
+ /// Closes the currently active thread tab.
+ CloseActiveThreadTab,
/// Starts a chat conversation with follow-up enabled.
ChatWithFollow,
/// Cycles to the next inline assist suggestion.