Files
hermes-agent/apps/desktop/src/components/pane-shell/pane-shell.tsx
T

425 lines
13 KiB
TypeScript

import { useStore } from '@nanostores/react'
import {
Children,
type CSSProperties,
isValidElement,
type ReactElement,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { cn } from '@/lib/utils'
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
type PaneSide = 'left' | 'right'
type WidthValue = string | number
interface PaneRoleMarker {
__paneShellRole?: 'pane' | 'main'
}
export interface PaneProps {
children?: ReactNode
className?: string
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
/** Called with the reveal state whenever a collapsed hoverReveal pane floats in/out. */
onHoverRevealChange?: (revealed: boolean) => void
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
resizable?: boolean
side: PaneSide
width?: WidthValue
}
export interface PaneMainProps {
children?: ReactNode
className?: string
}
export interface PaneShellProps {
children?: ReactNode
className?: string
style?: CSSProperties
}
interface CollectedPane {
defaultOpen: boolean
disabled: boolean
id: string
resizable: boolean
side: PaneSide
width: string
}
const DEFAULT_WIDTH = '16rem'
const DEFAULT_RESIZE_MIN_WIDTH = 160
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
const remPx = () =>
typeof window === 'undefined'
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
if (!match) {
return undefined
}
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
}
function collectPanes(children: ReactNode) {
const left: CollectedPane[] = []
const right: CollectedPane[] = []
let mainCount = 0
Children.forEach(children, child => {
if (isRole(child, 'main')) {
mainCount++
return
}
if (!isRole(child, 'pane')) {
return
}
const props = child.props as PaneProps
const entry: CollectedPane = {
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
id: props.id,
resizable: props.resizable ?? false,
side: props.side,
width: widthToCss(props.width, DEFAULT_WIDTH)
}
;(props.side === 'left' ? left : right).push(entry)
})
return { left, mainCount, right }
}
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
const open = !pane.disabled && stateOpen
if (!open) {
return { open: false, track: '0px' }
}
const override = pane.resizable ? states[pane.id]?.widthOverride : undefined
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
}
export function PaneShell({ children, className, style }: PaneShellProps) {
const paneStates = useStore($paneStates)
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
if (import.meta.env.DEV && mainCount > 1) {
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
}
const ctxValue = useMemo(() => {
const paneById = new Map<string, PaneSlot>()
const tracks: string[] = []
const cssVars: Record<string, string> = {}
let column = 1
for (const pane of left) {
const { open, track } = trackForPane(pane, paneStates)
tracks.push(track)
paneById.set(pane.id, { column, open, side: 'left' })
cssVars[`--pane-${pane.id}-width`] = track
column++
}
tracks.push('minmax(0,1fr)')
const mainColumn = column++
for (const pane of right) {
const { open, track } = trackForPane(pane, paneStates)
tracks.push(track)
paneById.set(pane.id, { column, open, side: 'right' })
cssVars[`--pane-${pane.id}-width`] = track
column++
}
return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & {
cssVars: Record<string, string>
gridTemplate: string
}
}, [left, paneStates, right])
const composedStyle = useMemo<CSSProperties>(
() => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }),
[ctxValue.cssVars, ctxValue.gridTemplate, style]
)
return (
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
{children}
</div>
</PaneShellContext.Provider>
)
}
export function Pane({
children,
className,
defaultOpen = true,
disabled = false,
hoverReveal = false,
id,
maxWidth,
minWidth,
onHoverRevealChange,
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) {
return
}
registered.current = true
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const canResize = open && resizable
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
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])
// Surface the effective reveal state to consumers (e.g. so the sidebar can
// render its full content while floated, not just while the track is open).
useEffect(() => {
onHoverRevealChange?.(overlayActive && hoverRevealed)
}, [onHoverRevealChange, overlayActive, hoverRevealed])
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
if (!canResize || paneWidth <= 0) {
return
}
event.preventDefault()
const handle = event.currentTarget
const { pointerId, clientX: startX } = event
const dir = side === 'left' ? 1 : -1
const restoreCursor = document.body.style.cursor
const restoreSelect = document.body.style.userSelect
handle.setPointerCapture?.(pointerId)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const onMove = (e: PointerEvent) => {
const next = paneWidth + (e.clientX - startX) * dir
setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next))))
}
const cleanup = () => {
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
handle.releasePointerCapture?.(pointerId)
window.removeEventListener('pointermove', onMove, true)
window.removeEventListener('pointerup', cleanup, true)
window.removeEventListener('pointercancel', cleanup, true)
window.removeEventListener('blur', cleanup)
}
window.addEventListener('pointermove', onMove, true)
window.addEventListener('pointerup', cleanup, true)
window.addEventListener('pointercancel', cleanup, true)
window.addEventListener('blur', cleanup)
},
[canResize, hi, id, lo, side]
)
if (!ctx) {
if (import.meta.env.DEV) {
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
}
return null
}
if (!slot) {
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 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}
className={cn('relative row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
data-pane-id={id}
data-pane-open={open ? 'true' : 'false'}
data-pane-side={slot.side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
>
{canResize && (
<div
aria-label={`Resize ${id}`}
aria-orientation="vertical"
className={cn(
'group absolute bottom-0 top-0 z-20 w-1 cursor-col-resize [-webkit-app-region:no-drag]',
slot.side === 'left' ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2'
)}
onPointerDown={startResize}
role="separator"
tabIndex={0}
>
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
</div>
)}
{children}
</div>
)
}
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
export function PaneMain({ children, className }: PaneMainProps) {
const ctx = useContext(PaneShellContext)
if (!ctx) {
if (import.meta.env.DEV) {
console.warn('[PaneMain] must be rendered inside <PaneShell>')
}
return null
}
return (
<div
className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
data-pane-main="true"
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
>
{children}
</div>
)
}
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'