diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index 82237aa3..5e63dec5 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -25,6 +25,7 @@ import { DOCK_WIDTH_MIN, clampDockWidth, clampFloatRect, + containFloatRect, defaultFloatRect, expandedDockWidth, resizeFloatRect, @@ -251,6 +252,33 @@ export default function AgentSheet({ ) : null + // Open/resize-time validation of the persisted float rect: a rect saved at + // the viewport edge (or on a larger monitor) passes clampFloatRect but + // renders as a 48px sliver, so snap it fully on screen and persist the + // corrected position. panelPrefs.float is read through a ref, NOT the deps: + // putting it in the deps would re-run this on every drag commit and snap + // the window back mid-session, killing the deliberate hang-off-the-edge + // allowance of the live drag path. The effect fires on sheet mount (every + // fresh open remounts, keyed by the provider) and on viewport resize only. + const floatPrefRef = useRef(panelPrefs.float) + useEffect(() => { + floatPrefRef.current = panelPrefs.float + }, [panelPrefs.float]) + useEffect(() => { + if (!floating) return + const rect = floatPrefRef.current + if (!rect) return + const contained = containFloatRect(rect, viewport.w, viewport.h) + if ( + contained.x !== rect.x || + contained.y !== rect.y || + contained.w !== rect.w || + contained.h !== rect.h + ) { + updatePanelPrefs({ float: contained }) + } + }, [floating, viewport.w, viewport.h, updatePanelPrefs]) + // Reserve page margin while docked, compact AND expanded: both reflow the // page beside the panel instead of covering it (the original complaint). // Floating and collapsed claim nothing; below md the margin variable is diff --git a/lib/agent-panel/__tests__/geometry.test.ts b/lib/agent-panel/__tests__/geometry.test.ts index dd874ccf..610ea05f 100644 --- a/lib/agent-panel/__tests__/geometry.test.ts +++ b/lib/agent-panel/__tests__/geometry.test.ts @@ -11,6 +11,7 @@ import { FLOAT_KEEP_ON_SCREEN, clampDockWidth, clampFloatRect, + containFloatRect, defaultFloatRect, expandedDockWidth, resizeFloatRect, @@ -147,6 +148,56 @@ describe('clampFloatRect', () => { }) }) +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) diff --git a/lib/agent-panel/geometry.ts b/lib/agent-panel/geometry.ts index 9cfb5688..534037ef 100644 --- a/lib/agent-panel/geometry.ts +++ b/lib/agent-panel/geometry.ts @@ -122,6 +122,28 @@ export function clampFloatRect( return { x, y, w, h } } +/** + * Snap a floating rect fully inside the viewport. clampFloatRect above only + * guarantees FLOAT_KEEP_ON_SCREEN of the panel stays reachable (so a live + * drag may deliberately hang off an edge), which means a rect persisted at + * the edge, or on a larger monitor, legally renders as a 48px sliver on the + * next open. This is the open/resize-time validation: whole window visible. + * The output is always a fixpoint of clampFloatRect (w and h end up >= the + * minimums, x and y inside the reachability bounds), so the render clamp + * applied afterwards stays a no-op. + */ +export function containFloatRect( + 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))) + const x = Math.round(Math.min(Math.max(rect.x, 0), Math.max(0, viewportW - w))) + const y = Math.round(Math.min(Math.max(rect.y, 0), Math.max(0, viewportH - h))) + 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))