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.
This commit is contained in:
Brooklyn Nicholson 2026-06-07 20:22:52 -05:00
parent 69a293b419
commit faad03530c
3 changed files with 94 additions and 3 deletions

View file

@ -580,7 +580,12 @@ 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',
// Hover-reveal overlay: when collapsed, the PaneShell floats this
// sidebar over the content and marks the wrapper `data-pane-hover-reveal`.
// Force it fully visible + interactive while revealed, regardless of the
// collapsed (sidebarOpen=false) styling above.
'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'
)}
collapsible="none"
>

View file

@ -873,6 +873,7 @@ export function DesktopController() {
>
<Pane
disabled={terminalTakeoverActive}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}

View file

@ -10,7 +10,8 @@ import {
useContext,
useEffect,
useMemo,
useRef
useRef,
useState
} from 'react'
import { cn } from '@/lib/utils'
@ -31,6 +32,13 @@ export interface PaneProps {
defaultOpen?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/**
* When the pane is collapsed, reveal its contents as a fixed overlay on hover
* (or keyboard focus) instead of leaving it fully hidden. The overlay floats
* over the main content it does not reserve grid space, so the collapsed
* track stays at 0px.
*/
hoverReveal?: boolean
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
@ -193,14 +201,18 @@ export function Pane({
className,
defaultOpen = true,
disabled = false,
hoverReveal = false,
id,
maxWidth,
minWidth,
resizable = false
resizable = false,
width
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
const [hoverRevealed, setHoverRevealed] = useState(false)
useEffect(() => {
if (registered.current) {
@ -218,6 +230,24 @@ export function Pane({
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of leaving them fully hidden. Honors any persisted
// resize width so the overlay matches the pane's expanded size.
const overlayActive = !open && hoverReveal && !disabled
const overlayWidth =
resizable && paneStates[id]?.widthOverride !== undefined
? `${paneStates[id]?.widthOverride}px`
: widthToCss(width, DEFAULT_WIDTH)
// Collapse the reveal whenever the pane reopens or gets disabled so it never
// sticks "open" underneath the now-expanded track.
useEffect(() => {
if (!overlayActive && hoverRevealed) {
setHoverRevealed(false)
}
}, [overlayActive, hoverRevealed])
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
@ -273,6 +303,61 @@ export function Pane({
return null
}
// Collapsed hover-reveal track: keep the grid cell at 0px (no reserved space)
// but don't clip — the trigger strip and the floating overlay both escape the
// zero-width box via absolute positioning, so the panel renders over the main
// content rather than pushing it.
if (overlayActive) {
const revealEdge = slot.side === 'left' ? { left: 0 } : { right: 0 }
return (
<div
className={cn('pointer-events-none relative row-start-1 min-w-0', className)}
data-pane-hover-reveal={hoverRevealed ? 'open' : 'closed'}
data-pane-id={id}
data-pane-open="false"
data-pane-side={slot.side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
>
{/* Edge hot-zone: hovering it (or moving onto the revealed overlay)
keeps the panel open. Sits above main content but is invisible. */}
<button
aria-expanded={hoverRevealed}
aria-label={`Reveal ${id}`}
className={cn(
'pointer-events-auto absolute inset-y-0 z-30 w-3 cursor-pointer [-webkit-app-region:no-drag]',
slot.side === 'left' ? 'left-0' : 'right-0'
)}
onFocus={() => setHoverRevealed(true)}
onPointerEnter={() => setHoverRevealed(true)}
type="button"
/>
{/* Floating panel fixed-height, absolutely positioned over the main
column. Slides in from the edge; hidden (translated off-edge) until
hovered/focused. */}
<div
className={cn(
'pointer-events-auto absolute inset-y-0 z-30 overflow-hidden shadow-2xl transition-transform duration-200 ease-out',
slot.side === 'left'
? hoverRevealed
? 'translate-x-0'
: '-translate-x-[calc(100%+1rem)]'
: hoverRevealed
? 'translate-x-0'
: 'translate-x-[calc(100%+1rem)]'
)}
onPointerEnter={() => setHoverRevealed(true)}
onPointerLeave={() => setHoverRevealed(false)}
style={{ ...revealEdge, width: overlayWidth }}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
aria-hidden={!open}