b4b7549004
* 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>
196 lines
5.9 KiB
TypeScript
196 lines
5.9 KiB
TypeScript
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 })
|
|
})
|
|
})
|