//! The chat header, as T3 Code lays it out: the project as a "New thread in" //! breadcrumb, then the thread title, which opens the thread action menu //! (double-click renames), and the panel toggles on the right. use std::time::Duration; use gpui_kit::component::{Icon, IconName, Selectable as _, h_flex, input::Input, tooltip::Tooltip, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::Workspace; use super::surfaces::Surface; use crate::Dismiss; use crate::ui::browser::BrowserWatch; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _}; /// How long a title click waits for a second click before opening the menu. const DOUBLE_CLICK: Duration = Duration::from_millis(500); /// The sidebar toggle: in the sidebar's chrome row while it's open, at the /// header's start while it's closed. pub(super) fn sidebar_toggle(open: bool, cx: &mut Context) -> MetalButton { MetalButton::new(if open { "hide-sidebar" } else { "show-sidebar" }) .ghost() .mini() .icon(if open { Lucide::PanelLeftClose } else { Lucide::PanelLeft }) .tooltip("Toggle main sidebar (Ctrl+B)") .on_click(cx.listener(|this, _, _, cx| { this.sidebar_open = !this.sidebar_open; cx.notify(); })) } impl Workspace { /// Opens the thread action menu under the title. fn open_title_menu(&mut self, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.clone().filter(|session| !session.read(cx).is_blank()) else { return }; let bounds = self.title_bounds.get(); let position = point(bounds.origin.x, bounds.origin.y + bounds.size.height + px(4.)); let workspace = cx.weak_entity(); self.open_menu(position, window, cx, move |menu, window, cx| { Workspace::thread_action_menu(workspace, session, false, menu, window, cx) }); } /// A right click anywhere in the header: the thread menu at the pointer, /// or just Project settings for a thread nothing was sent in yet. fn open_header_menu(&mut self, position: Point, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.clone() else { return }; let workspace = cx.weak_entity(); if session.read(cx).is_blank() { let Some(project) = self.project_of(&session) else { return }; self.open_menu(position, window, cx, move |menu, _, _| { menu.item( gpui_kit::component::menu::PopupMenuItem::new("Project settings") .icon(Icon::new(IconName::Settings)) .on_click(move |_, window, cx| { let _ = workspace.update(cx, |this, cx| this.open_project_settings(project, window, cx)); }), ) }); return; } self.open_menu(position, window, cx, move |menu, window, cx| { Workspace::thread_action_menu(workspace, session, false, menu, window, cx) }); } /// A click on the title: a double click renames; a single one opens the /// menu once it's clear no second click follows. fn click_title(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context) { let modified = event.modifiers().modified(); if event.click_count() >= 2 && !modified { self.title_click = Task::ready(()); if let Some(session) = self.active.clone() { self.overlay_menu = None; self.start_rename(session, false, window, cx); } return; } if event.is_keyboard() { self.open_title_menu(window, cx); return; } self.title_click = cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(DOUBLE_CLICK).await; let _ = this.update_in(cx, |this, window, cx| this.open_title_menu(window, cx)); }); } pub(super) fn render_top_bar(&self, panel_visible: bool, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let project = self.active_project().filter(|_| self.active.is_some()); let project_ix = self.active_project; let active = self.active.as_ref().map(|session| session.read(cx)); let title = active.map(|session| session.title.clone()); let draft = active.is_none_or(|session| session.is_blank()); let renaming = self.renaming.as_ref().filter(|rename| !rename.sidebar && self.active.as_ref() == Some(&rename.session)); let bounds = self.title_bounds.clone(); let breadcrumb = project.filter(|project| Some(&project.path) != super::scratch_dir().as_ref()).map(|project| { let tip = SharedString::from(format!("New thread in {}", project.name)); h_flex() .id("project-breadcrumb") .flex_none() .gap(px(8.)) .cursor_pointer() .text_size(px(14.)) .text_color(p.ink3) .hover(|style| style.text_color(p.ink)) .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) .on_click(cx.listener(move |this, _, window, cx| this.new_thread(project_ix, window, cx))) .child(metal::badge(project.initials.clone(), project.tint, p)) .child(div().max_w(px(160.)).truncate().font_weight(FontWeight::MEDIUM).child(project.name.clone())) }); // A subagent's thread leads back to the thread that delegated its task. let parent = active .and_then(|session| session.delegation.as_ref()) .and_then(|delegation| self.local_thread(&delegation.parent, cx)) .map(|(parent_ix, parent)| { let parent_title = parent.read(cx).title.clone(); let tip = SharedString::from(format!("Subagent of {parent_title}")); h_flex() .id("parent-thread") .flex_none() .gap(px(6.)) .cursor_pointer() .text_size(px(14.)) .text_color(p.ink3) .hover(|style| style.text_color(p.ink)) .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) .on_click(cx.listener(move |this, _, window, cx| this.select(parent_ix, parent.clone(), window, cx))) .child(div().max_w(px(200.)).truncate().child(parent_title)) .child(div().text_color(p.icon).child("/")) .child(Icon::new(Lucide::Bot).size(px(14.))) }); let title_element = title.map(|title| -> AnyElement { if let Some(rename) = renaming { return div() .key_context("InlineRename") .on_action(cx.listener(|this, _: &Dismiss, _, cx| this.cancel_rename(cx))) .flex_1() .min_w_0() .max_w(px(480.)) .h(px(28.)) .px(px(8.)) .flex() .items_center() .rounded(px(8.)) .well(p) .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .child(Input::new(&rename.input).appearance(false)) .into_any_element(); } if draft { return div() .min_w_0() .truncate() .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(title) .into_any_element(); } let tip = title.clone(); h_flex() .id("thread-title") .group("thread-title") .relative() .min_w_0() .gap(px(4.)) .px(px(6.)) .h(px(28.)) .rounded(px(8.)) .cursor_pointer() .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .hover(|style| style.bg(p.row_hover)) .when(self.overlay_menu.is_none(), |this| { this.tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) }) .on_click(cx.listener(|this, event: &ClickEvent, window, cx| this.click_title(event, window, cx))) .child(div().min_w_0().truncate().child(title)) .child( div() .id("thread-title-chevron") .invisible() .group_hover("thread-title", |style| style.visible()) .on_click(cx.listener(|this, _, window, cx| { cx.stop_propagation(); this.title_click = Task::ready(()); this.open_title_menu(window, cx); })) .child(Icon::new(IconName::ChevronDown).size(px(14.)).text_color(p.ink3)), ) .child( canvas(move |element_bounds, _, _| bounds.set(element_bounds), |_, _, _, _| {}) .absolute() .inset_0(), ) .into_any_element() }); v_flex() .flex_none() .child( h_flex() .id("chat-header") .h(px(52.)) .pl(px(if self.sidebar_open { 20. } else { 12. })) .pr(px(12.)) .gap(px(8.)) .on_mouse_down( MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| { this.open_header_menu(event.position, window, cx) }), ) .when(!self.sidebar_open, |this| this.child(sidebar_toggle(false, cx))) .when_some(breadcrumb, |this, breadcrumb| { this.child(breadcrumb).child(div().text_size(px(14.)).text_color(p.icon).child("/")) }) .when_some(parent, |this, parent| this.child(parent)) .when_some(title_element, |this, title| this.child(title)) .child(div().flex_1()) .child(self.render_layout_controls(panel_visible, cx)), ) .child(metal::rule(p)) } /// T3's panel toggles: thread details (our project panel), the terminal /// drawer, and the right panel (the browser), plus the subagents panel. fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); // While the panel shows something else, its light says subagents are // working, or the agent is using the browser. let subagents_working = !self.surface_open(Surface::Subagents) && self.working_subagents(cx) > 0; let browser_live = !self.surface_open(Surface::Browser) && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx)); let live = subagents_working || browser_live; h_flex() .flex_none() .gap(px(4.)) .child( MetalButton::new("toggle-thread-details") .ghost() .mini() .icon(Lucide::SquareMenu) .selected(panel_visible) .tooltip("Toggle thread details (Ctrl+Shift+B)") .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))), ) .child(if self.terminal_available(cx) { MetalButton::new("toggle-terminal-drawer") .ghost() .mini() .icon(IconName::PanelBottom) .selected(self.terminal_open() || self.surface_open(Surface::Terminal)) .tooltip("Toggle terminal drawer (Ctrl+J)") .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx))) .into_any_element() } else { // A thread on another device has no folder here to start in. div() .id("terminal-drawer-unavailable") .tooltip(|window, cx| Tooltip::new("Terminal drawer is unavailable").build(window, cx)) .child(MetalButton::new("toggle-terminal-drawer").ghost().mini().icon(IconName::PanelBottom).disabled(true)) .into_any_element() }) .child( div() .relative() .child( MetalButton::new("toggle-right-panel") .ghost() .mini() .icon(IconName::PanelRight) .selected(self.right_panel.is_some()) .tooltip("Toggle right panel (Ctrl+Alt+B)") .on_click(cx.listener(|this, _, window, cx| this.toggle_right_panel(window, cx))), ) .when(live, |this| { this.child( div().absolute().top(px(1.)).right(px(1.)).child(metal::led("right-panel-live", Led::Live, subagents_working, p)), ) }), ) } }