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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
78a581bca1
commit
7ccaab7a08
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user