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
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest'
import {
DOCK_WIDTH_DEFAULT,
DOCK_WIDTH_MIN,
DOCK_WIDTH_MAX,
DOCK_GUTTER,
MIN_PAGE_WIDTH,
EXPANDED_WIDTH_MAX,
FLOAT_MIN_W,
FLOAT_MIN_H,
FLOAT_KEEP_ON_SCREEN,
clampDockWidth,
clampFloatRect,
defaultFloatRect,
expandedDockWidth,
resizeFloatRect,
resolveAgentPanelPrefs,
serializeAgentPanelPrefs,
} from '../geometry'
const WIDE = 1920
const TALL = 1080
const NAV = 248
describe('resolveAgentPanelPrefs', () => {
it('defaults on absent input', () => {
expect(resolveAgentPanelPrefs(undefined)).toEqual({
mode: 'docked',
dockWidth: DOCK_WIDTH_DEFAULT,
float: null,
})
})
it('keeps a valid persisted state', () => {
const prefs = resolveAgentPanelPrefs({
mode: 'floating',
dock_width: 600,
float: { x: 100, y: 50, w: 500, h: 700 },
})
expect(prefs).toEqual({
mode: 'floating',
dockWidth: 600,
float: { x: 100, y: 50, w: 500, h: 700 },
})
})
it('discards garbage values instead of propagating them', () => {
const prefs = resolveAgentPanelPrefs({
mode: 'floating',
dock_width: Number.NaN,
// Partial/garbage rect: one bad member invalidates the whole rect.
float: { x: 10, y: 10, w: Number.POSITIVE_INFINITY, h: 400 },
})
expect(prefs.dockWidth).toBe(DOCK_WIDTH_DEFAULT)
expect(prefs.float).toBeNull()
})
it('hard-clamps a persisted dock width to the fixed bounds', () => {
expect(resolveAgentPanelPrefs({ dock_width: 40 }).dockWidth).toBe(DOCK_WIDTH_MIN)
expect(resolveAgentPanelPrefs({ dock_width: 4000 }).dockWidth).toBe(DOCK_WIDTH_MAX)
})
})
describe('serializeAgentPanelPrefs', () => {
it('rounds pixels to integers (the API schema rejects fractions)', () => {
expect(
serializeAgentPanelPrefs({
mode: 'floating',
dockWidth: 480.6,
float: { x: 10.4, y: 20.5, w: 400.2, h: 500.9 },
}),
).toEqual({
mode: 'floating',
dock_width: 481,
float: { x: 10, y: 21, w: 400, h: 501 },
})
})
it('omits float when there is none', () => {
expect(serializeAgentPanelPrefs({ mode: 'docked', dockWidth: 480, float: null })).toEqual({
mode: 'docked',
dock_width: 480,
})
})
})
describe('clampDockWidth', () => {
it('passes through a width the viewport can afford', () => {
expect(clampDockWidth(600, WIDE, NAV)).toBe(600)
})
it('caps at the fixed maximum on huge viewports', () => {
expect(clampDockWidth(5000, 3840, NAV)).toBe(DOCK_WIDTH_MAX)
})
it('never lets the page drop below its minimum readable width', () => {
const viewport = 1280
const clamped = clampDockWidth(DOCK_WIDTH_MAX, viewport, NAV)
expect(viewport - NAV - clamped - DOCK_GUTTER).toBeGreaterThanOrEqual(MIN_PAGE_WIDTH)
})
it('floors at the panel minimum even when the viewport is too small for both', () => {
expect(clampDockWidth(100, 900, NAV)).toBe(DOCK_WIDTH_MIN)
})
})
describe('expandedDockWidth', () => {
it('caps at the fixed expanded maximum on huge viewports', () => {
expect(expandedDockWidth(3840, NAV)).toBe(EXPANDED_WIDTH_MAX)
})
it('reserves the minimum page width on mid-size viewports', () => {
const viewport = 1440
expect(expandedDockWidth(viewport, NAV)).toBe(viewport - NAV - MIN_PAGE_WIDTH - DOCK_GUTTER)
})
it('floors at the default compact width', () => {
expect(expandedDockWidth(1000, NAV)).toBe(DOCK_WIDTH_DEFAULT)
})
})
describe('clampFloatRect', () => {
it('keeps a legal rect unchanged', () => {
const rect = { x: 200, y: 100, w: 420, h: 640 }
expect(clampFloatRect(rect, WIDE, TALL)).toEqual(rect)
})
it('enforces minimum size', () => {
const r = clampFloatRect({ x: 0, y: 0, w: 10, h: 10 }, WIDE, TALL)
expect(r.w).toBe(FLOAT_MIN_W)
expect(r.h).toBe(FLOAT_MIN_H)
})
it('keeps the panel reachable when dragged off the right edge', () => {
const r = clampFloatRect({ x: 99999, y: 100, w: 420, h: 640 }, WIDE, TALL)
expect(r.x).toBe(WIDE - FLOAT_KEEP_ON_SCREEN)
})
it('never lets the header go above the top edge', () => {
const r = clampFloatRect({ x: 100, y: -500, w: 420, h: 640 }, WIDE, TALL)
expect(r.y).toBe(0)
})
it('keeps the header reachable at the bottom', () => {
const r = clampFloatRect({ x: 100, y: 99999, w: 420, h: 640 }, WIDE, TALL)
expect(r.y).toBe(TALL - FLOAT_KEEP_ON_SCREEN)
})
})
describe('defaultFloatRect', () => {
it('spawns bottom-right and fully on screen', () => {
const r = defaultFloatRect(WIDE, TALL)
expect(r.x + r.w).toBeLessThanOrEqual(WIDE)
expect(r.y + r.h).toBeLessThanOrEqual(TALL)
expect(r.x).toBeGreaterThan(WIDE / 2)
})
it('shrinks to fit a small desktop viewport', () => {
const r = defaultFloatRect(800, 500)
expect(r.w).toBeLessThanOrEqual(800)
expect(r.h).toBeLessThanOrEqual(500)
expect(r.y).toBeGreaterThanOrEqual(0)
})
})
describe('resizeFloatRect', () => {
const base = { x: 400, y: 200, w: 500, h: 600 }
it('grows from the right edge without moving x', () => {
const r = resizeFloatRect(base, 80, 0, { right: true }, WIDE, TALL)
expect(r).toEqual({ ...base, w: 580 })
})
it('grows from the bottom edge', () => {
const r = resizeFloatRect(base, 0, 50, { bottom: true }, WIDE, TALL)
expect(r).toEqual({ ...base, h: 650 })
})
it('anchors the right edge on a left-edge drag', () => {
const r = resizeFloatRect(base, -60, 0, { left: true }, WIDE, TALL)
expect(r.x).toBe(base.x - 60)
expect(r.x + r.w).toBe(base.x + base.w)
})
it('keeps the right edge anchored when the minimum width clamp kicks in', () => {
const r = resizeFloatRect(base, 400, 0, { left: true }, WIDE, TALL)
expect(r.w).toBe(FLOAT_MIN_W)
expect(r.x + r.w).toBe(base.x + base.w)
})
it('resizes both axes from a corner', () => {
const r = resizeFloatRect(base, 40, 30, { right: true, bottom: true }, WIDE, TALL)
expect(r).toEqual({ ...base, w: 540, h: 630 })
})
})
+164
View File
@@ -0,0 +1,164 @@
import type { AgentPanelFloatRect, AgentPanelState } from '@/types'
/**
* Pure geometry for the assistant panel (components/agent/AgentSheet).
*
* The panel has two modes:
* - docked: full-height sheet on the right edge; the page panel gives up
* `width + DOCK_GUTTER` of right margin (via --agent-dock-w) so content
* reflows beside the panel instead of being covered by it.
* - floating: a free window the user drags and resizes anywhere; the page
* keeps its full width and the user decides what the window may cover.
*
* All clamping lives here, testable and shared between the live drag path
* (imperative style writes) and the persisted-preference path.
*/
export const DOCK_WIDTH_DEFAULT = 480
export const DOCK_WIDTH_MIN = 380
export const DOCK_WIDTH_MAX = 800
/** Frame gutter added on top of the panel width when reserving page margin,
* so panel and page float side by side with the same 10px seam as the frame. */
export const DOCK_GUTTER = 10
/** The page keeps at least this much width before the docked panel stops growing. */
export const MIN_PAGE_WIDTH = 480
/** Ceiling for the expanded (focus) width; beyond this a chat column stops
* gaining readability. */
export const EXPANDED_WIDTH_MAX = 1100
export const FLOAT_MIN_W = 360
export const FLOAT_MIN_H = 400
export const FLOAT_DEFAULT_W = 420
export const FLOAT_DEFAULT_H = 640
/** Gap kept between a freshly spawned floating panel and the viewport edge. */
export const FLOAT_SPAWN_MARGIN = 24
/** At least this much of the panel stays inside the viewport, so the header
* (the drag surface) is always reachable to drag it back. */
export const FLOAT_KEEP_ON_SCREEN = 48
/** Resolved, always-valid panel preferences (persisted shape is all-optional). */
export interface ResolvedAgentPanelPrefs {
mode: 'docked' | 'floating'
dockWidth: number
float: AgentPanelFloatRect | null
}
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
/**
* Normalize a persisted (or absent, or garbage) agent_panel value into a
* usable preference object. Viewport-dependent clamping happens at use time;
* this only guards types and hard bounds.
*/
export function resolveAgentPanelPrefs(
raw: AgentPanelState | null | undefined,
): ResolvedAgentPanelPrefs {
const mode = raw?.mode === 'floating' ? 'floating' : 'docked'
const dockWidth = finite(raw?.dock_width)
? Math.min(Math.max(raw.dock_width, DOCK_WIDTH_MIN), DOCK_WIDTH_MAX)
: DOCK_WIDTH_DEFAULT
const f = raw?.float
const float =
f && finite(f.x) && finite(f.y) && finite(f.w) && finite(f.h)
? { x: f.x, y: f.y, w: f.w, h: f.h }
: null
return { mode, dockWidth, float }
}
/** Persisted wire shape (integers: the API schema rejects fractional pixels). */
export function serializeAgentPanelPrefs(prefs: ResolvedAgentPanelPrefs): AgentPanelState {
return {
mode: prefs.mode,
dock_width: Math.round(prefs.dockWidth),
...(prefs.float
? {
float: {
x: Math.round(prefs.float.x),
y: Math.round(prefs.float.y),
w: Math.round(prefs.float.w),
h: Math.round(prefs.float.h),
},
}
: {}),
}
}
/**
* Clamp a docked width so the page keeps MIN_PAGE_WIDTH beside the panel.
* `navWidth` is the sidebar column (--nav-w); on viewports too small for
* both minimums the panel floor wins (the md: margin is inert there anyway).
*/
export function clampDockWidth(px: number, viewportW: number, navWidth: number): number {
const available = Math.max(DOCK_WIDTH_MIN, viewportW - navWidth - MIN_PAGE_WIDTH - DOCK_GUTTER)
const max = Math.min(DOCK_WIDTH_MAX, available)
return Math.round(Math.min(Math.max(px, DOCK_WIDTH_MIN), max))
}
/**
* Width of the expanded (focus) panel: as wide as the viewport allows while
* the page keeps MIN_PAGE_WIDTH, capped at EXPANDED_WIDTH_MAX. Unlike the
* old fixed 1100px overlay, this reserves page margin like the compact dock.
*/
export function expandedDockWidth(viewportW: number, navWidth: number): number {
const available = viewportW - navWidth - MIN_PAGE_WIDTH - DOCK_GUTTER
return Math.round(Math.min(EXPANDED_WIDTH_MAX, Math.max(DOCK_WIDTH_DEFAULT, available)))
}
/** Keep a floating rect at legal size and reachably on screen. */
export function clampFloatRect(
rect: AgentPanelFloatRect,
viewportW: number,
viewportH: number,
): AgentPanelFloatRect {
const w = Math.round(Math.min(Math.max(rect.w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, viewportW)))
const h = Math.round(Math.min(Math.max(rect.h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, viewportH)))
// Horizontally the panel may hang off either side as long as
// FLOAT_KEEP_ON_SCREEN of it stays visible; vertically the header must
// never go above the top edge (it is the only drag surface).
const x = Math.round(
Math.min(Math.max(rect.x, FLOAT_KEEP_ON_SCREEN - w), viewportW - FLOAT_KEEP_ON_SCREEN),
)
const y = Math.round(Math.min(Math.max(rect.y, 0), Math.max(0, viewportH - FLOAT_KEEP_ON_SCREEN)))
return { x, y, w, h }
}
/** First-undock placement: bottom-right, mirroring where the FAB lives. */
export function defaultFloatRect(viewportW: number, viewportH: number): AgentPanelFloatRect {
const w = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, viewportW - 2 * FLOAT_SPAWN_MARGIN))
const h = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, viewportH - 2 * FLOAT_SPAWN_MARGIN))
return clampFloatRect(
{ x: viewportW - w - FLOAT_SPAWN_MARGIN, y: viewportH - h - FLOAT_SPAWN_MARGIN, w, h },
viewportW,
viewportH,
)
}
export interface ResizeEdges {
left?: boolean
right?: boolean
bottom?: boolean
}
/**
* Apply a pointer drag delta to a floating rect from the given edges.
* A left-edge drag moves x with the width so the right edge stays anchored,
* including when the width clamp kicks in.
*/
export function resizeFloatRect(
base: AgentPanelFloatRect,
dx: number,
dy: number,
edges: ResizeEdges,
viewportW: number,
viewportH: number,
): AgentPanelFloatRect {
let w = base.w
if (edges.right) w = base.w + dx
if (edges.left) w = base.w - dx
const h = edges.bottom ? base.h + dy : base.h
const clampedW = Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, viewportW))
const x = edges.left ? base.x + (base.w - clampedW) : base.x
return clampFloatRect({ x, y: base.y, w: clampedW, h }, viewportW, viewportH)
}