From 793ffabb92754e72ce978661968492490eff48e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 20:44:07 -0300 Subject: [PATCH 1/8] Sidebar: show a session hover card beside each chat row Compact rows drop the project, device, branch and pull request, and even regular rows truncate the title and never show the PR's title. Dwelling on a sidebar row now floats a frosted card out of its right edge with the whole title, status and age, project and device, branch, worktree folder (when the checkout lives outside the project folder) and the pull request tinted in its state's color. The card waits out a 550ms dwell so sweeping down the list stays quiet; once one has shown it is warm and follows the pointer to the next row at once, until the pointer rests off the list for 400ms. A press, scroll, context menu, drag or sidebar collapse dismisses it. Since gpui sends no hover-out to a row that moves or unmounts under a still pointer, the shown card re-checks its row's hitbox at paint. Sidebar rows only: the palette's copy and a row being renamed never show it. --- crates/ui/src/shell.rs | 61 +++ crates/ui/src/shell/session_hover_card.rs | 531 ++++++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 crates/ui/src/shell/session_hover_card.rs diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2b6f436f46..d4b6cb0a40 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -83,6 +83,7 @@ use panel_layout::{AuxiliaryPanel, FitInputs, HorizontalPanelFit, panel_content_ mod panel_interaction_tests; pub(crate) mod project_icon; mod pull_request_pane; +mod session_hover_card; mod session_info; mod side_chats; mod sidebar_pins; @@ -2131,6 +2132,8 @@ pub struct Shell { /// under a still pointer would otherwise never show its archive pill; /// while set, rows adopt the hover from a paint-time hit test instead. chat_hover_resync: bool, + /// The sidebar row whose hover card shows (or waits out its dwell). + session_hover_card: session_hover_card::SessionHoverCard, /// Scroll position of the sidebar lists region (drives its edge fades). sidebar_scroll: gpui::ScrollHandle, /// In-flight reorder for the pinned section only. @@ -2622,6 +2625,7 @@ impl Shell { project_icons: Default::default(), chat_status_hover: None, chat_hover_resync: false, + session_hover_card: Default::default(), sidebar_scroll: gpui::ScrollHandle::new(), pinned_session_drag: None, pinned_session_drag_generation: 0, @@ -3290,6 +3294,7 @@ impl Shell { } let from = self.sidebar_now(); let before = self.painted_panels(cx); + self.session_hover_card.dismiss(); self.sidebar_edge_bounce = None; self.sidebar_resize_edge = None; self.pane_resize_active = None; @@ -8329,6 +8334,22 @@ impl Shell { let rename_input = (search_query_none && !preview) .then(|| self.rename_input_for(&id, ChatRenameSurface::Sidebar)) .flatten(); + // Sidebar rows only: the palette's copy already spells out its row, + // and a row being renamed keeps the pointer's attention on the field. + let hover_card = (search_query_none + && !preview + && rename_input.is_none() + && self.session_hover_card.shown() == Some(id.as_str())) + .then(|| { + self.render_session_hover_card( + &id, + status_label.map(|label| (label, status_color)), + time_ago.clone(), + cx, + ) + }) + .flatten(); + let card_id = id.clone(); // Hover fades over transition-colors (zeron session-row.tsx) — both // the wash and the title brighten ride the same 150ms blend. let fade_key = format!("{row_id}-hover"); @@ -8386,6 +8407,9 @@ impl Shell { cx.listener(move |this, hovered: &bool, window, cx| { fade_hover(hovered, window, cx); this.chat_hover_resync = false; + if search_query_none { + this.hover_session_card(&card_id, *hovered, cx); + } if *hovered { if this.chat_status_hover.as_deref() != Some(hover_id.as_str()) { this.chat_status_hover = Some(hover_id.clone()); @@ -8398,6 +8422,13 @@ impl Shell { }) }) .cursor_pointer() + // Pressing, scrolling or opening the menu puts the hover card + // away: the row has the pointer's intent now. + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.dismiss_session_card(cx)), + ) + .on_scroll_wheel(cx.listener(|this, _, _, cx| this.dismiss_session_card(cx))) .on_click(cx.listener(move |this, event: &gpui::ClickEvent, _, cx| { // The palette's copy of the row only opens; the sidebar's // double-click edits the title in place. @@ -8410,6 +8441,7 @@ impl Shell { .on_mouse_down( MouseButton::Right, cx.listener(move |this, event: &MouseDownEvent, _, cx| { + this.dismiss_session_card(cx); this.chat_menu.open(ChatMenuState { tab: None, chat_id: menu_id.clone(), @@ -8483,6 +8515,35 @@ impl Shell { .inset_0(), ) }) + // gpui sends no hover-out to a row that unmounts or moves away + // under a still pointer (a collapsed sidebar, a resort), so the + // shown card re-checks its row by paint and hides once it lost it. + .when(hover_card.is_some(), |el| { + let shell = cx.weak_entity(); + let chat_id = id.clone(); + el.child( + gpui::canvas( + |bounds, window, _| { + window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) + }, + move |_, hitbox, window, cx| { + if hitbox.is_hovered(window) { + return; + } + window.defer(cx, move |_, cx| { + shell + .update(cx, |this, cx| { + this.hover_session_card(&chat_id, false, cx) + }) + .ok(); + }); + }, + ) + .absolute() + .inset_0(), + ) + }) + .children(hover_card) // Line 1: "project @ device", status word / time-ago right. .when(!compact && show_label, |el| { el.child( diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs new file mode 100644 index 0000000000..37ce92ba08 --- /dev/null +++ b/crates/ui/src/shell/session_hover_card.rs @@ -0,0 +1,531 @@ +//! The session hover card: dwell on a sidebar row and a frosted card floats +//! out beside it with what the row has no room for — the whole title, the +//! project and device, the branch and worktree, and the pull request's title. +//! Compact rows drop all of that, so the card is where it lives on. +//! +//! The card waits out a short dwell so sweeping the pointer down the list +//! stays quiet. Once one has shown, the card is warm: the next row's card +//! follows the pointer at once, until it rests off the list for a moment. +use std::time::Instant; + +use super::*; + +use crate::change_requests::ChangeRequestBadgeModel; + +/// How long the pointer rests on a row before its card shows. +const DWELL: Duration = Duration::from_millis(550); +/// How long after a card hides the next row's card skips the dwell. +const WARM: Duration = Duration::from_millis(400); +const CARD_WIDTH: f32 = 284.0; +/// The gap between the row's right edge and the card. +const CARD_GAP: f32 = 10.0; +const CARD_PAD: f32 = 12.0; + +/// Which row's card shows, and which is waiting out its dwell. +#[derive(Default)] +pub(super) struct SessionHoverCard { + shown: Option, + pending: Option<(String, Task<()>)>, + hidden_at: Option, +} + +impl SessionHoverCard { + pub(super) fn shown(&self) -> Option<&str> { + self.shown.as_deref() + } + + /// The pointer entered `chat_id`'s row. True when the card should show + /// at once: another card is up, or one hid moments ago. + fn enter(&mut self, chat_id: &str, now: Instant) -> bool { + if self.shown.as_deref() == Some(chat_id) { + return false; + } + let warm = self.shown.is_some() + || self + .hidden_at + .is_some_and(|hidden| now.duration_since(hidden) < WARM); + if warm { + self.pending = None; + self.shown = Some(chat_id.to_owned()); + } + warm + } + + /// The pointer left `chat_id`'s row. True when a shown card hid. + fn leave(&mut self, chat_id: &str, now: Instant) -> bool { + if self + .pending + .as_ref() + .is_some_and(|(pending, _)| pending == chat_id) + { + self.pending = None; + } + if self.shown.as_deref() != Some(chat_id) { + return false; + } + self.shown = None; + self.hidden_at = Some(now); + true + } + + /// The dwell on `chat_id` ran out with the pointer still there. + fn settle(&mut self, chat_id: &str) -> bool { + if self + .pending + .as_ref() + .is_none_or(|(pending, _)| pending != chat_id) + { + return false; + } + self.pending = None; + self.shown = Some(chat_id.to_owned()); + true + } + + /// A click, menu, drag or scroll: hide now, and go cold so the card does + /// not chase the pointer straight back. + pub(super) fn dismiss(&mut self) -> bool { + self.pending = None; + self.hidden_at = None; + self.shown.take().is_some() + } +} + +/// The checkout folder's name when the chat runs outside its project's own +/// folder — a worktree — else `None`. A project-less chat in a plain folder, +/// or one in a subfolder of its project, has no worktree worth naming. +fn worktree_label(cwd: Option<&str>, project_path: Option<&str>) -> Option { + let cwd = cwd?.trim_end_matches(['/', '\\']); + let project = project_path?.trim_end_matches(['/', '\\']); + let inside = crate::pickers::same_folder_path(cwd, project) + || cwd + .strip_prefix(project) + .is_some_and(|rest| rest.starts_with(['/', '\\'])); + if cwd.is_empty() || inside { + return None; + } + cwd.rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty()) + .map(str::to_owned) +} + +/// "21h ago" for the card's footer line ("now" stays as it is). +fn time_label(time_ago: &str) -> String { + if time_ago == "now" { + "just now".to_owned() + } else { + format!("{time_ago} ago") + } +} + +impl Shell { + /// Row hover in or out (the row's own hover listener forwards here). + pub(super) fn hover_session_card( + &mut self, + chat_id: &str, + hovered: bool, + cx: &mut Context, + ) { + let now = Instant::now(); + if !hovered { + if self.session_hover_card.leave(chat_id, now) { + cx.notify(); + } + return; + } + if cx.has_active_drag() || self.chat_menu.is_open() { + return; + } + if self.session_hover_card.enter(chat_id, now) { + cx.notify(); + return; + } + if self.session_hover_card.shown() == Some(chat_id) { + return; + } + let target = chat_id.to_owned(); + let task = cx.spawn({ + let target = target.clone(); + async move |this, cx| { + cx.background_executor().timer(DWELL).await; + let _ = this.update(cx, |this, cx| { + if this.session_hover_card.settle(&target) { + cx.notify(); + } + }); + } + }); + self.session_hover_card.pending = Some((target, task)); + } + + pub(super) fn dismiss_session_card(&mut self, cx: &mut Context) { + if self.session_hover_card.dismiss() { + cx.notify(); + } + } + + /// The card for `chat_id`'s row, mounted from the row itself: it floats + /// from the row's right edge above everything else, clear of the + /// sidebar's clip. `status` is the row's own status word and color. + pub(super) fn render_session_hover_card( + &self, + chat_id: &str, + status: Option<(&'static str, gpui::Hsla)>, + time_ago: SharedString, + cx: &mut Context, + ) -> Option { + let theme = Theme::of(cx).for_popup(); + let now = Utc::now(); + let (title, project, device, local, online, branch, worktree, change_request) = { + let state = self.state.read(cx); + let chat = state.chats.iter().find(|chat| chat.id == chat_id)?; + let space = state.space_for_chat(chat); + let project = space.map(|space| SharedString::from(space.display_name().to_owned())); + let device = state + .device_name(&chat.device_id) + .map(|name| SharedString::from(name.to_owned())); + let local = state.local_device_id.as_deref() == Some(chat.device_id.as_str()); + let online = state.device_online(&chat.device_id, now); + let branch = crate::change_requests::conversation_branch(chat, &state.spaces) + .map(str::trim) + .filter(|branch| !branch.is_empty()) + .map(|branch| SharedString::from(branch.to_owned())); + let worktree = worktree_label( + chat.cwd.as_deref(), + space.map(|space| space.path.as_str()), + ) + .map(SharedString::from); + let change_request = state + .change_request_for_chat(chat) + .map(ChangeRequestBadgeModel::from_summary); + let title = SharedString::from( + chat.title + .as_deref() + .map(|title| title.split_whitespace().collect::>().join(" ")) + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| "New session".to_owned()), + ); + ( + title, + project, + device, + local, + online, + branch, + worktree, + change_request, + ) + }; + + let muted = theme.text_muted; + let meta_row = |glyph: AnyElement, label: SharedString| { + div() + .flex() + .flex_row() + .items_center() + .gap(px(7.0)) + .min_w_0() + .h(px(18.0)) + .child( + div() + .w(px(14.0)) + .flex_none() + .flex() + .justify_center() + .child(glyph), + ) + .child(div().flex_1().min_w_0().truncate().child(label)) + }; + let glyph = |path: &'static str| { + icon(path) + .size(px(13.0)) + .text_color(muted.opacity(0.8)) + .into_any_element() + }; + + // ── Title, then status and age on one quiet line. + let header = div() + .flex() + .flex_col() + .gap(px(4.0)) + .child( + div() + .w_full() + .text_size(crate::typography::ui_rems(13.0)) + .line_height(px(18.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text) + .line_clamp(3) + .child(title), + ) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(muted) + .when_some(status, |el, (label, color)| { + el.child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(5.0)) + .text_color(color) + .font_weight(gpui::FontWeight::MEDIUM) + .child(div().size(px(6.0)).rounded_full().bg(color)) + .child(label), + ) + .child(div().text_color(muted.opacity(0.6)).child("·")) + }) + .child(time_label(&time_ago)), + ); + + // ── Where it runs: project and device, branch, worktree. + let project_row = meta_row( + match &project { + Some(_) => self.render_project_icon(chat_id, 14.0, false, cx), + None => glyph(icons::FOLDER), + }, + project.clone().unwrap_or_else(|| "No project".into()), + ) + .text_color(theme.text) + .when_some(device, |row, device| { + row.child( + div() + .flex_none() + .max_w(px(120.0)) + .flex() + .flex_row() + .items_center() + .gap(px(4.0)) + .text_color(muted) + .child( + icon(if !online { + icons::WIFI_OFF + } else if local { + icons::LAPTOP + } else { + icons::REMOTE_SERVER + }) + .size(px(12.0)) + .flex_none() + .text_color(if online { + muted.opacity(0.8) + } else { + theme.warning.opacity(0.8) + }), + ) + .child(div().min_w_0().truncate().child(device)), + ) + }); + let location = div() + .flex() + .flex_col() + .gap(px(2.0)) + .text_size(crate::typography::ui_rems(12.0)) + .text_color(muted) + .child(project_row) + .children(branch.map(|branch| meta_row(glyph(icons::GIT_BRANCH), branch))) + .children(worktree.map(|worktree| meta_row(glyph(icons::FOLDER), worktree))); + + // ── The pull request, tinted in its state's color like the badge. + let pull_request = change_request.map(|model| { + let tone = model.tone.color(&theme); + div() + .flex() + .flex_col() + .gap(px(3.0)) + .px(px(9.0)) + .py(px(7.0)) + .rounded(px(8.0)) + .bg(tone.opacity(0.08)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(5.0)) + .text_size(crate::typography::ui_rems(11.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(tone.opacity(0.9)) + .child( + icon(icons::PULL_REQUEST) + .size(px(11.0)) + .flex_none() + .text_color(tone.opacity(0.9)), + ) + .child( + div() + .font_family(theme.font_mono.clone()) + .child(SharedString::from(format!("#{}", model.number))), + ) + .child(div().opacity(0.6).child("·")) + .child(model.state_label), + ) + .child( + div() + .w_full() + .text_size(crate::typography::ui_rems(12.0)) + .line_height(px(16.0)) + .text_color(theme.text) + .line_clamp(2) + .child(model.title), + ) + }); + + let card = div() + .w(px(CARD_WIDTH)) + .p(px(CARD_PAD)) + .flex() + .flex_col() + .gap(px(10.0)) + .rounded(px(popover::CARD_RADIUS)) + .overflow_hidden() + .border_1() + .border_color(theme.border) + .bg(popover::surface_bg(&theme)) + .when(!theme.is_frost(), |card| card.shadow_lg()) + .child(header) + .child(location) + .children(pull_request); + let card = div() + .debug_selector({ + let chat_id = chat_id.to_owned(); + move || format!("session-hover-card-{chat_id}") + }) + .child(crate::frost::frosted( + popover::CARD_RADIUS, + crate::frost::MENU_BLUR, + card, + )); + // Drifts in from the row's side, like a submenu leaving its trigger. + let card = if self.reduced_motion { + card.into_any_element() + } else { + card.with_animation( + SharedString::from(format!("session-hover-card-{chat_id}")), + motion::MENU_IN.animation(), + |el, t| el.relative().opacity(t).left(px(-4.0 * (1.0 - t))), + ) + .into_any_element() + }; + Some( + div() + .absolute() + .top_0() + .left_full() + .child( + gpui::deferred( + gpui::anchored() + .anchor(gpui::Anchor::TopLeft) + .snap_to_window_with_margin(px(8.0)) + .child(div().pl(px(CARD_GAP)).mt(px(-4.0)).child(card)), + ) + .with_priority(1), + ) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn card() -> SessionHoverCard { + SessionHoverCard::default() + } + + #[test] + fn a_cold_card_waits_out_the_dwell() { + let mut card = card(); + let now = Instant::now(); + assert!(!card.enter("a", now)); + assert_eq!(card.shown(), None); + // The dwell only settles a row the pointer is still on. + assert!(!card.settle("a")); + card.pending = Some(("a".into(), Task::ready(()))); + assert!(card.settle("a")); + assert_eq!(card.shown(), Some("a")); + } + + #[test] + fn leaving_before_the_dwell_cancels_it() { + let mut card = card(); + card.pending = Some(("a".into(), Task::ready(()))); + assert!(!card.leave("a", Instant::now())); + assert!(!card.settle("a")); + assert_eq!(card.shown(), None); + } + + #[test] + fn a_warm_card_follows_the_pointer_in_either_event_order() { + let now = Instant::now(); + // Leave fires first: the card hid a moment ago, so it is still warm. + let mut card = card(); + card.shown = Some("a".into()); + assert!(card.leave("a", now)); + assert!(card.enter("b", now + Duration::from_millis(16))); + assert_eq!(card.shown(), Some("b")); + // Enter fires first: the stale leave must not hide the new card. + let mut card = SessionHoverCard { + shown: Some("a".into()), + ..Default::default() + }; + assert!(card.enter("b", now)); + assert!(!card.leave("a", now)); + assert_eq!(card.shown(), Some("b")); + } + + #[test] + fn the_card_cools_after_a_rest_or_a_dismissal() { + let now = Instant::now(); + let mut card = card(); + card.shown = Some("a".into()); + card.leave("a", now); + assert!(!card.enter("b", now + WARM + Duration::from_millis(1))); + let mut card = SessionHoverCard { + shown: Some("a".into()), + ..Default::default() + }; + assert!(card.dismiss()); + assert!(!card.enter("a", now)); + assert_eq!(card.shown(), None); + } + + #[test] + fn only_a_checkout_outside_the_project_folder_is_a_worktree() { + assert_eq!( + worktree_label( + Some("/home/me/.zeron/worktrees/comet/swift-aspen"), + Some("/home/me/code/comet") + ), + Some("swift-aspen".to_owned()) + ); + assert_eq!( + worktree_label(Some("/home/me/code/comet/"), Some("/home/me/code/comet")), + None + ); + assert_eq!( + worktree_label(Some("/home/me/code/comet/crates"), Some("/home/me/code/comet/")), + None + ); + // A sibling folder sharing the name's prefix is not inside it. + assert_eq!( + worktree_label(Some("/home/me/code/comet-old"), Some("/home/me/code/comet")), + Some("comet-old".to_owned()) + ); + assert_eq!(worktree_label(Some("/tmp/scratch"), None), None); + assert_eq!(worktree_label(None, Some("/home/me/code/comet")), None); + } + + #[test] + fn the_age_reads_as_a_sentence() { + assert_eq!(time_label("21h"), "21h ago"); + assert_eq!(time_label("now"), "just now"); + } +} From 396c01fec974e75ea09b343e54ed52f5560f9c91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 20:47:39 -0300 Subject: [PATCH 2/8] Sidebar: drop the PR badge tooltip now that the hover card shows the PR The session hover card already spells out the pull request's number, state and title, so the sidebar badge's own tooltip only stacked a second popup over it. The badge still opens the PR on click and keeps its hover tint; the composer's badge, with no card beside it, keeps its tooltip. --- crates/ui/src/change_requests.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/change_requests.rs b/crates/ui/src/change_requests.rs index e7e406d43d..89d86de223 100644 --- a/crates/ui/src/change_requests.rs +++ b/crates/ui/src/change_requests.rs @@ -219,13 +219,16 @@ fn render_badge_model( cx.stop_propagation(); crate::pull_request_detail::open_on_device(&url, device.clone(), window, cx); }) - .tooltip(move |_, cx| { - cx.new(|_| ChangeRequestTooltip { - model: tooltip_model.clone(), - }) - .into() + }) + // Sidebar rows spell the pull request out in their hover card. + .when(interactive && composer, |el| { + el.tooltip(move |_, cx| { + cx.new(|_| ChangeRequestTooltip { + model: tooltip_model.clone(), }) - .tooltip_show_delay(std::time::Duration::from_millis(350)) + .into() + }) + .tooltip_show_delay(std::time::Duration::from_millis(350)) }) .child( crate::icons::icon(crate::icons::PULL_REQUEST) From 173481f9c16928019fffe3db22cbcba6fc34c3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 21:44:22 -0300 Subject: [PATCH 3/8] Session hover card: fold the pull request into one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR block spent a header line on "#475 · Open" above the title. The state already reads from the block's tint and the glyph's color (the sidebar badge's), so the block is now a single row: glyph, title (up to two lines) and the number trailing in the state's color. --- crates/ui/src/shell/session_hover_card.rs | 50 +++++++++++------------ 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs index 37ce92ba08..e9afc7ef94 100644 --- a/crates/ui/src/shell/session_hover_card.rs +++ b/crates/ui/src/shell/session_hover_card.rs @@ -332,49 +332,45 @@ impl Shell { .children(branch.map(|branch| meta_row(glyph(icons::GIT_BRANCH), branch))) .children(worktree.map(|worktree| meta_row(glyph(icons::FOLDER), worktree))); - // ── The pull request, tinted in its state's color like the badge. + // ── The pull request on one line: its state lives in the tint and + // the glyph's color (the badge's), so only the title and number + // need words. let pull_request = change_request.map(|model| { let tone = model.tone.color(&theme); div() .flex() - .flex_col() - .gap(px(3.0)) + .flex_row() + .items_start() + .gap(px(7.0)) .px(px(9.0)) .py(px(7.0)) .rounded(px(8.0)) .bg(tone.opacity(0.08)) + .text_size(crate::typography::ui_rems(12.0)) + .line_height(px(16.0)) .child( - div() - .flex() - .flex_row() - .items_center() - .gap(px(5.0)) - .text_size(crate::typography::ui_rems(11.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(tone.opacity(0.9)) - .child( - icon(icons::PULL_REQUEST) - .size(px(11.0)) - .flex_none() - .text_color(tone.opacity(0.9)), - ) - .child( - div() - .font_family(theme.font_mono.clone()) - .child(SharedString::from(format!("#{}", model.number))), - ) - .child(div().opacity(0.6).child("·")) - .child(model.state_label), + div().h(px(16.0)).flex_none().flex().items_center().child( + icon(icons::PULL_REQUEST) + .size(px(12.0)) + .text_color(tone.opacity(0.9)), + ), ) .child( div() - .w_full() - .text_size(crate::typography::ui_rems(12.0)) - .line_height(px(16.0)) + .flex_1() + .min_w_0() .text_color(theme.text) .line_clamp(2) .child(model.title), ) + .child( + div() + .flex_none() + .font_family(theme.font_mono.clone()) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(tone.opacity(0.85)) + .child(SharedString::from(format!("#{}", model.number))), + ) }); let card = div() From 95865789fbca57be4e2619b088818bbcf939fb3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 21:53:23 -0300 Subject: [PATCH 4/8] Session hover card: drop the worktree folder and the status word The worktree folder's generated name added noise next to the branch, which already says where the session works, and the status word repeated what the row itself shows. The card keeps the title and its age, project and device, branch, and the pull request. --- crates/ui/src/shell.rs | 1 - crates/ui/src/shell/session_hover_card.rs | 83 ++--------------------- 2 files changed, 6 insertions(+), 78 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index e1655ade38..62a101467b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -8373,7 +8373,6 @@ impl Shell { .then(|| { self.render_session_hover_card( &id, - status_label.map(|label| (label, status_color)), time_ago.clone(), cx, ) diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs index e9afc7ef94..b9f2e2d4d4 100644 --- a/crates/ui/src/shell/session_hover_card.rs +++ b/crates/ui/src/shell/session_hover_card.rs @@ -1,6 +1,6 @@ //! The session hover card: dwell on a sidebar row and a frosted card floats //! out beside it with what the row has no room for — the whole title, the -//! project and device, the branch and worktree, and the pull request's title. +//! project and device, the branch, and the pull request's title. //! Compact rows drop all of that, so the card is where it lives on. //! //! The card waits out a short dwell so sweeping the pointer down the list @@ -91,25 +91,6 @@ impl SessionHoverCard { } } -/// The checkout folder's name when the chat runs outside its project's own -/// folder — a worktree — else `None`. A project-less chat in a plain folder, -/// or one in a subfolder of its project, has no worktree worth naming. -fn worktree_label(cwd: Option<&str>, project_path: Option<&str>) -> Option { - let cwd = cwd?.trim_end_matches(['/', '\\']); - let project = project_path?.trim_end_matches(['/', '\\']); - let inside = crate::pickers::same_folder_path(cwd, project) - || cwd - .strip_prefix(project) - .is_some_and(|rest| rest.starts_with(['/', '\\'])); - if cwd.is_empty() || inside { - return None; - } - cwd.rsplit(['/', '\\']) - .next() - .filter(|name| !name.is_empty()) - .map(str::to_owned) -} - /// "21h ago" for the card's footer line ("now" stays as it is). fn time_label(time_ago: &str) -> String { if time_ago == "now" { @@ -167,17 +148,16 @@ impl Shell { /// The card for `chat_id`'s row, mounted from the row itself: it floats /// from the row's right edge above everything else, clear of the - /// sidebar's clip. `status` is the row's own status word and color. + /// sidebar's clip. pub(super) fn render_session_hover_card( &self, chat_id: &str, - status: Option<(&'static str, gpui::Hsla)>, time_ago: SharedString, cx: &mut Context, ) -> Option { let theme = Theme::of(cx).for_popup(); let now = Utc::now(); - let (title, project, device, local, online, branch, worktree, change_request) = { + let (title, project, device, local, online, branch, change_request) = { let state = self.state.read(cx); let chat = state.chats.iter().find(|chat| chat.id == chat_id)?; let space = state.space_for_chat(chat); @@ -191,11 +171,6 @@ impl Shell { .map(str::trim) .filter(|branch| !branch.is_empty()) .map(|branch| SharedString::from(branch.to_owned())); - let worktree = worktree_label( - chat.cwd.as_deref(), - space.map(|space| space.path.as_str()), - ) - .map(SharedString::from); let change_request = state .change_request_for_chat(chat) .map(ChangeRequestBadgeModel::from_summary); @@ -213,7 +188,6 @@ impl Shell { local, online, branch, - worktree, change_request, ) }; @@ -244,7 +218,7 @@ impl Shell { .into_any_element() }; - // ── Title, then status and age on one quiet line. + // ── Title, then its age on one quiet line. let header = div() .flex() .flex_col() @@ -261,30 +235,12 @@ impl Shell { ) .child( div() - .flex() - .flex_row() - .items_center() - .gap(px(6.0)) .text_size(crate::typography::ui_rems(11.0)) .text_color(muted) - .when_some(status, |el, (label, color)| { - el.child( - div() - .flex() - .flex_row() - .items_center() - .gap(px(5.0)) - .text_color(color) - .font_weight(gpui::FontWeight::MEDIUM) - .child(div().size(px(6.0)).rounded_full().bg(color)) - .child(label), - ) - .child(div().text_color(muted.opacity(0.6)).child("·")) - }) .child(time_label(&time_ago)), ); - // ── Where it runs: project and device, branch, worktree. + // ── Where it runs: project and device, branch. let project_row = meta_row( match &project { Some(_) => self.render_project_icon(chat_id, 14.0, false, cx), @@ -329,8 +285,7 @@ impl Shell { .text_size(crate::typography::ui_rems(12.0)) .text_color(muted) .child(project_row) - .children(branch.map(|branch| meta_row(glyph(icons::GIT_BRANCH), branch))) - .children(worktree.map(|worktree| meta_row(glyph(icons::FOLDER), worktree))); + .children(branch.map(|branch| meta_row(glyph(icons::GIT_BRANCH), branch))); // ── The pull request on one line: its state lives in the tint and // the glyph's color (the badge's), so only the title and number @@ -493,32 +448,6 @@ mod tests { assert_eq!(card.shown(), None); } - #[test] - fn only_a_checkout_outside_the_project_folder_is_a_worktree() { - assert_eq!( - worktree_label( - Some("/home/me/.zeron/worktrees/comet/swift-aspen"), - Some("/home/me/code/comet") - ), - Some("swift-aspen".to_owned()) - ); - assert_eq!( - worktree_label(Some("/home/me/code/comet/"), Some("/home/me/code/comet")), - None - ); - assert_eq!( - worktree_label(Some("/home/me/code/comet/crates"), Some("/home/me/code/comet/")), - None - ); - // A sibling folder sharing the name's prefix is not inside it. - assert_eq!( - worktree_label(Some("/home/me/code/comet-old"), Some("/home/me/code/comet")), - Some("comet-old".to_owned()) - ); - assert_eq!(worktree_label(Some("/tmp/scratch"), None), None); - assert_eq!(worktree_label(None, Some("/home/me/code/comet")), None); - } - #[test] fn the_age_reads_as_a_sentence() { assert_eq!(time_label("21h"), "21h ago"); From 1d0b6ae15db109b65f7b7267dcdc67b8773954eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 22:06:38 -0300 Subject: [PATCH 5/8] Session hover card: show the device as a corner glyph The device's name sat beside the project as a second label. It is now only its glyph, in the card's top-right corner beside the title: a laptop for this device, the remote server for another, and the warning wifi-off while it is out of reach. --- crates/ui/src/shell/session_hover_card.rs | 98 +++++++++++------------ 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs index b9f2e2d4d4..f8e04e4fec 100644 --- a/crates/ui/src/shell/session_hover_card.rs +++ b/crates/ui/src/shell/session_hover_card.rs @@ -1,6 +1,6 @@ //! The session hover card: dwell on a sidebar row and a frosted card floats //! out beside it with what the row has no room for — the whole title, the -//! project and device, the branch, and the pull request's title. +//! device and project, the branch, and the pull request's title. //! Compact rows drop all of that, so the card is where it lives on. //! //! The card waits out a short dwell so sweeping the pointer down the list @@ -157,14 +157,11 @@ impl Shell { ) -> Option { let theme = Theme::of(cx).for_popup(); let now = Utc::now(); - let (title, project, device, local, online, branch, change_request) = { + let (title, project, local, online, branch, change_request) = { let state = self.state.read(cx); let chat = state.chats.iter().find(|chat| chat.id == chat_id)?; let space = state.space_for_chat(chat); let project = space.map(|space| SharedString::from(space.display_name().to_owned())); - let device = state - .device_name(&chat.device_id) - .map(|name| SharedString::from(name.to_owned())); let local = state.local_device_id.as_deref() == Some(chat.device_id.as_str()); let online = state.device_online(&chat.device_id, now); let branch = crate::change_requests::conversation_branch(chat, &state.spaces) @@ -184,7 +181,6 @@ impl Shell { ( title, project, - device, local, online, branch, @@ -218,29 +214,60 @@ impl Shell { .into_any_element() }; - // ── Title, then its age on one quiet line. + // ── The title and its age on one quiet line, the device's glyph in + // the corner (this one, another, or out of reach). + let device = icon(if !online { + icons::WIFI_OFF + } else if local { + icons::LAPTOP + } else { + icons::REMOTE_SERVER + }) + .size(px(13.0)) + .text_color(if online { + muted.opacity(0.8) + } else { + theme.warning.opacity(0.8) + }); let header = div() .flex() - .flex_col() - .gap(px(4.0)) + .flex_row() + .items_start() + .gap(px(8.0)) .child( div() - .w_full() - .text_size(crate::typography::ui_rems(13.0)) - .line_height(px(18.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(theme.text) - .line_clamp(3) - .child(title), + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(4.0)) + .child( + div() + .w_full() + .text_size(crate::typography::ui_rems(13.0)) + .line_height(px(18.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text) + .line_clamp(3) + .child(title), + ) + .child( + div() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(muted) + .child(time_label(&time_ago)), + ), ) .child( div() - .text_size(crate::typography::ui_rems(11.0)) - .text_color(muted) - .child(time_label(&time_ago)), + .h(px(18.0)) + .flex_none() + .flex() + .items_center() + .child(device), ); - // ── Where it runs: project and device, branch. + // ── Where it runs: project and branch. let project_row = meta_row( match &project { Some(_) => self.render_project_icon(chat_id, 14.0, false, cx), @@ -248,36 +275,7 @@ impl Shell { }, project.clone().unwrap_or_else(|| "No project".into()), ) - .text_color(theme.text) - .when_some(device, |row, device| { - row.child( - div() - .flex_none() - .max_w(px(120.0)) - .flex() - .flex_row() - .items_center() - .gap(px(4.0)) - .text_color(muted) - .child( - icon(if !online { - icons::WIFI_OFF - } else if local { - icons::LAPTOP - } else { - icons::REMOTE_SERVER - }) - .size(px(12.0)) - .flex_none() - .text_color(if online { - muted.opacity(0.8) - } else { - theme.warning.opacity(0.8) - }), - ) - .child(div().min_w_0().truncate().child(device)), - ) - }); + .text_color(theme.text); let location = div() .flex() .flex_col() From 0a8d8573de764d006fe155ee1b6def820eed7d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 22:13:37 -0300 Subject: [PATCH 6/8] Session hover card: list the pull request as a row under the branch The tinted PR block set the pull request apart from the rest of the card. It is now one more row under the project and branch, its glyph and trailing number still in the state's color (the badge's), the title truncated to one line like its neighbors. --- crates/ui/src/shell/session_hover_card.rs | 69 ++++++++--------------- 1 file changed, 24 insertions(+), 45 deletions(-) diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs index f8e04e4fec..7d8f76c116 100644 --- a/crates/ui/src/shell/session_hover_card.rs +++ b/crates/ui/src/shell/session_hover_card.rs @@ -1,6 +1,6 @@ //! The session hover card: dwell on a sidebar row and a frosted card floats //! out beside it with what the row has no room for — the whole title, the -//! device and project, the branch, and the pull request's title. +//! device and project, the branch, and the pull request. //! Compact rows drop all of that, so the card is where it lives on. //! //! The card waits out a short dwell so sweeping the pointer down the list @@ -276,6 +276,26 @@ impl Shell { project.clone().unwrap_or_else(|| "No project".into()), ) .text_color(theme.text); + // ── The pull request as one more row under the branch: its state + // lives in the glyph's and number's color (the badge's). + let pull_request = change_request.map(|model| { + let tone = model.tone.color(&theme); + meta_row( + icon(icons::PULL_REQUEST) + .size(px(13.0)) + .text_color(tone.opacity(0.9)) + .into_any_element(), + model.title, + ) + .child( + div() + .flex_none() + .font_family(theme.font_mono.clone()) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(tone.opacity(0.85)) + .child(SharedString::from(format!("#{}", model.number))), + ) + }); let location = div() .flex() .flex_col() @@ -283,48 +303,8 @@ impl Shell { .text_size(crate::typography::ui_rems(12.0)) .text_color(muted) .child(project_row) - .children(branch.map(|branch| meta_row(glyph(icons::GIT_BRANCH), branch))); - - // ── The pull request on one line: its state lives in the tint and - // the glyph's color (the badge's), so only the title and number - // need words. - let pull_request = change_request.map(|model| { - let tone = model.tone.color(&theme); - div() - .flex() - .flex_row() - .items_start() - .gap(px(7.0)) - .px(px(9.0)) - .py(px(7.0)) - .rounded(px(8.0)) - .bg(tone.opacity(0.08)) - .text_size(crate::typography::ui_rems(12.0)) - .line_height(px(16.0)) - .child( - div().h(px(16.0)).flex_none().flex().items_center().child( - icon(icons::PULL_REQUEST) - .size(px(12.0)) - .text_color(tone.opacity(0.9)), - ), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_color(theme.text) - .line_clamp(2) - .child(model.title), - ) - .child( - div() - .flex_none() - .font_family(theme.font_mono.clone()) - .text_size(crate::typography::ui_rems(11.0)) - .text_color(tone.opacity(0.85)) - .child(SharedString::from(format!("#{}", model.number))), - ) - }); + .children(branch.map(|branch| meta_row(glyph(icons::GIT_BRANCH), branch))) + .children(pull_request); let card = div() .w(px(CARD_WIDTH)) @@ -339,8 +319,7 @@ impl Shell { .bg(popover::surface_bg(&theme)) .when(!theme.is_frost(), |card| card.shadow_lg()) .child(header) - .child(location) - .children(pull_request); + .child(location); let card = div() .debug_selector({ let chat_id = chat_id.to_owned(); From 2db8a14045d356fc641bfbfcaf2a851df2d30118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 13 Oct 2026 23:24:01 -0300 Subject: [PATCH 7/8] Fix session hover card lifecycle and preserve PR tooltips --- crates/ui/src/change_requests.rs | 26 +- crates/ui/src/shell.rs | 119 +++++---- crates/ui/src/shell/session_hover_card.rs | 302 ++++++++++++++++++++++ crates/ui/src/shell/spaces.rs | 36 +++ 4 files changed, 430 insertions(+), 53 deletions(-) diff --git a/crates/ui/src/change_requests.rs b/crates/ui/src/change_requests.rs index 89d86de223..a60ee0e29d 100644 --- a/crates/ui/src/change_requests.rs +++ b/crates/ui/src/change_requests.rs @@ -67,6 +67,7 @@ impl Render for ChangeRequestTooltip { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx); let card = div() + .debug_selector(|| "change-request-tooltip".into()) .max_w(px(320.0)) .px(px(9.0)) .py(px(7.0)) @@ -104,6 +105,8 @@ impl Render for ChangeRequestTooltip { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ChangeRequestBadgeSurface { Sidebar, + /// A sidebar session row supplies the PR details in its hover card. + SessionRow, Composer, } @@ -195,6 +198,10 @@ fn render_badge_model( let accessible_label = format!("Open pull request {}: {}", model.number, model.title); div() + .debug_selector({ + let id = id.clone(); + move || format!("pull-request-badge-{id}") + }) .id(id) .h(px(if composer { 20.0 } else { 16.0 })) .flex_none() @@ -221,15 +228,18 @@ fn render_badge_model( }) }) // Sidebar rows spell the pull request out in their hover card. - .when(interactive && composer, |el| { - el.tooltip(move |_, cx| { - cx.new(|_| ChangeRequestTooltip { - model: tooltip_model.clone(), + .when( + interactive && surface != ChangeRequestBadgeSurface::SessionRow, + |el| { + el.tooltip(move |_, cx| { + cx.new(|_| ChangeRequestTooltip { + model: tooltip_model.clone(), + }) + .into() }) - .into() - }) - .tooltip_show_delay(std::time::Duration::from_millis(350)) - }) + .tooltip_show_delay(std::time::Duration::from_millis(350)) + }, + ) .child( crate::icons::icon(crate::icons::PULL_REQUEST) .size(px(if composer { 11.0 } else { 10.0 })) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 62a101467b..889728eb97 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -8058,6 +8058,11 @@ impl Shell { .find(|chat| chat.id == id) .map(|chat| chat.device_id.clone()); let compact = search_query.is_none() && self.settings.sidebar_compact; + let pr_surface = if search_query.is_some() { + crate::change_requests::ChangeRequestBadgeSurface::Sidebar + } else { + crate::change_requests::ChangeRequestBadgeSurface::SessionRow + }; let show_label = search_query.is_some() || self.settings.sidebar_show_project_label; let remote = self .state @@ -8334,18 +8339,24 @@ impl Shell { .items_center() .when(!preview, |el| el.cursor_pointer()) .when(corner_hovered, |el| { - el.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(cx.listener(move |this, _, _, cx| { + el.on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.dismiss_session_card(cx); cx.stop_propagation(); - this.chat_hover_resync = true; - this.set_chat_archived(archive_id.clone(), !archived, cx); - })) - // Above the pill: below it the chip would cover the next row. - .tooltip(crate::settings::widgets::text_tooltip_above(if archived { - "Unarchive session" - } else { - ShortcutId::ArchiveSession.label() - })) + }), + ) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.chat_hover_resync = true; + this.set_chat_archived(archive_id.clone(), !archived, cx); + })) + // Above the pill: below it the chip would cover the next row. + .tooltip(crate::settings::widgets::text_tooltip_above(if archived { + "Unarchive session" + } else { + ShortcutId::ArchiveSession.label() + })) }) .child(corner_body) .into_any_element() @@ -8544,34 +8555,40 @@ impl Shell { .inset_0(), ) }) - // gpui sends no hover-out to a row that unmounts or moves away - // under a still pointer (a collapsed sidebar, a resort), so the - // shown card re-checks its row by paint and hides once it lost it. - .when(hover_card.is_some(), |el| { - let shell = cx.weak_entity(); - let chat_id = id.clone(); - el.child( - gpui::canvas( - |bounds, window, _| { - window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) - }, - move |_, hitbox, window, cx| { - if hitbox.is_hovered(window) { - return; - } - window.defer(cx, move |_, cx| { - shell - .update(cx, |this, cx| { - this.hover_session_card(&chat_id, false, cx) - }) - .ok(); - }); - }, + // Check both shown and pending cards after layout/occlusion + // changes under a stationary pointer. Unmounted rows are handled + // by the container's reconciliation. + .when( + !preview + && search_query_none + && rename_input.is_none() + && self.session_hover_card.target() == Some(id.as_str()), + |el| { + let shell = cx.weak_entity(); + let chat_id = id.clone(); + el.child( + gpui::canvas( + |bounds, window, _| { + window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) + }, + move |_, hitbox, window, cx| { + if hitbox.is_hovered(window) { + return; + } + window.defer(cx, move |_, cx| { + shell + .update(cx, |this, cx| { + this.hover_session_card(&chat_id, false, cx) + }) + .ok(); + }); + }, + ) + .absolute() + .inset_0(), ) - .absolute() - .inset_0(), - ) - }) + }, + ) .children(hover_card) // Line 1: "project @ device", status word / time-ago right. .when(!compact && show_label, |el| { @@ -8687,14 +8704,14 @@ impl Shell { crate::change_requests::pull_request_badge_preview( format!("{row_id}-compact-pr").into(), summary, - crate::change_requests::ChangeRequestBadgeSurface::Sidebar, + pr_surface, theme, ) } else { crate::change_requests::pull_request_badge( format!("{row_id}-compact-pr").into(), summary, - crate::change_requests::ChangeRequestBadgeSurface::Sidebar, + pr_surface, pr_device.clone(), theme, ) @@ -8726,9 +8743,13 @@ impl Shell { }) .when(show_archive, |el| { el.cursor_pointer() - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation() - }) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.dismiss_session_card(cx); + cx.stop_propagation(); + }), + ) .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); this.chat_hover_resync = true; @@ -8805,14 +8826,14 @@ impl Shell { crate::change_requests::pull_request_badge_preview( format!("{row_id}-pr").into(), summary, - crate::change_requests::ChangeRequestBadgeSurface::Sidebar, + pr_surface, theme, ) } else { crate::change_requests::pull_request_badge_with_query( format!("{row_id}-pr").into(), summary, - crate::change_requests::ChangeRequestBadgeSurface::Sidebar, + pr_surface, search_query, pr_device.clone(), theme, @@ -8993,6 +9014,7 @@ impl Shell { } fn render_chat_sidebar(&mut self, theme: &Theme, cx: &mut Context) -> AnyElement { + self.reconcile_session_hover_card(true, cx); if self.sidebar_session_transfer.as_ref().is_some_and(|drag| { !cx.has_active_drag() || !self.sidebar_session_transfer_is_valid(&drag.payload, cx) }) { @@ -13357,6 +13379,13 @@ impl Render for Shell { self.reduced_motion = motion::reduced_motion(cx); self.motion_active.set(false); self.track_horizontal_fit(cx); + self.reconcile_session_hover_card( + matches!(gate, GatePhase::Ready) + && !restart_required + && !matches!(self.route, Route::Settings(_)) + && self.sidebar_target() > 0.0, + cx, + ); self.suspend_hidden_right_images(cx); // After the fit tracking, which starts a fit exit this frame. let browser_active = matches!(gate, GatePhase::Ready) diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs index 7d8f76c116..6ecd8690dd 100644 --- a/crates/ui/src/shell/session_hover_card.rs +++ b/crates/ui/src/shell/session_hover_card.rs @@ -34,6 +34,11 @@ impl SessionHoverCard { self.shown.as_deref() } + pub(super) fn target(&self) -> Option<&str> { + self.shown() + .or_else(|| self.pending.as_ref().map(|(id, _)| id.as_str())) + } + /// The pointer entered `chat_id`'s row. True when the card should show /// at once: another card is up, or one hid moments ago. fn enter(&mut self, chat_id: &str, now: Instant) -> bool { @@ -101,6 +106,30 @@ fn time_label(time_ago: &str) -> String { } impl Shell { + /// Reconcile from the container as well as the row: an unmounted row + /// cannot report hover-out or run its paint-time hit test. + pub(super) fn reconcile_session_hover_card( + &mut self, + sidebar_visible: bool, + cx: &mut Context, + ) { + if !sidebar_visible { + self.dismiss_session_card(cx); + return; + } + let Some(target) = self.session_hover_card.target() else { + return; + }; + if !self.sidebar_hover_card_row_visible(target, cx) + || self + .rename_input_for(target, ChatRenameSurface::Sidebar) + .is_some() + || cx.has_active_drag() + { + self.dismiss_session_card(cx); + } + } + /// Row hover in or out (the row's own hover listener forwards here). pub(super) fn hover_session_card( &mut self, @@ -138,6 +167,9 @@ impl Shell { } }); self.session_hover_card.pending = Some((target, task)); + // Mount the hit test while waiting too, so moving/clipping the row + // under a stationary pointer cancels the dwell before it settles. + cx.notify(); } pub(super) fn dismiss_session_card(&mut self, cx: &mut Context) { @@ -363,6 +395,276 @@ impl Shell { #[cfg(test)] mod tests { use super::*; + use gpui::{AppContext, TestAppContext, VisualTestContext}; + + fn test_chat(id: &str) -> zeron_proto::Chat { + serde_json::from_value(serde_json::json!({ + "id": id, "title": id, "deviceId": "local", "archived": false, + "createdAt": Utc::now(), + })) + .unwrap() + } + + fn test_shell(data_dir: &std::path::Path, cx: &mut Context) -> Shell { + let state = cx.new(|_| { + let mut state = AppState::new(); + state.workspace_scope = Some(WorkspaceScope::Development); + state.connection = ConnectionStatus::Ready; + state.chats = vec![test_chat("hover-test"), test_chat("next")]; + state + }); + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: data_dir.into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ); + shell.debug_gate = Some(GatePhase::Ready); + shell.reduced_motion = true; + shell + } + + fn init_test(cx: &mut TestAppContext, dir: &std::path::Path, compact: bool) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + let mut ui_settings = settings::UiSettings::default(); + ui_settings.sidebar_compact = compact; + settings::init(ui_settings, dir, cx); + motion::set_reduced_motion(cx, true); + }); + } + + struct RowHost { + shell: Entity, + palette: bool, + _observation: Subscription, + _dir: tempfile::TempDir, + } + + impl Render for RowHost { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let palette = self.palette; + self.shell.update(cx, |shell, cx| { + div().w(px(280.0)).h(px(200.0)).child(shell.render_chat_row( + "hover-test".into(), + "Hover test".into(), + "now".into(), + "Project".into(), + None, + Some(zeron_proto::ChangeRequestSummary { + provider: "github".into(), + number: 90, + title: "Pull request title".into(), + url: "https://github.com/acme/zeron/pull/90".into(), + state: zeron_proto::ChangeRequestState::Open, + base_ref: "main".into(), + head_ref: "feature/pr".into(), + }), + None, + zeron_proto::ChatIndicator::Idle, + false, + false, + false, + None, + None, + false, + palette.then_some(""), + &Theme::default(), + cx, + )) + }) + } + } + + fn row_setup( + cx: &mut TestAppContext, + compact: bool, + palette: bool, + ) -> (Entity, &mut VisualTestContext) { + let dir = tempfile::tempdir().unwrap(); + init_test(cx, dir.path(), compact); + let (host, cx) = cx.add_window_view(|_, cx| { + let shell = cx.new(|cx| test_shell(dir.path(), cx)); + let observation = cx.observe(&shell, |_, _, cx| cx.notify()); + RowHost { + shell, + palette, + _observation: observation, + _dir: dir, + } + }); + let shell = host.read_with(cx, |host, _| host.shell.clone()); + redraw(cx); + (shell, cx) + } + + fn redraw(cx: &mut VisualTestContext) { + cx.run_until_parked(); + // Cached frames omit debug selectors; refresh before inspecting + // either the row's bounds or the absence of a floating card. + cx.refresh().unwrap(); + cx.update(|window, cx| window.draw(cx).clear()); + cx.run_until_parked(); + } + + fn wait_for_card(cx: &mut VisualTestContext) { + cx.executor() + .advance_clock(DWELL + Duration::from_millis(1)); + redraw(cx); + } + + #[gpui::test] + fn archive_press_cancels_pending_and_shown_cards_in_both_layouts(cx: &mut TestAppContext) { + for compact in [false, true] { + for shown in [false, true] { + let (shell, cx) = row_setup(cx, compact, false); + let row = cx.debug_bounds("chat-hover-test").unwrap().center(); + cx.simulate_mouse_move(row, None, gpui::Modifiers::default()); + redraw(cx); + if shown { + wait_for_card(cx); + } + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), Some("hover-test")); + assert_eq!(shell.session_hover_card.shown().is_some(), shown); + }); + let corner = if compact { + "chat-time-hover-test" + } else { + "chat-hover-test-corner" + }; + let position = cx.debug_bounds(corner).unwrap().center(); + cx.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default()); + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), None) + }); + wait_for_card(cx); + assert!(cx.debug_bounds("session-hover-card-hover-test").is_none()); + } + } + } + + #[gpui::test] + fn palette_pr_badge_keeps_its_tooltip_without_a_session_card(cx: &mut TestAppContext) { + let (shell, cx) = row_setup(cx, false, true); + let badge = cx + .debug_bounds("pull-request-badge-palette-chat-hover-test-pr") + .unwrap() + .center(); + cx.simulate_mouse_move(badge, None, gpui::Modifiers::default()); + redraw(cx); + wait_for_card(cx); + assert!(cx.debug_bounds("change-request-tooltip").is_some()); + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), None) + }); + } + + #[gpui::test] + fn sidebar_pr_badge_uses_the_session_card_instead_of_a_second_tooltip(cx: &mut TestAppContext) { + let (_, cx) = row_setup(cx, false, false); + let badge = cx + .debug_bounds("pull-request-badge-chat-hover-test-pr") + .unwrap() + .center(); + cx.simulate_mouse_move(badge, None, gpui::Modifiers::default()); + redraw(cx); + wait_for_card(cx); + assert!(cx.debug_bounds("session-hover-card-hover-test").is_some()); + assert!(cx.debug_bounds("change-request-tooltip").is_none()); + } + + fn shell_setup( + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + init_test(cx, dir.path(), false); + let (shell, cx) = cx.add_window_view(|_, cx| test_shell(dir.path(), cx)); + cx.simulate_resize(gpui::size(px(1400.0), px(800.0))); + redraw(cx); + (shell, cx, dir) + } + + #[gpui::test] + fn removing_a_pending_row_cancels_its_dwell_and_keeps_the_next_card_cold( + cx: &mut TestAppContext, + ) { + let (shell, cx, _dir) = shell_setup(cx); + let row = cx.debug_bounds("chat-hover-test").unwrap().center(); + cx.simulate_mouse_move(row, None, gpui::Modifiers::default()); + redraw(cx); + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), Some("hover-test")) + }); + shell.update(cx, |shell, cx| { + shell.state.update(cx, |state, cx| { + state.chats.retain(|chat| chat.id != "hover-test"); + cx.notify(); + }); + cx.notify(); + }); + redraw(cx); + let next = cx.debug_bounds("chat-next").unwrap().center(); + wait_for_card(cx); + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), None) + }); + // Leave the old hit region before entering the surviving row. + cx.simulate_mouse_move( + gpui::point(px(600.0), px(700.0)), + None, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move(next, None, gpui::Modifiers::default()); + redraw(cx); + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), Some("next")); + assert_eq!(shell.session_hover_card.shown(), None); + }); + } + + #[gpui::test] + fn settings_navigation_cancels_pending_and_shown_cards(cx: &mut TestAppContext) { + for shown in [false, true] { + let (shell, cx, _dir) = shell_setup(cx); + let row = cx.debug_bounds("chat-hover-test").unwrap().center(); + cx.simulate_mouse_move(row, None, gpui::Modifiers::default()); + redraw(cx); + if shown { + wait_for_card(cx); + } + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.shown().is_some(), shown) + }); + shell.update(cx, |shell, cx| { + shell.open_settings(SettingsSection::General, cx) + }); + redraw(cx); + wait_for_card(cx); + shell.read_with(cx, |shell, _| { + assert_eq!(shell.session_hover_card.target(), None) + }); + shell.update(cx, |shell, cx| shell.close_settings(cx)); + redraw(cx); + assert!(cx.debug_bounds("session-hover-card-hover-test").is_none()); + } + } fn card() -> SessionHoverCard { SessionHoverCard::default() diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index cb3154ac90..93101dbde5 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -4856,6 +4856,42 @@ impl Shell { visible } + /// Hover targets include the paged Archived shelf, unlike jump shortcuts. + pub(super) fn sidebar_hover_card_row_visible(&self, chat_id: &str, cx: &Context) -> bool { + if self + .sidebar_visible_order(cx) + .iter() + .any(|id| id == chat_id) + { + let in_own_section = self.active_sidebar_pins(cx).iter().any(|id| id == chat_id) + || self + .active_sidebar_sections(cx) + .iter() + .any(|section| section.session_ids.iter().any(|id| id == chat_id)); + if in_own_section { + return true; + } + let state = self.state.read(cx); + return state + .chats + .iter() + .find(|chat| chat.id == chat_id) + .is_some_and(|chat| { + self.sidebar_group_key(state, chat).is_none_or(|group| { + !self + .sidebar_collapsed_groups + .contains(&self.sidebar_group_collapse_key(&group)) + }) + }); + } + self.archived_open + && self + .archived_sidebar_chats(cx) + .iter() + .take(self.archived_shown.max(ARCHIVED_INITIAL_ROWS)) + .any(|chat| chat.id == chat_id) + } + /// The group a chat's row sits under when the sidebar groups by device or /// by project; `None` in one list. fn sidebar_group_key(&self, state: &AppState, chat: &zeron_proto::Chat) -> Option { From a39d1eeea7004b1e9c0dfccdb3dcb4953f961df7 Mon Sep 17 00:00:00 2001 From: Wing Date: Sat, 17 Oct 2026 10:46:45 -0700 Subject: [PATCH 8/8] Session hover card: reconcile once per frame, in the sidebar's render The shell's render re-derived the sidebar's visible order (cloning and sorting every chat) to check the card's row, then the sidebar's render did it again. The sidebar pane re-renders on every shell notify, so the shell now only dismisses the card while the sidebar is hidden. Co-Authored-By: Claude Opus 5.5 --- crates/ui/src/shell.rs | 17 +++++++++-------- crates/ui/src/shell/session_hover_card.rs | 14 ++++---------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 889728eb97..9cd85dd162 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -9014,7 +9014,7 @@ impl Shell { } fn render_chat_sidebar(&mut self, theme: &Theme, cx: &mut Context) -> AnyElement { - self.reconcile_session_hover_card(true, cx); + self.reconcile_session_hover_card(cx); if self.sidebar_session_transfer.as_ref().is_some_and(|drag| { !cx.has_active_drag() || !self.sidebar_session_transfer_is_valid(&drag.payload, cx) }) { @@ -13379,13 +13379,14 @@ impl Render for Shell { self.reduced_motion = motion::reduced_motion(cx); self.motion_active.set(false); self.track_horizontal_fit(cx); - self.reconcile_session_hover_card( - matches!(gate, GatePhase::Ready) - && !restart_required - && !matches!(self.route, Route::Settings(_)) - && self.sidebar_target() > 0.0, - cx, - ); + // The sidebar's render reconciles the card against its rows. + if !matches!(gate, GatePhase::Ready) + || restart_required + || matches!(self.route, Route::Settings(_)) + || self.sidebar_target() <= 0.0 + { + self.dismiss_session_card(cx); + } self.suspend_hidden_right_images(cx); // After the fit tracking, which starts a fit exit this frame. let browser_active = matches!(gate, GatePhase::Ready) diff --git a/crates/ui/src/shell/session_hover_card.rs b/crates/ui/src/shell/session_hover_card.rs index 6ecd8690dd..aadd4d465e 100644 --- a/crates/ui/src/shell/session_hover_card.rs +++ b/crates/ui/src/shell/session_hover_card.rs @@ -107,16 +107,10 @@ fn time_label(time_ago: &str) -> String { impl Shell { /// Reconcile from the container as well as the row: an unmounted row - /// cannot report hover-out or run its paint-time hit test. - pub(super) fn reconcile_session_hover_card( - &mut self, - sidebar_visible: bool, - cx: &mut Context, - ) { - if !sidebar_visible { - self.dismiss_session_card(cx); - return; - } + /// cannot report hover-out or run its paint-time hit test. Runs in the + /// sidebar's render, which follows every shell notify; the shell itself + /// only dismisses the card while the sidebar is hidden. + pub(super) fn reconcile_session_hover_card(&mut self, cx: &mut Context) { let Some(target) = self.session_hover_card.target() else { return; };