From 7ccaab7a084bc175bba62c300c018d5b779a6fa9 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 13 Aug 2026 15:14:34 +0200 Subject: [PATCH] fix(agent): contain the floating assistant panel on open and resize (#1575) 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 --- components/agent/AgentSheet.tsx | 28 ++++++++++++ lib/agent-panel/__tests__/geometry.test.ts | 51 ++++++++++++++++++++++ lib/agent-panel/geometry.ts | 22 ++++++++++ 3 files changed, 101 insertions(+) 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))