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>
165 lines
6.3 KiB
TypeScript
165 lines
6.3 KiB
TypeScript
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)
|
|
}
|