feat(agent): resizable, undockable assistant panel (#1467)

* feat(agent): resizable, undockable assistant panel

User report: the assistant chat sheet sometimes covers the page content
the user is asking about, with no way to resize or move it.

- Docked mode is now drag-resizable from its left edge (380-800px,
  clamped so the page keeps a 480px readable column) and the page
  reflows beside it via the existing --agent-dock-w reservation.
- Expanded (focus) mode reserves page margin like the compact dock
  instead of overlaying up to 1100px of the page.
- New undock toggle turns the sheet into a floating window that can be
  dragged by its header and resized from edges/corners, clamped so the
  header always stays reachable. Desktop only; mobile keeps the
  full-screen sheet.
- Geometry (mode, dock width, float rect) persists per user in
  user_preferences.ui_state.agent_panel, server-seeded to avoid a
  first-paint jump; the ui-state API schema gains a strict agent_panel
  key with nested merge.
- Pure clamp/resize math lives in lib/agent-panel/geometry with unit
  tests; drag frames write styles imperatively and commit one
  preference update on release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): address review findings on panel drag, a11y, and persistence

CodeRabbit round 1, all six findings fixed:

- Bind drag listeners to window (plus lostpointercapture) so a failed
  pointer capture or mid-drag unmount can never leave the transition
  suppression and data-agent-resizing stuck for the session.
- Keyboard resize now steps from the visible width (expandedW in focus
  mode) instead of jumping to the persisted dock width.
- The width handle exposes window-splitter semantics: aria-valuenow,
  aria-valuemin, aria-valuemax.
- --nav-w is read reactively via a MutationObserver on #dash-shell
  instead of computed-style reads in the render body and per drag frame.
- The ui-state POST in updatePanelPrefs gets a 300ms trailing debounce
  (state stays immediate) so key auto-repeat cannot produce one
  read-merge-write per repeat; pending write flushes on unmount.
- globals.css keeps one :root token block; the agent-resizing rule moved
  below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): filter drag events by pointer id, clear fired debounce timer

CodeRabbit round 2, both findings fixed:

- Window-level drag listeners now ignore events from pointers other than
  the initiating one, so a second touch or pen cannot move the panel or
  end the first pointer's drag.
- The persist debounce timer ref is nulled when the timer fires, so the
  unmount flush only writes genuinely pending values instead of
  replaying an already-persisted (possibly stale) geometry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-08 16:13:02 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 39f4ecdad4
