Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 133 additions & 34 deletions crates/ui/src/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,70 @@ pub(crate) use crate::material::{
};
/// Left padding on the chat header while the sidebar is collapsed, so its
/// leading control clears the native macOS traffic lights (which end near x=72
/// on macOS 26). Only applied on macOS: see `render_header`.
/// on macOS 26). Only applied on macOS: see [`traffic_light_clearance`].
const TRAFFIC_LIGHT_INSET: f32 = 80.;
/// The header's padding on either side, unless it clears the traffic lights.
const HEADER_PADDING: f32 = 16.;
/// Below this header width the git and Open split buttons drop their text and
/// keep their icon and chevron. At 16pt padding, the header with its longest
/// git label, five panel toggles and a title kept to a few words beside a
/// "+10" pull request badge measures 671pt in English, the widest shipped
/// locale. [`header_extra_width`] adds what a thread's header holds beyond that.
const HEADER_LABELS_MIN_WIDTH: f32 = 672.;
/// The way back to the parent thread, each with the 8pt gap that follows it.
/// Its arrow alone measures 20pt. With the parent's title cut to its
/// 24-character maximum it measured 215pt for an ordinary title; 265pt leaves
/// room for a title of wide letters.
const PARENT_LINK_COMPACT_WIDTH: f32 = 28.;
const PARENT_LINK_LABELLED_WIDTH: f32 = 273.;

/// Whether a header last laid out `width` wide, `extra` of it beyond the plain
/// header, labels its split buttons. Before the first layout it does.
fn header_labels_fit(width: Option<f32>, extra: f32) -> bool {
width.is_none_or(|width| width - extra >= HEADER_LABELS_MIN_WIDTH)
}

/// The thread the active one was reached from: its id and title.
fn parent_session(store: &WorkspaceStore) -> Option<(String, String)> {
let active = store.active_session_id()?;
let sessions = store.sidebar_sessions();
let parent_id = sessions
.iter()
.find(|meta| meta.id == active)?
.parent_session_id
.clone()?;
let parent = sessions.iter().find(|meta| meta.id == parent_id)?;
Some((parent_id, parent.title.clone()))
}

/// The header's left padding beyond [`HEADER_PADDING`]. Collapsed, the sidebar
/// has zero width and the header starts at the window's left edge, where macOS
/// draws the traffic lights — but only when it draws them: they are hidden in
/// fullscreen, and other platforms never had them.
fn traffic_light_clearance(sidebar_collapsed: bool, fullscreen: bool) -> f32 {
if cfg!(target_os = "macos") && sidebar_collapsed && !fullscreen {
TRAFFIC_LIGHT_INSET - HEADER_PADDING
} else {
0.
}
}

