diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index ef9131c1..38a6588b 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -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, 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.; @@ -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>>, + /// 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>>, /// The first row painted while following the tail. painted_tail_start: Option, /// Open/closed keys for collapsibles other than activity details. @@ -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(), @@ -2386,19 +2453,14 @@ impl ChatView { ) } - /// The way back from an agent to the thread it was reached from. - fn parent_link(&self, cx: &mut Context) -> Option { - 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) -> Option { + 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()) @@ -2406,8 +2468,9 @@ impl ChatView { .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 @@ -2560,14 +2623,7 @@ impl ChatView { window: &mut Window, cx: &mut Context, ) -> 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. @@ -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 @@ -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 @@ -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() @@ -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() }) @@ -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) -> Option { + /// 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) -> Option { let (quick, items) = self.workspace_store.read(cx).chat_git_controls()?; let border = cx.theme().border; @@ -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 { @@ -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| { @@ -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) -> 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, + ) -> AnyElement { let border = cx.theme().border; let main_cwd = cwd.clone(); let menu_cwd = cwd; @@ -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); })), diff --git a/crates/ui/src/diff/view.rs b/crates/ui/src/diff/view.rs index 79993c65..d69704bf 100644 --- a/crates/ui/src/diff/view.rs +++ b/crates/ui/src/diff/view.rs @@ -1,6 +1,7 @@ //! The right-side diff panel view: scope controls, virtualized unified/split //! lists, expandable gaps, and line-anchored review comments. +use std::cell::Cell; use std::collections::HashMap; use std::ops::Range; use std::path::{Path, PathBuf}; @@ -12,6 +13,7 @@ use crate::theme::ActiveTheme as _; use crate::widgets::Popover; use crate::widgets::button::{Button, ButtonVariants as _}; use crate::widgets::input::{Input, InputState}; +use crate::widgets::tooltip::Tooltip; use crate::{ icon::{Icon, IconName}, sizing::Sizable as _, @@ -23,7 +25,7 @@ use gpui::{ StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, prelude::FluentBuilder as _, px, }; -use gpui_base::{PopoverState, StyledExt as _, h_flex, v_flex}; +use gpui_base::{ElementExt as _, PopoverState, StyledExt as _, h_flex, v_flex}; use serde::Deserialize; use super::list::{ @@ -59,6 +61,18 @@ enum DiffViewOption { Invisibles, } +/// Below this tab strip width (caption buttons excluded) the tabs show their +/// icons alone. All four tabs labelled, with two-digit counts, beside the +/// panel's own controls fit in this much in English, the widest shipped +/// locale, so no label is ever cut off; the default panel width is above it. +const TAB_LABELS_MIN_WIDTH: f32 = 485.; + +/// Whether a strip last laid out `width` wide labels its tabs. Before the +/// first layout it does. +fn tab_labels_fit(width: Option, caption_width: f32) -> bool { + width.is_none_or(|width| width - caption_width >= TAB_LABELS_MIN_WIDTH) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum DiffScope { Turn(usize), @@ -181,6 +195,9 @@ pub struct DiffPanel { render_loading_key: Option, comment_input: Option>, observed_review_comments: Vec, + /// The tab strip's laid-out width on the last frame, which decides whether + /// the next one labels its tabs. + strip_width: Rc>>, _subscriptions: Vec, } @@ -223,6 +240,7 @@ impl DiffPanel { render_loading_key: None, comment_input: None, observed_review_comments: Vec::new(), + strip_width: Rc::new(Cell::new(None)), _subscriptions: subscriptions, } } @@ -595,7 +613,15 @@ impl DiffPanel { }; let muted = cx.theme().muted_foreground; let tab_active = cx.theme().tab_active; + let caption_width = if hosts_caption { + window_caption::CAPTION_CLUSTER_WIDTH + } else { + 0. + }; + let show_labels = tab_labels_fit(self.strip_width.get(), caption_width); + // Without its label a tab is its icon, named by its tooltip and its + // accessibility label. let labelled = |id: &'static str, icon: IconName, label: gpui::SharedString, @@ -604,9 +630,11 @@ impl DiffPanel { cx: &mut Context| -> gpui_base::Tab { material::tab(id, label.clone(), is_active, cx) + .debug_selector(move || id.into()) + .flex_none() .h(px(28.)) - .px_2p5() - .gap_1p5() + .when(show_labels, |s| s.px_2p5().gap_1p5()) + .when(!show_labels, |s| s.w(px(28.))) .rounded(material::radius_button(cx)) .text_size(px(13.)) .font_medium() @@ -615,7 +643,13 @@ impl DiffPanel { s.text_color(muted).hover(|s| s.bg(cx.theme().muted)) }) .child(Icon::new(icon).xsmall().text_color(muted)) - .child(content) + .map(|s| { + if show_labels { + s.child(content) + } else { + s.tooltip(move |window, cx| Tooltip::new(label.clone()).build(window, cx)) + } + }) }; let tab = |id, icon, label: gpui::SharedString, is_active, cx: &mut Context| { labelled( @@ -628,22 +662,20 @@ impl DiffPanel { ) }; - gpui_base::Tabs::new("right-panel-tabs") + let tabs = gpui_base::Tabs::new("right-panel-tabs") .flex() .items_center() .debug_selector(|| "right-panel-tabs".into()) .aria_label(crate::tr!("diff.panel_tabs")) - .flex_none() - .h(px(if hosts_caption { - window_caption::CAPTION_STRIP_HEIGHT - } else { - 40. - })) - .w_full() - .px_2() - .when(hosts_caption, |strip| strip.pr_0()) + // The tabs give way before the window controls do, so the close + // button stays on screen at any panel width. The 2px inset keeps + // the focus ring of the first tab inside the clip. + .min_w_0() + .overflow_hidden() + .h_full() + .px(px(2.)) + .ml(px(-2.)) .gap_1() - .items_center() .child( tab( "diff-tab", @@ -759,7 +791,28 @@ impl DiffPanel { store.set_right_tab(RightTab::PullRequests, cx) }) }) + }); + + let strip_width = self.strip_width.clone(); + h_flex() + .flex_none() + .h(px(if hosts_caption { + window_caption::CAPTION_STRIP_HEIGHT + } else { + 40. + })) + .w_full() + .px_2() + .when(hosts_caption, |strip| strip.pr_0()) + .gap_1() + .on_prepaint(move |bounds, window, _| { + let width = Some(f32::from(bounds.size.width)); + let previous = strip_width.replace(width); + if tab_labels_fit(previous, caption_width) != tab_labels_fit(width, caption_width) { + window.request_animation_frame(); + } }) + .child(tabs) // The gap between the tabs and the icon cluster holds nothing, so // it doubles as the window's drag handle: `window_drag_area` for the // app-owned move (macOS), `drag_region` for native HTCAPTION diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2f7b853d..792acbf0 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -182,11 +182,15 @@ struct ShellAttachment { /// appears mid-session, is that minimum. Left alone, the diff panel opens /// pinned to its 320px floor. split: Entity, - /// The width the right panel opens at: the default until the user drags a - /// handle, then whatever they chose (for this run). + /// The width the right panel opens at: the default until the user drags the + /// panel's own handle, then whatever they chose (for this run). A sidebar + /// drag never changes it, even when it squeezes the panel. right_width: Rc>, - /// Whether the open right panel has already been given its width. - right_sized: bool, + /// Keep giving the docked right panel its width until the group reports + /// it, then stop so the restore never fights a drag. Armed when the panel + /// docks and on every window width change: the group rescales its panels + /// by ratio and so drifts the panel off the width the user chose. + right_restore_pending: bool, /// Stable expanded-sidebar width. The resizable component otherwise scales /// every panel proportionally when the window enters or leaves fullscreen — /// and a trip through the compact layout is such a resize, so this is also @@ -269,13 +273,25 @@ pub struct AppShell { /// frame the keyboard leaves can tell an IME dismissing itself (focus is /// still there) from focus having moved to another input. keyboard_focus: Option, - /// Viewport width last seen by render; a change arms the sidebar restore. - last_viewport_width: Option, + /// Viewport width and sidebar collapse last seen by render; a change in + /// either re-lays the split out, which arms the sidebar and panel restores. + last_split_layout: Option<(Pixels, bool)>, _subscriptions: Vec, } /// The right panel's default width. const RIGHT_PANEL_WIDTH: f32 = 560.; +/// The panel's narrowest: the icon tabs and the panel's own controls fit in +/// 320pt, beside the window's caption buttons where the panel's strip holds them. +const RIGHT_PANEL_MIN_WIDTH: f32 = 320. + window_caption::RIGHT_PANEL_CAPTION_WIDTH; +/// The chat column never gets narrower than this beside the right panel, plus +/// whatever its header holds beyond the plain one (`header_extra_width`). At +/// 16pt padding the compact header fits: a title kept to a few words beside a +/// "+10" pull request badge, the git and Open split buttons as icon and +/// chevron, and five panel toggles measure 531pt. Where the chat's minimum and +/// the panel's do not both fit beside the sidebar, the panel floats over the +/// chat instead. +const CHAT_MIN_WIDTH: f32 = 532.; const SIDEBAR_WIDTH: f32 = 255.; /// Collapsed only: width of the window's left-edge activation region. const SIDEBAR_HOVER_EDGE: f32 = 12.; @@ -401,7 +417,7 @@ impl AppShell { next_toast_id: 1, palette_was_open: false, keyboard_focus: None, - last_viewport_width: None, + last_split_layout: None, _subscriptions: subscriptions, setup, }; @@ -763,7 +779,7 @@ impl AppShell { sidebar, split: cx.new(|_| ResizableState::default()), right_width: Rc::new(Cell::new(px(RIGHT_PANEL_WIDTH))), - right_sized: false, + right_restore_pending: true, sidebar_width: Rc::new(Cell::new(px(SIDEBAR_WIDTH))), sidebar_restore_pending: false, sidebar_overlay_visible: false, @@ -2394,6 +2410,11 @@ impl AppShell { } else { attachment.diff.clone().into_any_element() }; + let sidebar_layout_width = if collapsed { + px(0.) + } else { + attachment.sidebar_width.get() + }; // Sidebar | chat | right panel live in ONE resizable group. Nesting a // second group inside the chat panel does not shrink the chat: it keeps @@ -2414,11 +2435,14 @@ impl AppShell { // container, which can trail the viewport change — so keep restoring // until the width matches, then stop. A return from the compact layout // is such a change, which is why the split comes back as it was. + // Collapsing or expanding the sidebar changes the group's panel count, + // which rescales it the same way. let viewport_width = window.viewport_size().width; - let width_changed = self.last_viewport_width != Some(viewport_width); - if width_changed { - self.last_viewport_width = Some(viewport_width); + let layout_changed = self.last_split_layout != Some((viewport_width, collapsed)); + if layout_changed { + self.last_split_layout = Some((viewport_width, collapsed)); attachment.sidebar_restore_pending = true; + attachment.right_restore_pending = true; } if attachment.sidebar_restore_pending && !collapsed { let width = attachment.sidebar_width.get(); @@ -2433,61 +2457,125 @@ impl AppShell { None => false, }); // The rescale happens while the group lays out, which is after this - // runs — so a match on the very frame the viewport changed is the + // runs — so a match on the very frame the layout changed is the // *old* width, not a settled one. Never stop on that frame. - if restored && !width_changed { + if restored && !layout_changed { attachment.sidebar_restore_pending = false; } } - // Give the right panel its width once the group knows about it (the - // panel count is synced while the group renders, so this lands on the - // frame after it opens — the group notifies, so that frame comes). - if diff_open && chat_visible { - if !attachment.right_sized { - let width = attachment.right_width.get(); - let sized = attachment.split.update(cx, |state, cx| { - if state.sizes().len() > right_ix { - state.resize_panel(right_ix, width, window, cx); - true - } else { - false - } - }); - attachment.right_sized = sized; - } + // Too narrow for the chat and the panel side by side: the panel floats + // over the chat's right edge and the chat keeps the whole column + // underneath, rather than either being squeezed below its minimum. + let beside_sidebar = viewport_width - sidebar_layout_width; + let chat_min = px(CHAT_MIN_WIDTH + + crate::chat::header_extra_width( + attachment.link.store.read(cx), + collapsed, + fullscreen, + false, + )); + let right_floats = + diff_open && chat_visible && beside_sidebar < chat_min + px(RIGHT_PANEL_MIN_WIDTH); + let right_docked = diff_open && !right_floats; + let (docked_panel, floating_panel) = if right_floats { + (None, Some(right_panel)) } else { - attachment.right_sized = false; + (Some(right_panel), None) + }; + + // Give the docked right panel its width, as much of it as leaves the + // chat its minimum, once the group knows about the panel (its count is + // synced while the group renders, so this lands on the frame after it + // docks — the group notifies, so that frame comes) and once the sidebar + // has its width back, since the panel's room is what the sidebar leaves. + if !(right_docked && chat_visible) { + attachment.right_restore_pending = true; + } else if attachment.right_restore_pending + && (collapsed || !attachment.sidebar_restore_pending) + { + let width = attachment + .right_width + .get() + .min(beside_sidebar - chat_min) + .max(px(RIGHT_PANEL_MIN_WIDTH)); + let restored = + attachment + .split + .update(cx, |state, cx| match state.sizes().get(right_ix) { + Some(size) if (f32::from(*size) - f32::from(width)).abs() > 0.5 => { + state.resize_panel(right_ix, width, window, cx); + false + } + Some(_) => true, + None => false, + }); + // A match on the frame the layout changed is the old layout, not a + // settled one. + if restored && !layout_changed { + attachment.right_restore_pending = false; + } } // Chat and right-panel reading surfaces sit above the translucent canvas. - let chat_panel = resizable_panel().visible(chat_visible).child( - v_flex() - .size_full() - .bg(crate::material::content_surface(cx)) - .shadow_sm() - .child( - div().flex_1().min_h_0().child( - self.attachment - .as_ref() - .expect("attachment checked above") - .chat - .clone(), + let chat_panel = resizable_panel() + .visible(chat_visible) + .when(right_docked, |panel| { + panel.size_range(chat_min..Pixels::MAX) + }) + .child( + v_flex() + .size_full() + .bg(crate::material::content_surface(cx)) + .shadow_sm() + .child( + div().flex_1().min_h_0().child( + self.attachment + .as_ref() + .expect("attachment checked above") + .chat + .clone(), + ), ), - ), - ); + ); let attachment = self.attachment.as_ref().expect("attachment checked above"); + // `flex_none`: whatever the group's sizes leave over or lack goes to the + // chat, so the panel is exactly the width restored above. let right = resizable_panel() - .visible(diff_open) + .flex_none() + .visible(right_docked) .size(px(RIGHT_PANEL_WIDTH)) - .size_range(px(320.)..px(1400.)) + .size_range(px(RIGHT_PANEL_MIN_WIDTH)..px(1400.)) .child( div() + .debug_selector(|| "right-panel".into()) .size_full() .bg(crate::material::content_surface(cx)) .shadow_sm() - .child(right_panel), + .children(docked_panel), ); + let floating_right = floating_panel.map(|panel| { + div() + .id("right-panel-overlay") + .debug_selector(|| "right-panel-overlay".into()) + .absolute() + .top_0() + .right_0() + .h_full() + .w(attachment + .right_width + .get() + .min(beside_sidebar) + .max(px(RIGHT_PANEL_MIN_WIDTH))) + // Near-opaque, so the chat beneath does not bleed through the + // translucent content surface. + .bg(cx.theme().popover) + .shadow_lg() + .border_l_1() + .border_color(cx.theme().border) + .occlude() + .child(panel) + }); let remembered_right = attachment.right_width.clone(); let remembered_sidebar = attachment.sidebar_width.clone(); @@ -2500,12 +2588,20 @@ impl AppShell { h_resizable(id) .with_state(&split) .on_resize(move |state, _, cx| { + // The group does not say which handle was dragged. A drag + // that moved the sidebar was the sidebar's, and whatever it + // squeezed the panel to is not a width the user chose. let sizes = state.read(cx).sizes(); - if !collapsed && let Some(size) = sizes.first() { - remembered_sidebar.set(*size); - } - if let Some(size) = sizes.get(right_ix) { - remembered_right.set(*size); + let sidebar = (!collapsed).then(|| sizes.first()).flatten(); + match sidebar { + Some(size) if (f32::from(*size - remembered_sidebar.get())).abs() > 0.5 => { + remembered_sidebar.set(*size); + } + _ => { + if let Some(size) = sizes.get(right_ix) { + remembered_right.set(*size); + } + } } }) }; @@ -2535,6 +2631,7 @@ impl AppShell { .relative() .size_full() .child(group("chat-diff-panels").child(chat_panel).child(right)) + .children(floating_right) // This fixed transparent strip only opens the overlay. Its // inevitable false transition when the overlay occludes it is // deliberately ignored by the state machine. @@ -2584,16 +2681,22 @@ impl AppShell { }) .into_any_element() } else { - group("workspace-panels") + div() + .relative() + .size_full() .child( - resizable_panel() - .flex_none() - .size(px(SIDEBAR_WIDTH)) - .size_range(px(220.)..px(380.)) - .child(sidebar), + group("workspace-panels") + .child( + resizable_panel() + .flex_none() + .size(px(SIDEBAR_WIDTH)) + .size_range(px(220.)..px(380.)) + .child(sidebar), + ) + .child(chat_panel) + .child(right), ) - .child(chat_panel) - .child(right) + .children(floating_right) .into_any_element() }; @@ -5478,6 +5581,16 @@ mod tests { /// unbreakable 400-character line, so the diff panel has real content far /// wider than a phone page. fn seed_wide_diff(shell: &Entity, host: &MountedShell, cx: &mut VisualTestContext) { + seed_wide_diff_with(shell, host, cx, Vec::new()); + } + + /// [`seed_wide_diff`], with `extra` events after the turn's changes. + fn seed_wide_diff_with( + shell: &Entity, + host: &MountedShell, + cx: &mut VisualTestContext, + extra: Vec, + ) { let session = "thread-1"; let cwd = std::path::Path::new("/tmp/tcode-compact-panel"); let path = "a/deeply/nested/module/tree/with/an/absurdly/long/file_name.rs"; @@ -5502,20 +5615,22 @@ mod tests { total_turns: 0, truncated: false, from: 0, - end: 2, - records: vec![ + end: 2 + extra.len() as u64, + records: [ agent::AgentEvent::TurnStarted { turn_id: "turn-1".into(), - } - .into(), + }, agent::AgentEvent::TurnChangesUpdated { turn_id: "turn-1".into(), changes: agent::file_changes_from_unified_diff(&diff) .expect("one file section"), completeness: agent::ChangeCompleteness::Exact, - } - .into(), - ], + }, + ] + .into_iter() + .chain(extra) + .map(Into::into) + .collect(), }, ), ]; @@ -5636,6 +5751,230 @@ mod tests { ); } + /// An agent's thread with every right-panel tab, two-digit counts on Agents + /// and Pull requests, the longest git label, all five header toggles and a + /// link back to a parent whose title is longer than the link shows. + fn mount_crowded_thread( + cx: &mut TestAppContext, + ) -> (Entity, MountedShell, &mut VisualTestContext) { + let (shell, host, cx) = mount(cx); + cx.update(|_, cx| crate::window_seam::override_mobile_for_test(cx, false)); + let mut meta = tcode_core::project::SessionMeta::new( + agent::ProviderKind::ClaudeCode, + "/tmp/tcode-compact-panel".into(), + None, + ); + let mut parent = tcode_core::project::SessionMeta::new( + agent::ProviderKind::ClaudeCode, + "/tmp/tcode-compact-panel".into(), + None, + ); + parent.id = "thread-0".into(); + parent.title = "Plan the checkout service retry and timeout policy".into(); + meta.id = "thread-1".into(); + meta.parent_session_id = Some(parent.id.clone()); + meta.pull_requests = (1..=10) + .map(|number| tcode_core::pull_request::ThreadPullRequestLink { + key: tcode_core::pull_request::PullRequestKey { + host: "github.com".into(), + repository: "acme/api".into(), + number, + }, + source: tcode_core::pull_request::PullRequestSource::Manual, + url: String::new(), + linked_at: None, + snapshot: None, + stack: Default::default(), + sync_error: None, + watch: None, + }) + .collect(); + let git = tcode_core::git::GitStatus { + is_repo: true, + has_commits: true, + has_upstream: true, + has_working_tree_changes: true, + ..Default::default() + }; + for (topic, event) in [ + ( + Topic::Settings, + ServerEvent::SettingsSnapshot(Default::default()), + ), + ( + Topic::Index, + ServerEvent::IndexSnapshot(IndexSnapshot { + summary: Default::default(), + sessions: vec![parent, meta], + projects: Vec::new(), + }), + ), + ( + Topic::GitStatus { + session_id: "thread-1".into(), + }, + ServerEvent::GitStatusReplaced(tcode_protocol::GitStatusStatus { + status: Some(git), + busy: false, + }), + ), + ] { + host.incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic, + event, + })) + .unwrap(), + ) + .unwrap(); + } + let agents = (0..10) + .map(|index| { + agent::AgentEvent::ItemStarted(agent::ThreadItem { + id: format!("agent-{index}"), + parent_item_id: None, + content: agent::ItemContent::Subagent { + agent_type: "explore".into(), + description: format!("Survey module {index}"), + status: agent::ItemStatus::InProgress, + summary: None, + model: None, + effort: None, + }, + }) + }) + .collect(); + seed_wide_diff_with(&shell, &host, cx, agents); + (shell, host, cx) + } + + /// The right panel at the widths a desktop window passes through, on a + /// thread whose header and tab strip are as full as they get, with the + /// sidebar open and collapsed. Docked, the panel is the width the user chose, + /// less whatever the chat needs for its minimum — more while the header + /// clears the macOS traffic lights or links to its parent — and it comes + /// back to that width after the window narrows and widens again, after it + /// floated, and after a sidebar drag squeezed it; the header's last toggle + /// ends before the panel starts. At every width no tab label is cut off and + /// the close button is on screen and closes the panel. + #[gpui::test] + fn the_right_panel_keeps_its_width_and_leaves_the_chat_header_clear(cx: &mut TestAppContext) { + let _locale_guard = crate::settings::TestLocaleGuard::acquire(); + let (shell, host, cx) = mount_crowded_thread(cx); + let store = store_of(&shell, cx); + let window_state = shell.read_with(cx, |shell, _| shell.window_state()); + resize(cx, 1600.); + store.update(cx, |store, cx| store.toggle_diff_panel(cx)); + draw_until(&shell, cx, &host, "diff-body", None); + assert!( + cx.debug_bounds("agent-parent-link").is_some(), + "the header links back to the parent" + ); + + let walk = |cx: &mut VisualTestContext, widths: &[f32]| { + for &width in widths { + resize(cx, width); + draw_until(&shell, cx, &host, "diff-body", None); + draw(cx); + let (sidebar, collapsed) = shell.read_with(cx, |shell, cx| { + let attachment = shell.attachment.as_ref().expect("attached"); + let collapsed = shell.window_state.read(cx).sidebar_collapsed; + let sidebar = if collapsed { + 0. + } else { + f32::from(attachment.sidebar_width.get()) + }; + (sidebar, collapsed) + }); + let chat_min = CHAT_MIN_WIDTH + + store.read_with(cx, |store, _| { + crate::chat::header_extra_width(store, collapsed, false, false) + }); + let beside = width - sidebar; + if beside >= chat_min + RIGHT_PANEL_MIN_WIDTH { + let panel = cx.debug_bounds("right-panel").expect("the docked panel"); + assert_eq!( + panel.size.width, + px(RIGHT_PANEL_WIDTH.min(beside - chat_min)), + "{width}pt, sidebar {sidebar}pt" + ); + let toggle = cx + .debug_bounds("diff-panel") + .expect("the header's diff toggle"); + assert!( + toggle.right() <= panel.left(), + "{width}pt, sidebar {sidebar}pt: the header ends at {:?}, \ + under a panel starting at {:?}", + toggle.right(), + panel.left() + ); + } else { + assert!( + cx.debug_bounds("right-panel-overlay").is_some(), + "{width}pt, sidebar {sidebar}pt: the panel floats" + ); + } + let tabs = cx.debug_bounds("right-panel-tabs").expect("the tab list"); + for tab in ["diff-tab", "plan-tab", "agents-tab", "pull-requests-tab"] { + let bounds = cx.debug_bounds(tab).expect(tab); + assert!( + bounds.right() <= tabs.right(), + "{width}pt: {tab} ends at {:?}, past the tab list's {:?}", + bounds.right(), + tabs.right() + ); + } + let close = cx.debug_bounds("diff-close").expect("the close button"); + assert!(close.right() <= px(width), "{width}pt: close at {close:?}"); + } + }; + + walk(cx, &[1600., 1200., 1600., 1140., 720., 1600., 1200.]); + + // At 1200pt the chat's minimum already holds the panel below the width + // the user chose; a sidebar drag squeezes it further, but only a drag + // of the panel's own handle chooses a width. + let from = gpui::point(px(SIDEBAR_WIDTH), px(400.)); + let to = gpui::point(px(SIDEBAR_WIDTH + 40.), px(400.)); + // As a pointer does: the drag starts on the first move and moves the + // handle on the next, and the release listener exists only while a + // resize is under way, so each step gets its frame. + cx.simulate_mouse_down(from, MouseButton::Left, gpui::Modifiers::default()); + for step in [from + gpui::point(px(5.), px(0.)), to] { + cx.simulate_mouse_move(step, Some(MouseButton::Left), gpui::Modifiers::default()); + draw(cx); + } + cx.simulate_mouse_up(to, MouseButton::Left, gpui::Modifiers::default()); + draw(cx); + assert!( + shell.read_with(cx, |shell, _| { + shell + .attachment + .as_ref() + .expect("attached") + .sidebar_width + .get() + }) > px(SIDEBAR_WIDTH), + "the drag moved the sidebar" + ); + walk(cx, &[1600.]); + + window_state.update(cx, |state, cx| state.toggle_sidebar_collapsed(&store, cx)); + walk(cx, &[1600., 1250., 1100., 1000., 900., 860., 720., 1600.]); + + resize(cx, 720.); + draw_until(&shell, cx, &host, "diff-body", None); + let close = cx.debug_bounds("diff-close").expect("the close button"); + cx.simulate_click(close.center(), gpui::Modifiers::default()); + draw(cx); + assert!( + !store.read_with(cx, |store, _| store.panel_state().right_panel_open), + "nothing covers the close button" + ); + } + /// The working tree changes outside any turn — the user edits a file while /// another tab is showing. Choosing the Diff tab reads the tree again, as /// opening the panel does, and a tree with nothing to show draws the empty diff --git a/crates/ui/src/window_caption.rs b/crates/ui/src/window_caption.rs index a6f87818..3c080beb 100644 --- a/crates/ui/src/window_caption.rs +++ b/crates/ui/src/window_caption.rs @@ -38,10 +38,18 @@ pub(crate) const CAPTION_STRIP_HEIGHT: f32 = 52.; const CAPTION_BUTTON_WIDTH: f32 = 46.; /// Horizontal space the whole cluster occupies, for surfaces that must reserve /// room for it rather than simply place it last in a row. -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) const CAPTION_CLUSTER_WIDTH: f32 = CAPTION_BUTTON_WIDTH * 3.; /// Whether this build owns its window chrome and must draw caption buttons. const CLIENT_DECORATED: bool = cfg!(target_os = "windows"); +/// What the caption cluster takes from an open right panel's top strip. With +/// the panel open the workspace's top-right corner is the panel's, docked or +/// floating, so on a build that draws its own caption buttons they always sit +/// in its strip. +pub(crate) const RIGHT_PANEL_CAPTION_WIDTH: f32 = if CLIENT_DECORATED { + CAPTION_CLUSTER_WIDTH +} else { + 0. +}; /// Left inset a top strip keeps clear of the native macOS traffic lights, /// which the system draws over the window's top-left outside full screen.