commit b4b7549004
10 changed files with 952 additions and 34 deletions
+394 -31
View File
@@ -5,7 +5,9 @@ import {
X,
Expand,
Shrink,
PanelRight,
PanelRightClose,
PictureInPicture2,
Eraser,
History,
ChevronLeft,
@@ -16,7 +18,18 @@ import AgentChat, {
normalizeStoredMessages,
type ChatMessage,
} from './AgentChat'
import type { StoredStagedOperation } from '@/types'
import type { AgentPanelFloatRect, StoredStagedOperation } from '@/types'
import {
DOCK_GUTTER,
DOCK_WIDTH_MAX,
DOCK_WIDTH_MIN,
clampDockWidth,
clampFloatRect,
defaultFloatRect,
expandedDockWidth,
resizeFloatRect,
type ResizeEdges,
} from '@/lib/agent-panel/geometry'
import type { AgentStatusEvent } from './agent-status'
import ContextChip from './ContextChip'
import { intentLabel } from './conversation-display'
@@ -54,10 +67,127 @@ interface Props {
onClose: () => void
}
// The panel is max-w-[480px]; the page gives up that plus the frame's own
// 10px gutter, so the two panels float side by side on the frame with the
// same seam as everywhere else instead of butting their borders together.
const DOCKED_WIDTH = 490
// Geometry (docked width, expanded width, floating rect) lives in
// lib/agent-panel/geometry and is persisted per user via the provider
// (ui_state.agent_panel). The drag paths below write styles imperatively and
// commit ONE preference update on release, so a long conversation never
// re-renders at pointer-move frequency while the user drags.
function useViewportSize() {
const [size, setSize] = useState(() =>
typeof window === 'undefined'
? { w: 1440, h: 900 }
: { w: window.innerWidth, h: window.innerHeight },
)
useEffect(() => {
let frame = 0
const onResize = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() =>
setSize({ w: window.innerWidth, h: window.innerHeight }),
)
}
window.addEventListener('resize', onResize)
return () => {
cancelAnimationFrame(frame)
window.removeEventListener('resize', onResize)
}
}, [])
return size
}
/** Matches Tailwind's md breakpoint: floating mode exists on desktop only. */
function useMinWidthMd() {
const [md, setMd] = useState(
() => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches,
)
useEffect(() => {
const mq = window.matchMedia('(min-width: 768px)')
const onChange = () => setMd(mq.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
return md
}
/** Current sidebar column width (--nav-w, set inline on #dash-shell). */
function readNavWidth(): number {
if (typeof document === 'undefined') return 248
const shell = document.getElementById('dash-shell')
const px = shell ? parseInt(getComputedStyle(shell).getPropertyValue('--nav-w'), 10) : NaN
return Number.isFinite(px) ? px : 248
}
/**
* Reactive sidebar width: the nav toggle rewrites #dash-shell's inline
* --nav-w, which fires no resize event, so observe the style attribute
* instead of forcing a computed-style read on every render.
*/
function useNavWidth(): number {
const [w, setW] = useState(readNavWidth)
useEffect(() => {
const shell = document.getElementById('dash-shell')
if (!shell) return
const update = () => setW(readNavWidth())
update()
const mo = new MutationObserver(update)
mo.observe(shell, { attributes: true, attributeFilter: ['style'] })
return () => mo.disconnect()
}, [])
return w
}
/**
* Minimal pointer drag: capture on the handle, report cursor deltas, call
* onEnd exactly once on release or cancel. Deliberately not a library:
* three call sites, no gesture semantics beyond delta tracking.
*/
function startPointerDrag(
e: React.PointerEvent,
onMove: (dx: number, dy: number) => void,
onEnd: () => void,
) {
if (e.button !== 0) return
e.preventDefault()
const target = e.currentTarget as HTMLElement
const startX = e.clientX
const startY = e.clientY
try {
target.setPointerCapture(e.pointerId)
} catch {
// Capture is best-effort: without it the drag still works while the
// cursor stays over the handle.
}
// Listeners live on window, NOT on the handle: if capture fails (or the
// handle unmounts mid-drag), a pointerup outside the 8px strip would never
// reach the handle and onEnd would never run, leaving the drag's global
// side effects (transition suppression, data-agent-resizing) stuck for the
// rest of the session. lostpointercapture covers the mid-drag-unmount case.
// Window listeners see every active pointer, so a second touch or a pen
// must not move this drag or end it early: only the initiating pointer id
// counts. lostpointercapture carries no useful pointerId in all engines,
// so it stays unfiltered; it can only fire for the captured pointer anyway.
const pointerId = e.pointerId
const move = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return
onMove(ev.clientX - startX, ev.clientY - startY)
}
const end = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
target.removeEventListener('lostpointercapture', end)
onEnd()
}
const up = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return
end()
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
target.addEventListener('lostpointercapture', end)
}
interface LoadedConversation {
id: string
@@ -97,19 +227,39 @@ export default function AgentSheet({
const [loadingConversation, setLoadingConversation] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
// Enlarge the panel IN PLACE (no navigation): the user stays on the current
// page (e.g. /bookkeeping) with a wider reading/verifying surface.
// page (e.g. /bookkeeping) with a wider reading/verifying surface. Transient
// focus mode, deliberately not persisted (unlike the dock width below).
const [expanded, setExpanded] = useState(false)
// Dock at the compact width only. Expanded is a deliberate focus mode: at
// 1100px there is no page left to read beside it, so it goes back to
// overlaying. Collapsed and mobile claim nothing (below md the panel is
// full-width and the frame layout ignores the variable anyway).
const dockWidth = collapsed || expanded ? null : DOCKED_WIDTH
useEffect(() => {
onDockWidthChange?.(dockWidth)
}, [dockWidth, onDockWidthChange])
useEffect(() => () => onDockWidthChange?.(null), [onDockWidthChange])
const { identity } = useAgentSheet()
const { identity, panelPrefs, updatePanelPrefs } = useAgentSheet()
const viewport = useViewportSize()
const isDesktop = useMinWidthMd()
const navW = useNavWidth()
// Resolved geometry for this render. Floating exists on desktop only: below
// md the sheet stays the full-screen mobile surface whatever the persisted
// mode says. Everything re-clamps against the live viewport, so preferences
// saved on another screen can never strand the panel off-screen.
const floating = panelPrefs.mode === 'floating' && isDesktop
const dockW = clampDockWidth(panelPrefs.dockWidth, viewport.w, navW)
const expandedW = expandedDockWidth(viewport.w, navW)
const floatRect = floating
? clampFloatRect(
panelPrefs.float ?? defaultFloatRect(viewport.w, viewport.h),
viewport.w,
viewport.h,
)
: null
// Reserve page margin while docked, compact AND expanded: both reflow the
// page beside the panel instead of covering it (the original complaint).
// Floating and collapsed claim nothing; below md the margin variable is
// inert (the frame layout gates it on md:).
const reservedWidth = collapsed || floating ? null : (expanded ? expandedW : dockW) + DOCK_GUTTER
useEffect(() => {
onDockWidthChange?.(reservedWidth)
}, [reservedWidth, onDockWidthChange])
useEffect(() => () => onDockWidthChange?.(null), [onDockWidthChange])
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || null
@@ -171,6 +321,123 @@ export default function AgentSheet({
onCollapse()
}
// ── Docked width drag ─────────────────────────────────────────────────
// Live frames write the sheet's max-width and the page-margin variable
// directly; data-agent-resizing suppresses the 300ms margin transition
// (globals.css) so the page reflow tracks the cursor 1:1. One preference
// commit on release.
const pendingDockW = useRef<number | null>(null)
function onDockResizeStart(e: React.PointerEvent) {
const el = sheetRef.current
if (!el) return
const base = expanded ? expandedW : dockW
document.documentElement.setAttribute('data-agent-resizing', '')
el.style.transition = 'none'
startPointerDrag(
e,
(dx) => {
// The handle sits on the panel's left edge: dragging left widens.
// navW from the closure: the sidebar cannot change mid-drag, and this
// avoids a computed-style read on every pointer frame.
const w = clampDockWidth(base - dx, window.innerWidth, navW)
pendingDockW.current = w
el.style.maxWidth = `${w}px`
document.documentElement.style.setProperty('--agent-dock-w', `${w + DOCK_GUTTER}px`)
},
() => {
document.documentElement.removeAttribute('data-agent-resizing')
el.style.transition = ''
if (pendingDockW.current !== null) {
// Keep the final width as the inline override too: if the committed
// value equals the previous preference, React sees identical style
// props and writes nothing, so the DOM must already be correct.
el.style.maxWidth = `${pendingDockW.current}px`
// Dragging from focus mode lands on a custom width: that IS leaving
// focus mode, so fold the result back into the normal dock.
setExpanded(false)
updatePanelPrefs({ dockWidth: pendingDockW.current })
pendingDockW.current = null
}
},
)
}
function onDockResizeKey(e: React.KeyboardEvent) {
const step = 24
// Step from the width the user actually sees: in focus mode that is
// expandedW, and the first keypress folds it into a custom dock width.
const base = expanded ? expandedW : dockW
let next: number | null = null
if (e.key === 'ArrowLeft') next = base + step
if (e.key === 'ArrowRight') next = base - step
if (next === null) return
e.preventDefault()
setExpanded(false)
updatePanelPrefs({ dockWidth: clampDockWidth(next, viewport.w, navW) })
}
// ── Floating move / resize ────────────────────────────────────────────
const pendingFloat = useRef<AgentPanelFloatRect | null>(null)
const commitFloatRect = () => {
if (pendingFloat.current !== null) {
updatePanelPrefs({ float: pendingFloat.current })
pendingFloat.current = null
}
}
function onFloatMoveStart(e: React.PointerEvent) {
const el = sheetRef.current
if (!el || !floatRect) return
// Header buttons keep their clicks; only bare header surface drags.
if ((e.target as Element).closest('button, a, input, textarea, select, [role="button"]')) {
return
}
const base = floatRect
startPointerDrag(
e,
(dx, dy) => {
const r = clampFloatRect(
{ ...base, x: base.x + dx, y: base.y + dy },
window.innerWidth,
window.innerHeight,
)
pendingFloat.current = r
el.style.left = `${r.x}px`
el.style.top = `${r.y}px`
},
commitFloatRect,
)
}
function onFloatResizeStart(e: React.PointerEvent, edges: ResizeEdges) {
const el = sheetRef.current
if (!el || !floatRect) return
const base = floatRect
startPointerDrag(
e,
(dx, dy) => {
const r = resizeFloatRect(base, dx, dy, edges, window.innerWidth, window.innerHeight)
pendingFloat.current = r
el.style.left = `${r.x}px`
el.style.top = `${r.y}px`
el.style.width = `${r.w}px`
el.style.height = `${r.h}px`
},
commitFloatRect,
)
}
function toggleFloating() {
if (floating) {
updatePanelPrefs({ mode: 'docked' })
} else {
updatePanelPrefs({
mode: 'floating',
float: panelPrefs.float ?? defaultFloatRect(viewport.w, viewport.h),
})
}
}
// Resume a past conversation inline: fetch its messages, hydrate, and swap the
// sheet back to the chat view. Picking the one already open just closes the
// list (keeps its live in-memory state instead of re-hydrating it).
@@ -234,26 +501,92 @@ export default function AgentSheet({
// conversation state in AgentChat survives) while removing it from view
// and layout entirely (no stray horizontal scroll from an off-screen box).
className={cn(
'fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg transition-[max-width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
'fixed z-[60] flex flex-col bg-background',
floating
? // Undocked window: free rect from inline styles; overlay chrome
// (rounded, hairline border, shadow) like every other overlay.
'overflow-hidden rounded-lg border border-border shadow-lg'
: 'inset-y-0 right-0 w-full border-l border-border shadow-lg transition-[max-width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]',
// Arrive along the same edge, on the same curve and duration, as the
// page panel that animates its margin to make room (layout.tsx). Gated
// on first mount only: the panel stays mounted while collapsed, and
// display:none -> visible would otherwise replay the slide every time
// the user re-expands the same session.
entering && 'animate-in slide-in-from-right-full fade-in-0',
// the user re-expands the same session. A floating window has no edge
// to arrive from, so it just fades in.
entering && (floating ? 'animate-in fade-in-0' : 'animate-in slide-in-from-right-full fade-in-0'),
collapsed && 'hidden',
// Expanded grows the panel leftward over the page (still non-modal: the
// page stays interactive); normal is the compact side sheet.
expanded ? 'max-w-[min(100vw,1100px)]' : 'max-w-[480px]',
)}
style={{
// iOS notch / Android cutout: the sheet top edge needs to clear the
// status bar. Bottom is handled inside the form below.
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
style={
floating && floatRect
? { left: floatRect.x, top: floatRect.y, width: floatRect.w, height: floatRect.h }
: {
// Width is the persisted dock preference; expanded (focus mode)
// grows as far as the viewport allows while the page keeps a
// readable column beside it.
maxWidth: expanded ? expandedW : dockW,
// iOS notch / Android cutout: the sheet top edge needs to clear
// the status bar. Bottom is handled inside the form below.
paddingTop: 'env(safe-area-inset-top, 0px)',
}
}
>
{/* Docked: left-edge width handle (desktop only; mobile is full-width). */}
{!floating && (
<div
role="separator"
aria-orientation="vertical"
aria-label="Ändra panelens bredd"
aria-valuenow={expanded ? expandedW : dockW}
aria-valuemin={DOCK_WIDTH_MIN}
aria-valuemax={clampDockWidth(DOCK_WIDTH_MAX, viewport.w, navW)}
tabIndex={0}
onPointerDown={onDockResizeStart}
onKeyDown={onDockResizeKey}
className="group absolute inset-y-0 left-0 z-10 hidden w-2 cursor-col-resize touch-none focus-visible:outline-none md:block"
>
<div className="mx-auto h-full w-px bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-border group-focus-visible:bg-ring" />
</div>
)}
{/* Undocked: edge + corner resize handles. Pointer-only affordances
(aria-hidden): the keyboard path is dock -> arrow keys on the
separator above. */}
{floating && (
<>
<div
onPointerDown={(e) => onFloatResizeStart(e, { left: true })}
className="absolute inset-y-0 left-0 z-10 w-2 cursor-ew-resize touch-none"
aria-hidden="true"
/>
<div
onPointerDown={(e) => onFloatResizeStart(e, { right: true })}
className="absolute inset-y-0 right-0 z-10 w-2 cursor-ew-resize touch-none"
aria-hidden="true"
/>
<div
onPointerDown={(e) => onFloatResizeStart(e, { bottom: true })}
className="absolute inset-x-0 bottom-0 z-10 h-2 cursor-ns-resize touch-none"
aria-hidden="true"
/>
<div
onPointerDown={(e) => onFloatResizeStart(e, { left: true, bottom: true })}
className="absolute bottom-0 left-0 z-10 h-4 w-4 cursor-nesw-resize touch-none"
aria-hidden="true"
/>
<div
onPointerDown={(e) => onFloatResizeStart(e, { right: true, bottom: true })}
className="absolute bottom-0 right-0 z-10 h-4 w-4 cursor-nwse-resize touch-none"
aria-hidden="true"
/>
</>
)}
{view === 'list' ? (
<header className="flex items-center gap-3 border-b border-border px-5 py-4">
<header
onPointerDown={floating ? onFloatMoveStart : undefined}
className={cn(
'flex items-center gap-3 border-b border-border px-5 py-4',
floating && 'cursor-move touch-none select-none',
)}
>
<button
onClick={() => setView('chat')}
className="h-9 w-9 -ml-1 inline-flex items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
@@ -273,7 +606,15 @@ export default function AgentSheet({
</button>
</header>
) : (
<header className="flex items-center gap-2 border-b border-border px-4 py-4">
<header
// In floating mode the header doubles as the window's drag surface
// (buttons excluded inside onFloatMoveStart).
onPointerDown={floating ? onFloatMoveStart : undefined}
className={cn(
'flex items-center gap-2 border-b border-border px-4 py-4',
floating && 'cursor-move touch-none select-none',
)}
>
{!isSandbox && (
<button
onClick={() => setView('list')}
@@ -296,10 +637,32 @@ export default function AgentSheet({
<ContextChip contextRef={activeContextRef} className="mt-0.5" />
</div>
<div className="ml-auto flex items-center gap-1">
{/* Undock into a floating window the user can move and resize
freely / dock it back to the right edge. Hidden on mobile
where the sheet is always the full-screen surface. */}
{!isSandbox && (
<button
onClick={toggleFloating}
className="hidden md:inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
aria-label={floating ? 'Docka mot högerkanten' : 'Frigör panelen'}
title={
floating
? 'Docka mot högerkanten'
: 'Frigör panelen: flytta och ändra storlek fritt'
}
>
{floating ? (
<PanelRight className="h-4 w-4" />
) : (
<PictureInPicture2 className="h-4 w-4" />
)}
</button>
)}
{/* Grow/shrink the panel in place: NEVER navigates away, so the
user stays on the current page. Hidden on mobile where the sheet
is already full-width (the toggle would be a no-op). */}
{!isSandbox && (
is already full-width (the toggle would be a no-op), and while
floating (the window resizes by its edges instead). */}
{!isSandbox && !floating && (
<button
onClick={() => setExpanded((v) => !v)}
className="hidden md:inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
+76 -3
View File
@@ -7,9 +7,17 @@ import {
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import dynamic from 'next/dynamic'
import type { AgentPanelState } from '@/types'
import {
resolveAgentPanelPrefs,
serializeAgentPanelPrefs,
type ResolvedAgentPanelPrefs,
} from '@/lib/agent-panel/geometry'
import { persistUiState } from '@/lib/ui-state/client'
import {
INITIAL_AGENT_STATUS,
reduceAgentStatus,
@@ -31,8 +39,13 @@ function AgentSheetSkeleton() {
return (
<div
aria-hidden="true"
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
style={{ paddingTop: 'env(safe-area-inset-top, 0px)' }}
className="fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-lg"
style={{
// Tracks the user's persisted dock width (set by the provider below)
// so the skeleton has the same geometry as the sheet it stands in for.
maxWidth: 'var(--agent-panel-w, 480px)',
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-4">
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
@@ -157,6 +170,12 @@ interface AgentSheetContextValue {
// Width in px the docked panel is claiming from the page, or null when it is
// overlaying instead. Set by the panel, read by the frame layout.
setDockWidth: (px: number | null) => void
// Panel geometry preferences (docked width, floating rect, active mode).
// Owned here rather than in the sheet so they survive sheet remounts
// (intent changes bump the sheet's key) and persist across sessions via
// user_preferences.ui_state.agent_panel.
panelPrefs: ResolvedAgentPanelPrefs
updatePanelPrefs: (patch: Partial<ResolvedAgentPanelPrefs>) => void
// Agent name + avatar: set once from the server-loaded agent_profile
// and exposed through context so the trigger / chat headers can render
// them without their own fetches. Null when the user hasn't verified a
@@ -169,9 +188,16 @@ const AgentSheetContext = createContext<AgentSheetContextValue | null>(null)
interface AgentSheetProviderProps {
children: React.ReactNode
identity?: AgentIdentity
// Server-seeded ui_state.agent_panel, so the panel opens at the user's
// persisted size/mode without a first-paint jump.
initialPanelPrefs?: AgentPanelState
}
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
export function AgentSheetProvider({
children,
identity,
initialPanelPrefs,
}: AgentSheetProviderProps) {
useSheetPrefetch()
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
// Collapsed = session alive but hidden. Kept separate from activeArgs so
@@ -183,6 +209,49 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
const [status, publishAgentStatus] = useReducer(reduceAgentStatus, INITIAL_AGENT_STATUS)
const [dockWidth, setDockWidth] = useState<number | null>(null)
// Geometry preferences. The ref mirrors the state so updatePanelPrefs can
// merge and persist from event handlers without a stale closure (drag
// commits fire from listeners installed at drag start).
const [panelPrefs, setPanelPrefs] = useState<ResolvedAgentPanelPrefs>(() =>
resolveAgentPanelPrefs(initialPanelPrefs),
)
const panelPrefsRef = useRef(panelPrefs)
// Trailing debounce on the POST only: local state stays immediate, but key
// auto-repeat on the resize handle (one updatePanelPrefs per repeat) must
// not become one read-merge-write against user_preferences per repeat.
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const updatePanelPrefs = useCallback((patch: Partial<ResolvedAgentPanelPrefs>) => {
const next = { ...panelPrefsRef.current, ...patch }
panelPrefsRef.current = next
setPanelPrefs(next)
// Fire-and-forget: cosmetic preference, a lost write self-corrects on the
// next change.
if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
persistTimerRef.current = setTimeout(() => {
// Null before writing: a fired timer is no longer pending, and the
// unmount flush below must not replay this (possibly stale) value.
persistTimerRef.current = null
persistUiState({ agent_panel: serializeAgentPanelPrefs(panelPrefsRef.current) })
}, 300)
}, [])
useEffect(
() => () => {
// Flush on unmount so a pending debounced write is not lost.
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current)
persistUiState({ agent_panel: serializeAgentPanelPrefs(panelPrefsRef.current) })
}
},
[],
)
// The lazy sheet's loading skeleton renders before the sheet can report any
// geometry, so the persisted dock width is published as a CSS variable for
// it (and only it) to read.
useEffect(() => {
document.documentElement.style.setProperty('--agent-panel-w', `${panelPrefs.dockWidth}px`)
}, [panelPrefs.dockWidth])
// Visibility drives whether a finished turn is news or not, so it is derived
// from the session state rather than reported by the panel: closing counts as
// hidden just like collapsing, and a panel that never opened is neither.
@@ -244,6 +313,8 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
status,
publishAgentStatus,
setDockWidth,
panelPrefs,
updatePanelPrefs,
identity: resolvedIdentity,
}),
[
@@ -255,6 +326,8 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
activeArgs,
collapsed,
status,
panelPrefs,
updatePanelPrefs,
resolvedIdentity,
],
)