7ccaab7a08
A persisted float rect saved at the viewport edge, or on a larger monitor, passes clampFloatRect (which only keeps 48px reachable so a live drag may deliberately hang off an edge) and renders the panel as a 48px sliver on every open. Add containFloatRect, which snaps the whole window inside the viewport, and an AgentSheet effect that validates the persisted rect on sheet mount and on viewport resize and persists the corrected position. The rect is read through a ref so drag commits do not re-trigger containment: parking the window half off-screen still works within a session. The live drag path and clampFloatRect are unchanged, and containFloatRect's output is always a fixpoint of clampFloatRect, so the render clamp stays a no-op. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
247 lines
8.0 KiB
TypeScript
247 lines
8.0 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,
|
|
containFloatRect,
|
|
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('containFloatRect', () => {
|
|
it('keeps a fully visible rect unchanged', () => {
|
|
const rect = { x: 200, y: 100, w: 420, h: 640 }
|
|
expect(containFloatRect(rect, WIDE, TALL)).toEqual(rect)
|
|
})
|
|
|
|
it('pulls a right-edge sliver fully inside the viewport', () => {
|
|
// x = 1392 is legal for clampFloatRect (48px visible) but renders as a
|
|
// sliver; contain pulls the whole window on screen.
|
|
const r = containFloatRect({ x: 1392, y: 100, w: 420, h: 640 }, 1440, 900)
|
|
expect(r).toEqual({ x: 1440 - 420, y: 100, w: 420, h: 640 })
|
|
})
|
|
|
|
it('pulls a left-hanging rect back to the left edge', () => {
|
|
const r = containFloatRect({ x: -372, y: 100, w: 420, h: 640 }, 1440, 900)
|
|
expect(r).toEqual({ x: 0, y: 100, w: 420, h: 640 })
|
|
})
|
|
|
|
it('migrates a rect persisted on a larger screen fully on screen', () => {
|
|
// Saved bottom-right on a 2560x1440 external monitor, reopened on a
|
|
// 1440x900 laptop: both axes end flush with the smaller viewport.
|
|
const r = containFloatRect({ x: 2116, y: 776, w: 420, h: 640 }, 1440, 900)
|
|
expect(r).toEqual({ x: 1020, y: 260, w: 420, h: 640 })
|
|
})
|
|
|
|
it('enforces the minimum size before containing', () => {
|
|
const r = containFloatRect({ x: 99999, y: 99999, w: 10, h: 10 }, WIDE, TALL)
|
|
expect(r.w).toBe(FLOAT_MIN_W)
|
|
expect(r.h).toBe(FLOAT_MIN_H)
|
|
expect(r.x).toBe(WIDE - FLOAT_MIN_W)
|
|
expect(r.y).toBe(TALL - FLOAT_MIN_H)
|
|
})
|
|
|
|
it('output is a fixpoint of clampFloatRect and of itself', () => {
|
|
// Pins the render-clamp no-op invariant: the contained rect passed
|
|
// through clampFloatRect (what AgentSheet renders) must not move again.
|
|
const cases = [
|
|
{ x: 2116, y: 776, w: 420, h: 640 },
|
|
{ x: -372, y: -50, w: 420, h: 640 },
|
|
{ x: 0, y: 0, w: 10, h: 10 },
|
|
{ x: 500, y: 300, w: 5000, h: 5000 },
|
|
]
|
|
for (const rect of cases) {
|
|
const contained = containFloatRect(rect, 1440, 900)
|
|
expect(clampFloatRect(contained, 1440, 900)).toEqual(contained)
|
|
expect(containFloatRect(contained, 1440, 900)).toEqual(contained)
|
|
}
|
|
})
|
|
})
|
|
|
|
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 })
|
|
})
|
|
})
|