/// How much wider than the plain header the active thread's header is, with
/// its split buttons labelled or not: the padding that clears the macOS
/// traffic lights, and the parent link.
pub(crate) fn header_extra_width(
store: &WorkspaceStore,
sidebar_collapsed: bool,
fullscreen: bool,
labelled: bool,
) -> f32 {
let parent_link = match (parent_session(store), labelled) {
(None, _) => 0.,
(Some(_), true) => PARENT_LINK_LABELLED_WIDTH,
(Some(_), false) => PARENT_LINK_COMPACT_WIDTH,
};
traffic_light_clearance(sidebar_collapsed, fullscreen) + parent_link
}
/// Vertical rhythm between turns. Turns are separated by space and typographic
/// hierarchy alone — there is deliberately no rule/divider under the user bubble.
const TURN_GAP: f32 = 32.;
Expand Down Expand Up @@ -402,6 +464,10 @@ pub struct ChatView {
/// The rows the timeline paints in the frame being drawn: GPUI's list
/// prepaints only the rows on screen.
painted_rows: Rc<Cell<Option<(usize, usize)>>>,
/// The header's laid-out width on the last frame, which decides whether the
/// next one labels its split buttons. The header is as wide as the chat
/// column whatever it holds, so the choice cannot feed back into it.
header_width: Rc<Cell<Option<f32>>>,
/// The first row painted while following the tail.
painted_tail_start: Option<usize>,
/// Open/closed keys for collapsibles other than activity details.
Expand Down Expand Up @@ -708,6 +774,7 @@ impl ChatView {
markdown_visible_rows: 0..0,
markdown_scroll_top: None,
painted_rows: Rc::default(),
header_width: Rc::default(),
painted_tail_start: None,
expanded: HashSet::new(),
auto_activity_expansions: AutoActivityExpansions::default(),
Expand Down Expand Up @@ -2386,28 +2453,24 @@ impl ChatView {
)
}

/// The way back from an agent to the thread it was reached from.
fn parent_link(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let store = self.workspace_store.read(cx);
let active = store.active_session_id()?;
let sessions = store.sidebar_sessions();
let parent_id = sessions
.iter()
.find(|meta| meta.id == active)?
.parent_session_id
.clone()?;
let parent = sessions.iter().find(|meta| meta.id == parent_id)?;
let title = parent.title.clone();
/// The way back from an agent to the thread it was reached from. Unlabelled,
/// it is its arrow, named by its tooltip and its accessibility label.
fn parent_link(&self, labelled: bool, cx: &mut Context<Self>) -> Option<AnyElement> {
let (parent_id, title) = parent_session(self.workspace_store.read(cx))?;
let short = components::disclosure::truncate_chars(&title, 24);
let name: SharedString = crate::tr!("agents.back_to_parent", title = title)
.into_owned()
.into();
Some(
Button::new("agent-parent-link")
.debug_selector(|| "agent-parent-link".into())
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
.ghost()
.xsmall()
.icon(Icon::new(IconName::ArrowLeft).size(px(14.)))
.label(short)
.tooltip(crate::tr!("agents.back_to_parent", title = title))
.when(labelled, |link| link.label(short))
.aria_label(name.clone())
.tooltip(name)
.on_click(cx.listener(move |this, _, _, cx| {
let parent_id = parent_id.clone();
this.workspace_store
Expand Down Expand Up @@ -2560,14 +2623,7 @@ impl ChatView {
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
// Collapsed, the sidebar has zero width and this header starts at the
// window's left edge. On macOS the native traffic lights sit there, so
// the row's leading content (the sidebar toggle) is inset past them —
// but only when the platform actually draws them: they are hidden in
// fullscreen, and other platforms never had them.
let collapsed = self.window_state.read(cx).sidebar_collapsed;
let clears_traffic_lights =
cfg!(target_os = "macos") && collapsed && !window.is_fullscreen();
// Windows: with no right panel open this header is the window's
// top-right corner, so it hosts the caption buttons — flush to the
// right edge, past the header's usual inset.
Expand All @@ -2578,16 +2634,35 @@ impl ChatView {
right_panel_open,
right_tab,
);
let labelled_extra = header_extra_width(
self.workspace_store.read(cx),
collapsed,
window.is_fullscreen(),
true,
);
let labelled = header_labels_fit(self.header_width.get(), labelled_extra);
let header_width = self.header_width.clone();
let base = h_flex()
.flex_shrink_0()
.h(px(52.))
.px_4()
.when(clears_traffic_lights, |this| {
this.pl(px(TRAFFIC_LIGHT_INSET))
})
.px(px(HEADER_PADDING))
.pl(px(HEADER_PADDING
+ traffic_light_clearance(
collapsed,
window.is_fullscreen(),
)))
.when(hosts_caption, |this| this.pr_0())
.gap_2()
.items_center();
.items_center()
.on_prepaint(move |bounds, window, _| {
let width = Some(f32::from(bounds.size.width));
let previous = header_width.replace(width);
if header_labels_fit(previous, labelled_extra)
!= header_labels_fit(width, labelled_extra)
{
window.request_animation_frame();
}
});

// The sidebar toggle: the header's first control, immediately left of
// the title. It lives here rather than in the sidebar because a
Expand Down Expand Up @@ -2618,7 +2693,7 @@ impl ChatView {
}));

// An agent's header leads back to the thread it was reached from.
let parent_link = self.parent_link(cx);
let parent_link = self.parent_link(labelled, cx);

// A draft shows a muted "New thread" label; an open thread its title;
// nothing active shows "No active thread". The title stretch carries no
Expand Down Expand Up @@ -2733,8 +2808,11 @@ impl ChatView {
),
)
.when(show_actions, |this| {
this.children(self.render_git_button(cx))
.children(cwd.clone().map(|cwd| self.render_open_button(cwd, cx)))
this.children(self.render_git_button(labelled, cx))
.children(
cwd.clone()
.map(|cwd| self.render_open_button(cwd, labelled, cx)),
)
.child(
h_flex()
.flex_none()
Expand Down Expand Up @@ -2832,6 +2910,7 @@ impl ChatView {
})
.child(
Button::new("diff-panel")
.debug_selector(|| "diff-panel".into())
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation()
})
Expand All @@ -2856,7 +2935,9 @@ impl ChatView {

/// Git quick-action split button whose primary action and dropdown choices
/// follow the current git status.
fn render_git_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
/// Unlabelled, the main part is its icon, named by its tooltip (or the
/// disabled hint) and its accessibility label.
fn render_git_button(&self, labelled: bool, cx: &mut Context<Self>) -> Option<AnyElement> {
let (quick, items) = self.workspace_store.read(cx).chat_git_controls()?;
let border = cx.theme().border;

Expand Down Expand Up @@ -2892,7 +2973,7 @@ impl ChatView {
} else {
cx.theme().foreground
}))
.child(label);
.when(labelled, |main| main.child(label.clone()));
if quick.disabled {
main = main.text_color(cx.theme().muted_foreground);
if let Some(hint) = quick.hint {
Expand All @@ -2901,6 +2982,9 @@ impl ChatView {
}
} else if let Some(action) = quick.action {
main = main
.when(!labelled, |main| {
main.tooltip(move |window, cx| Tooltip::new(label.clone()).build(window, cx))
})
.cursor_pointer()
.hover(|s| s.bg(cx.theme().accent))
.on_click(cx.listener(move |this, _, window, cx| {
Expand Down Expand Up @@ -3027,7 +3111,14 @@ impl ChatView {
}

/// Open the session cwd in Zed, or choose a directory action from the menu.
fn render_open_button(&self, cwd: PathBuf, cx: &mut Context<Self>) -> AnyElement {
/// Unlabelled, the main part is its icon, named by its tooltip and its
/// accessibility label.
fn render_open_button(
&self,
cwd: PathBuf,
labelled: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let border = cx.theme().border;
let main_cwd = cwd.clone();
let menu_cwd = cwd;
Expand Down Expand Up @@ -3139,7 +3230,15 @@ impl ChatView {
.xsmall()
.text_color(cx.theme().muted_foreground),
)
.child(crate::tr!("chat.open"))
.map(|main| {
if labelled {
main.child(crate::tr!("chat.open"))
} else {
main.tooltip(|window, cx| {
Tooltip::new(crate::tr!("chat.open").into_owned()).build(window, cx)
})
}
})
.on_click(cx.listener(move |_, _, window, cx| {
open_in_zed(&main_cwd, window, cx);
})),
Expand Down
Loading
Loading