//! 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 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<Workspace>) -> 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<Self>) {
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<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) -> 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()))
});
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(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<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let subagents_shown = self.subagents_open && !self.browser_open;
let subagents_working = !subagents_shown && self.working_subagents(cx) > 0;
let live = !self.browser_open
&& self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx));
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(
div()
.relative()
.child(
MetalButton::new("toggle-subagents")
.ghost()
.mini()
.icon(Lucide::Bot)
.selected(subagents_shown)
.tooltip("Toggle subagents (Ctrl+Alt+S)")
.on_click(cx.listener(|this, _, _, cx| this.toggle_subagents(cx))),
)
.when(subagents_working, |this| {
this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("subagents-live", Led::Live, true, p)))
}),
)
.child(if self.terminal_available(cx) {
MetalButton::new("toggle-terminal-drawer")
.ghost()
.mini()
.icon(IconName::PanelBottom)
.selected(self.terminal_open())
.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.browser_open)
.tooltip("Toggle right panel (Ctrl+Alt+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.browser_open = !this.browser_open;
cx.notify();
})),
)
.when(live, |this| {
this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p)))
}),
)
}
}