From d65b513f23d79d72f70bcd3b0ca9c8c229e9ce08 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 7 Jun 2026 22:41:21 -0500 Subject: [PATCH] feat(desktop): hover-reveal collapsed sidebars as fixed overlays (#41670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay When the sessions sidebar is collapsed, hovering the left edge now floats it back in as a fixed overlay over the main content instead of just being hidden. The collapsed grid track stays at 0px so the panel never reserves space — it slides over whatever's underneath and retracts on pointer-leave. - PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal, render an edge hot-zone + a side-anchored floating panel (absolute, full height, honors any persisted resize width) that slides in on hover/focus. - ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully visible + interactive while the overlay is revealed, via an in-data-[pane-hover-reveal=open] variant. - desktop-controller: opt the chat-sidebar pane into hoverReveal. * feat(desktop): lower window minWidth 900→400 Lets the window shrink to a narrow rail (e.g. for the collapsed hover-reveal sidebar) instead of being floored at 900px. * fix(desktop): render full sidebar content in hover-reveal overlay The hover-reveal overlay showed only the nav rail — session rows, search, pinned/recents were gated behind `sidebarOpen` (false while collapsed), so they never mounted in the floated panel. Add a $sidebarRevealed store the PaneShell overlay drives via a new onHoverRevealChange callback, and gate ChatSidebar's content on `sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open state. The overlay now shows the complete sidebar. * fix(desktop): drop shadow on hover-reveal sidebar overlay * feat(desktop): hover-reveal the file-browser sidebar too The reveal mechanism already lives in the shared Pane primitive — the right rail just opts in with hoverReveal. Its content renders unconditionally, so (unlike the chat sidebar) it needs no extra content-visibility gating. * clean(desktop): tighten hover-reveal pane code KISS pass — flatten the translate ternary, derive a single `revealed`, inline the edge style, drop the redundant set-guard, and trim comments to the house one-liner style. No behavior change. * fix(desktop): stop hiding sidebar nav labels on narrow windows The nav labels (New session, Skills, …) and the ⌘N hint were gated on a viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid them even when the sidebar itself was wide — including in the hover-reveal overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it ellipsizes gracefully in a narrow rail, and contentVisible already hides it when collapsed to the icon rail. * feat(desktop): auto-collapse both sidebars below 600px into hover-reveal Add a Pane `forceCollapsed` prop — collapses the track without writing to the store (so the saved open state restores when the window widens) while keeping hoverReveal alive (unlike `disabled`, which suppresses it). desktop-controller watches (max-width: 600px) and force-collapses the chat sidebar + file browser, so on a narrow window both rails get out of the way and the hover-reveal overlay becomes the way in. * feat(desktop): hover-intent + refined easing for sidebar reveal - Gate the reveal on pointer velocity: the full-height edge hot-zone now only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward the titlebar/statusbar — or off the window — blow past the threshold and never trigger, so the wide hit area stops being a nuisance. - Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out, soft-land) for a more serious-app feel. * fix(desktop): don't reveal sidebar during window resize Resizing the window parks the cursor on the screen edge and fires slow pointermoves over the hot-zone, reading as deliberate intent. Guard the reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize — and (b) a 250ms cooldown after any window resize event. * feat(desktop): hoverIntent-style poll gate + inert contents during slide Replace the single-sample velocity check (too eager — fired on any one slow move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll the pointer every 90ms and only arm once it has *settled* (moved <5px between two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and resize drift never produce two close samples in a row, so they don't trigger. Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in transition finishes (onTransitionEnd → settled), so you can't misclick a session row mid-animation. Resets on retract. * fix(desktop): no cursor/hit-test leak before reveal settles The edge hot-zone showed cursor:pointer the instant the pointer touched it — before the panel was armed or in view. And contents were inert but the panel itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is cursor-default (it's invisible), and the whole panel is pointer-events-none until revealed && settled, so the cursor never changes or lands on a row before the slide-in finishes. * fix(desktop): geometry-driven close so revealed panel always retracts The revealed panel relied on its own onPointerLeave to close — but a panel that slid in under a still cursor (or whose contents were inert during the slide) never fires enter/leave, so it got stuck open (esp. the file browser). onTransitionEnd also bubbled from the file-tree's own row transitions, tripping the settled flag wrongly. Replace with a document-level pointermove watcher that closes once the cursor leaves the panel's bounding rect + a 24px grace — independent of pointer-events state or what the contents do. Gate interactivity on a simple slide-duration timer (interactive) instead of the fragile transitionEnd, so the cursor still can't flip or land on a row before the panel is in view. * feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they flipped the store but the pane ignores it. Now the toggle handlers also dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while overlayActive) and flips its hover-reveal, so the shortcut floats the rail in (and back out) at this responsive breakpoint. * refactor(desktop): name the 600px sidebar collapse breakpoint Hoist the inline '(max-width: 600px)' literal into SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in layout-constants, so the responsive collapse point is a single named source of truth instead of a magic string in the controller. * tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px 768 is the standard md breakpoint and a more honest 'no room to dock' point. * tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms * Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms" This reverts commit 6009a132008105f5f871370d554a5097be3090af. * perf(desktop): pre-mount hover-reveal contents to kill slide-in stall The reveal mounted the (heavy, virtualized) sidebar contents in the same frame the slide started, so the browser stalled painting the transform until the mount finished — a ~100-200ms beat before the panel moved, very visible on the instant keyboard toggle (hover masked it via the 90ms intent poll). Report overlayActive (collapsed-overlay mode) rather than the live reveal state to the mount consumer, so contents stay mounted off-screen while collapsed and reveal is a pure transform. Visibility is still driven separately by the data-pane-hover-reveal attr + the slide transform. * fix(desktop): make reveal hotkey spammable Two throttles on the reveal toggle: - The handler fired both the reveal event AND toggleSidebarOpen() per press; the store write hits localStorage synchronously every keystroke + recomputes the grid, janking rapid presses. When collapsed, only dispatch the reveal event (the store toggle was a no-op anyway). - The geometry close-watcher slammed a keyboard-opened panel shut on the first stray pointermove (trackpad jitter), fighting hotkey spam. Keyboard reveals now ignore geometry until the cursor actually enters the panel, then the mouse takes over. * fix(desktop): inset reveal hot-zone past the OS window-resize gutter The hot-zone sat flush at the window edge (left-0/right-0), overlapping the OS resize grab strip — reaching to drag-resize naturally slows the cursor there, which hoverIntent reads as settled and reveals before the resize drag even starts. Inset the hot-zone 8px so the outermost edge stays a pure resize/drag region and only an intentful move just inside it arms a reveal. * fix(desktop): keep reveal hot-zone at edge, gate arming past resize gutter Insetting the hot-zone made it unreachable when moving fast. Instead, anchor the zone flush at the edge (w-4, always captures the pointer) but only ARM the reveal when the cursor settles >=8px in from the edge — so a resize-reach that parks on the outermost OS grab strip never triggers, while a deliberate move into the zone still does. Keeps polling while in the gutter so moving inward still arms. * refactor(desktop): rebuild hover-reveal as pure CSS, delete the JS state machine The hand-rolled pointer state machine (hoverIntent poll, refs, timers, document pointermove geometry-close, interactive gate, resize cooldowns, keyboard-held suppression) was fragile and side/instance-specific — hover broke on the right rail, keyboard toggles triggered phantom animations, resize popped it open. Replace all of it with the native primitive: CSS group-hover drives the slide transform; a transition-delay on enter (instant on leave) is the hover-intent gate (a fast pass-by doesn't dwell long enough to open); a thin edge trigger inset past the OS resize grab strip arms it; and a single `forced` bool (data-forced, toggled by the keyboard event) pins it open. Side-agnostic by construction — group-hover doesn't care which edge or which pane. Net: ~200 lines of imperative pointer logic → ~40 lines of declarative CSS. * fix(desktop): don't animate hover-reveal panel across viewport on side flip Flipping panes changed the off-screen transform from -translateX (off the left) to +translateX (off the right). transition-transform interpolated between them, passing through translate-x-0 (fully on-screen) mid-way — so the hidden panel visibly slid across the window to reach its new hiding spot. Key the panel on side so it remounts off-screen on the new edge with no transition to play. * clean(desktop): tighten hover-reveal markup KISS pass on the CSS-driven reveal: reuse the existing `side` instead of a local `left`, move the static duration/ease to inline style (drop two single-use CSS vars + their arbitrary-value classes, keep only the state-dependent enter-delay var), and trim comments to the house one-liner density. No behavior change. * fix(desktop): inset titlebar past traffic lights when sidebar is force-collapsed The titlebar content inset (clearing the macOS traffic lights) keyed off the stored sidebarOpen/fileBrowserOpen, but below the collapse breakpoint both rails are force-collapsed so the left edge is uncovered while the store still says open — content (the intro wordmark) overflowed under the lights. Gate leftEdgePaneOpen on !narrowViewport using the shared SIDEBAR_COLLAPSE_MEDIA_QUERY. Also rename the now-misleading reveal plumbing to match what it actually does: onHoverRevealChange -> onOverlayActiveChange, $sidebarRevealed -> $sidebarOverlayMounted (+ setter/consumer). It reports/stores collapsed-overlay mode (mount gate), not live reveal state. * feat(desktop): small --nous-shadow lift on revealed hover-reveal panels Add a --nous-shadow token (white-based on light, black-based on dark) and apply it to the floating sidebar panel only while revealed (group-hover / data-forced) so it reads as lifted off the surface. No shadow on the off-screen panel. * feat(desktop): shadow-reveal lift on revealed hover-reveal panels Mirror the --shadow-nous layered falloff into a new --shadow-reveal token whose drop color flips per mode (white on light, black on dark) via --shadow-reveal-raw set in :root / :root.dark. Apply the generated shadow-reveal utility to the floated panel only while revealed (group-hover / data-forced). Leaves the shared --shadow-nous untouched. * feat(desktop): use tuned reveal shadow, drop per-mode token Replace the --shadow-reveal token machinery with Brooklyn's tuned literal (0 -18px 18px -5px #0000003b) inline per-panel via --reveal-shadow, y-offset sign flipped for the right side. Same color both modes. Reverts styles.css to pristine (token removed). * fix(desktop): use the reveal shadow verbatim, don't invert it per side Flipping the y-offset sign for the right side inverted the shadow's direction (cast-up -> cast-down), making it read heavier — not a mirror. The mirror axis for a left/right panel is offset-x, which is 0 here, so both sides take the tuned value as-is: 0 -18px 18px -5px #0000003b. * clean(desktop): hoist reveal shadow to a named const Move the inline reveal-shadow literal to HOVER_REVEAL_SHADOW alongside the other HOVER_REVEAL_* tuning consts; drop the now-stale per-side comment. * fix(desktop): truncate titlebar title before the right tool cluster The session title used a hardcoded max-w-[52vw] that's blind to where the right-side tools start, so it ran under them at narrow widths / with pane tools present. Bound the title container by the same vars the titlebar drag region uses (--titlebar-content-inset + --titlebar-tools-right + --titlebar-tools-width) so it truncates exactly at the cluster's left edge. * fix(desktop): responsive markdown tables — floor width + nowrap headers The wrapper had overflow-x-auto but the table was w-full with auto layout, so instead of scrolling it crushed columns until even header words broke mid-word (Tim/e, Nig/ht). Add a min-w-[18rem] floor so it scrolls horizontally when the column is narrower than readable, and whitespace-nowrap on th so headers never break mid-word. Above the floor it still wraps cells naturally. * fix intro --- apps/desktop/electron/main.cjs | 2 +- apps/desktop/src/app/chat/index.tsx | 9 +- apps/desktop/src/app/chat/sidebar/index.tsx | 30 +++-- apps/desktop/src/app/desktop-controller.tsx | 13 ++ apps/desktop/src/app/hooks/use-keybinds.ts | 21 ++- apps/desktop/src/app/layout-constants.ts | 6 + apps/desktop/src/app/shell/app-shell.tsx | 10 +- .../components/assistant-ui/markdown-text.tsx | 4 +- .../src/components/assistant-ui/thread.tsx | 9 +- apps/desktop/src/components/chat/intro.tsx | 6 +- .../src/components/pane-shell/index.ts | 2 +- .../src/components/pane-shell/pane-shell.tsx | 126 +++++++++++++++++- apps/desktop/src/store/layout.ts | 9 ++ apps/desktop/src/styles.css | 44 +++--- 14 files changed, 226 insertions(+), 65 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 32634e3ac41..35abc987d87 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4614,7 +4614,7 @@ function createWindow() { mainWindow = new BrowserWindow({ width: 1220, height: 800, - minWidth: 900, + minWidth: 400, minHeight: 620, title: 'Hermes', // Frameless title bar on every platform so the renderer can paint the diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 572e1360a2c..4a0d3829c39 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -124,7 +124,10 @@ function ChatHeader({ return (
-
+
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ef1832837f3..dcc516deadc 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -47,6 +47,7 @@ import { $sidebarAgentsGrouped, $sidebarCronOpen, $sidebarOpen, + $sidebarOverlayMounted, $sidebarPinsOpen, $sidebarRecentsOpen, pinSession, @@ -247,6 +248,9 @@ export function ChatSidebar({ const { t } = useI18n() const s = t.sidebar const sidebarOpen = useStore($sidebarOpen) + // Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail. + const overlayMounted = useStore($sidebarOverlayMounted) + const contentVisible = sidebarOpen || overlayMounted const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) const pinnedSessionIds = useStore($pinnedSessionIds) @@ -580,7 +584,11 @@ export function ChatSidebar({ panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0', sidebarOpen ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100' - : 'pointer-events-none border-transparent bg-transparent opacity-0' + : 'pointer-events-none border-transparent bg-transparent opacity-0', + // While floated by PaneShell's hover-reveal, force visible + interactive + // — on hover (group-hover/reveal) or when keyboard-pinned (data-forced). + 'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100', + 'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100' )} collapsible="none" > @@ -624,14 +632,14 @@ export function ChatSidebar({ type="button" > - {sidebarOpen && ( + {contentVisible && ( <> - + {s.nav[item.id] ?? item.label} {isNewSession && ( )} @@ -645,7 +653,7 @@ export function ChatSidebar({ - {sidebarOpen && showSessionSections && ( + {contentVisible && showSessionSections && (
)} - {sidebarOpen && showSessionSections && trimmedQuery && ( + {contentVisible && showSessionSections && trimmedQuery && ( )} - {sidebarOpen && showSessionSections && !trimmedQuery && ( + {contentVisible && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && showSessionSections && !trimmedQuery && ( + {contentVisible && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && !trimmedQuery && cronJobs.length > 0 && ( + {contentVisible && !trimmedQuery && cronJobs.length > 0 && ( )} - {sidebarOpen && !showSessionSections &&
} + {contentVisible && !showSessionSections &&
} - {sidebarOpen && ( + {contentVisible && (
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 15466d20950..42df767ef59 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -8,6 +8,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' import { Pane, PaneMain } from '@/components/pane-shell' +import { useMediaQuery } from '@/hooks/use-media-query' import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' @@ -23,6 +24,7 @@ import { FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, pinSession, + setSidebarOverlayMounted, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_SESSIONS_PAGE_SIZE, @@ -76,6 +78,7 @@ import { CommandPalette } from './command-palette' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { useKeybinds } from './hooks/use-keybinds' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' import { ModelPickerOverlay } from './model-picker-overlay' import { ModelVisibilityOverlay } from './model-visibility-overlay' import { RightSidebarPane } from './right-sidebar' @@ -165,6 +168,10 @@ export function DesktopController() { const terminalTakeover = useStore($terminalTakeover) const panesFlipped = useStore($panesFlipped) const profileScope = useStore($profileScope) + // Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail — + // collapse both sidebars (without touching their stored open state) so the + // hover-reveal overlay becomes the way in. Restores once it's wide again. + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) const routedSessionId = routeSessionId(location.pathname) const routeToken = `${location.pathname}:${location.search}:${location.hash}` @@ -300,6 +307,7 @@ export function DesktopController() { // with few recent sessions isn't windowed out of the cross-profile // recency page — the empty-history-on-profile-switch bug. const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope + const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { excludeSources: ['cron'] }) @@ -846,6 +854,8 @@ export function DesktopController() { { + if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { + window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } })) + } else { + toggleSidebarOpen() + } + }, + 'view.toggleRightSidebar': () => { + if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { + window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } })) + } else { + toggleFileBrowserOpen() + } + }, 'view.showFiles': () => showRightSidebarTab('files'), 'view.showTerminal': () => showRightSidebarTab('terminal'), 'view.flipPanes': togglePanesFlipped, diff --git a/apps/desktop/src/app/layout-constants.ts b/apps/desktop/src/app/layout-constants.ts index fff56d1e2b6..3174fc790ee 100644 --- a/apps/desktop/src/app/layout-constants.ts +++ b/apps/desktop/src/app/layout-constants.ts @@ -11,3 +11,9 @@ export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]' // Matching negative inline-margin to bleed an element (e.g. a sticky header bar) // out to the gutter edges before re-applying PAGE_INSET_X. export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]' + +// Below this viewport width a docked sidebar leaves no room for content, so both +// rails auto-collapse into the hover-reveal overlay. Single source of truth for +// the responsive collapse point. +export const SIDEBAR_COLLAPSE_BREAKPOINT_PX = 768 +export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_COLLAPSE_BREAKPOINT_PX}px)` diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index af9c75d6b7d..1c60e6411cf 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react' import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' import { SidebarProvider } from '@/components/ui/sidebar' +import { useMediaQuery } from '@/hooks/use-media-query' import { $fileBrowserOpen, $panesFlipped, @@ -16,6 +17,8 @@ import { import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' + import { KeybindPanel } from './keybind-panel' import { StatusbarControls, type StatusbarItem } from './statusbar-controls' import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' @@ -58,6 +61,7 @@ export function AppShell({ const sidebarOpen = useStore($sidebarOpen) const fileBrowserOpen = useStore($fileBrowserOpen) const panesFlipped = useStore($panesFlipped) + const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY) const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID)) const connection = useStore($connection) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) @@ -71,8 +75,10 @@ export function AppShell({ // The inset clears the top-left titlebar buttons when nothing covers the // window's left edge. Default layout: the sessions sidebar sits there. - // Flipped layout: the file browser does instead. - const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen + // Flipped layout: the file browser does instead. Below the collapse + // breakpoint both rails are force-collapsed (hover-reveal overlay), so the + // edge is uncovered regardless of their stored open state. + const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen) const titlebarContentInset = leftEdgePaneOpen ? 0 diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 30f77234f46..cf0d34fc662 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -425,7 +425,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
) => (
+
) : undefined @@ -470,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star s => s.thread.isRunning && s.message.status?.type === 'running' && - s.message.parts - .slice(Math.max(0, startIndex)) - .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') + s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) // A reasoning group with no actual text is pure noise — drop the whole diff --git a/apps/desktop/src/components/chat/intro.tsx b/apps/desktop/src/components/chat/intro.tsx index e942f55ff21..f7784855ec9 100644 --- a/apps/desktop/src/components/chat/intro.tsx +++ b/apps/desktop/src/components/chat/intro.tsx @@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) { return (

{WORDMARK} diff --git a/apps/desktop/src/components/pane-shell/index.ts b/apps/desktop/src/components/pane-shell/index.ts index 40946890cf3..1874b4bf005 100644 --- a/apps/desktop/src/components/pane-shell/index.ts +++ b/apps/desktop/src/components/pane-shell/index.ts @@ -1,4 +1,4 @@ export type { PaneShellContextValue, PaneSlot } from './context' export { PaneShellContext } from './context' -export { Pane, PaneMain, PaneShell } from './pane-shell' +export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell' export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell' diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index a3f6719ee54..8651ecd3ee9 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -10,7 +10,8 @@ import { useContext, useEffect, useMemo, - useRef + useRef, + useState } from 'react' import { cn } from '@/lib/utils' @@ -31,6 +32,12 @@ export interface PaneProps { defaultOpen?: boolean /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ disabled?: boolean + /** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */ + forceCollapsed?: boolean + /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ + hoverReveal?: boolean + /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ + onOverlayActiveChange?: (overlayActive: boolean) => void id: string maxWidth?: WidthValue minWidth?: WidthValue @@ -53,6 +60,7 @@ export interface PaneShellProps { interface CollectedPane { defaultOpen: boolean disabled: boolean + forceCollapsed: boolean id: string resizable: boolean side: PaneSide @@ -62,6 +70,22 @@ interface CollectedPane { const DEFAULT_WIDTH = '16rem' const DEFAULT_RESIZE_MIN_WIDTH = 160 +// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast +// pass-by doesn't dwell on the trigger long enough for the delay to elapse. +const HOVER_REVEAL_SLIDE_MS = 220 +const HOVER_REVEAL_ENTER_DELAY_MS = 130 +const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)' +// Offset shadow lifting the revealed panel off the content (same both sides; +// the mirror axis is offset-x, which is 0). Same color on light + dark. +const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012' +// Edge trigger strip, inset past the OS window-resize grab area. +const HOVER_REVEAL_TRIGGER_WIDTH = 14 +const HOVER_REVEAL_EDGE_GUTTER = 6 + +// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal +// from the keyboard, since its store-open toggle is a no-op while collapsed. +export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' + const widthToCss = (value: WidthValue | undefined, fallback: string) => value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value @@ -110,6 +134,7 @@ function collectPanes(children: ReactNode) { const entry: CollectedPane = { defaultOpen: props.defaultOpen ?? true, disabled: props.disabled ?? false, + forceCollapsed: props.forceCollapsed ?? false, id: props.id, resizable: props.resizable ?? false, side: props.side, @@ -124,7 +149,7 @@ function collectPanes(children: ReactNode) { function trackForPane(pane: CollectedPane, states: Record) { const stateOpen = states[pane.id]?.open ?? pane.defaultOpen - const open = !pane.disabled && stateOpen + const open = !pane.disabled && !pane.forceCollapsed && stateOpen if (!open) { return { open: false, track: '0px' } @@ -193,14 +218,29 @@ export function Pane({ className, defaultOpen = true, disabled = false, + hoverReveal = false, id, maxWidth, minWidth, - resizable = false + onOverlayActiveChange, + resizable = false, + width }: PaneProps) { const ctx = useContext(PaneShellContext) + const paneStates = useStore($paneStates) const registered = useRef(false) const paneRef = useRef(null) + // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. + const [forced, setForced] = useState(false) + + const slot = ctx?.paneById.get(id) + const open = Boolean(slot?.open && !disabled) + const side = slot?.side ?? 'left' + // Collapsed + hoverReveal: float the pane contents over the main column on + // hover/focus instead of hiding them. Honors any persisted resize width. + const overlayActive = !open && hoverReveal && !disabled + const override = resizable ? paneStates[id]?.widthOverride : undefined + const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH) useEffect(() => { if (registered.current) { @@ -211,12 +251,34 @@ export function Pane({ ensurePaneRegistered(id, { open: defaultOpen }) }, [defaultOpen, id]) - const slot = ctx?.paneById.get(id) - const open = Boolean(slot?.open && !disabled) + // Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer + // a collapsed overlay (reopened / widened). + useEffect(() => { + if (typeof window === 'undefined' || !overlayActive) { + setForced(false) + + return + } + + const onToggle = (e: Event) => { + if ((e as CustomEvent<{ id: string }>).detail?.id === id) { + setForced(v => !v) + } + } + + window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + + return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + }, [id, overlayActive]) + + // Keep contents mounted while collapsed so reveal is a pure CSS transform. + useEffect(() => { + onOverlayActiveChange?.(overlayActive) + }, [onOverlayActiveChange, overlayActive]) + const canResize = open && resizable const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY - const side = slot?.side ?? 'left' const startResize = useCallback( (event: ReactPointerEvent) => { @@ -273,6 +335,58 @@ export function Pane({ return null } + // Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a + // thin edge trigger + the floating panel (both absolute, escaping the zero + // box). group-hover (or data-forced from the keyboard) drives the slide; the + // enter-delay is the hover-intent gate. No JS pointer math. + if (overlayActive) { + const edge = side === 'left' ? 'left' : 'right' + const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]' + + return ( +

+ + ) + } + return (
= computed($paneStates, states export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY)) export const $sidebarPinsOpen = atom(true) +// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept +// true the whole time it's a floating overlay (not just while shown) so the +// consumer mounts contents off-screen, ready to slide. ChatSidebar mounts its +// rows on `sidebarOpen || this`. +export const $sidebarOverlayMounted = atom(false) export const $sidebarRecentsOpen = atom(true) // Cron-job sessions live in their own section below recents, collapsed by // default (it only renders at all when cron sessions exist) so the @@ -116,6 +121,10 @@ export function setSidebarPinsOpen(open: boolean) { $sidebarPinsOpen.set(open) } +export function setSidebarOverlayMounted(mounted: boolean) { + $sidebarOverlayMounted.set(mounted) +} + export function setSidebarRecentsOpen(open: boolean) { $sidebarRecentsOpen.set(open) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index fc7d3a03bf9..4dc57fb1c69 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -888,52 +888,42 @@ canvas { } .fit-text { + --fit-captured-length: initial; + --fit-support-sentinel: var(--fit-captured-length, 9999px); + display: flex; - font-size: var(--fit-text-min, 1rem); container-type: inline-size; - --captured-length: initial; - --support-sentinel: var(--captured-length, 9999px); } -.fit-text > [aria-hidden='true'] { +.fit-text > [aria-hidden] { visibility: hidden; } -.fit-text > :not([aria-hidden='true']) { +.fit-text > :not([aria-hidden]) { flex-grow: 1; container-type: inline-size; - --captured-length: 100cqi; - --available-space: var(--captured-length); + + --fit-captured-length: 100cqi; + --fit-available-space: var(--fit-captured-length); } -.fit-text > :not([aria-hidden='true']) > * { +.fit-text > :not([aria-hidden]) > * { + --fit-support-sentinel: inherit; + --fit-captured-length: 100cqi; + --fit-ratio: tan(atan2(var(--fit-available-space), var(--fit-available-space) - var(--fit-captured-length))); + display: block; - inline-size: var(--available-space); - line-height: var(--fit-text-line-height, 1); - --support-sentinel: inherit; - --captured-length: 100cqi; - --ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length))); - --font-size: clamp( - var(--fit-text-min, 1em), - 1em * var(--ratio), - var(--fit-text-max, infinity * 1px) - var(--support-sentinel) - ); - font-size: var(--font-size); + inline-size: var(--fit-available-space); + font-size: clamp(var(--fit-min, 1em), 1em * var(--fit-ratio), var(--fit-max, infinity * 1px) - var(--fit-support-sentinel)); } @container (inline-size > 0) { - .fit-text > :not([aria-hidden='true']) > * { + .fit-text > :not([aria-hidden]) > * { white-space: nowrap; } } -@property --captured-length { - syntax: ''; - initial-value: 0px; - inherits: true; -} - -@property --captured-length2 { +@property --fit-captured-length { syntax: ''; initial-value: 0px; inherits: true;