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:
co-authored by
Claude Fable 5
parent
39f4ecdad4
commit
b4b7549004
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user