fix(desktop): keep the floating composer in-bounds so it can't be lost off-screen

The pop-out position is a bottom-right corner inset; the old clamp only floored
it and capped each inset by a flat constant, so dragging left/up (or restoring a
position saved on a larger/other monitor) could push the box's width/height past
the left/top edges and strand it off-screen — unrecoverable since the bad spot
persisted to localStorage.

Now the clamp bounds the WHOLE box (accounting for its measured width/height plus
an edge margin) on all four sides. Applied on drag (measured size), on load
(clamped in readPosition), and via a mount + window-resize reclamp so a shrunk
window or stale persisted value always pulls the box back into view.
This commit is contained in:
Brooklyn Nicholson 2026-06-21 18:35:33 -05:00
parent 745c4db235
commit 7785655b4e
3 changed files with 86 additions and 24 deletions

View file

@ -239,15 +239,19 @@ export function useComposerPopoutGestures({
return
}
liveRef.current = setComposerPopoutPosition({
bottom: state.startBottom - (pending.y - state.startY),
right: state.startRight - (pending.x - state.startX)
})
const composer = composerRef.current
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
const rect = composerRef.current?.getBoundingClientRect()
liveRef.current = setComposerPopoutPosition(
{
bottom: state.startBottom - (pending.y - state.startY),
right: state.startRight - (pending.x - state.startX)
},
{ size }
)
if (rect) {
setDockProximity(dockProximityOf(rect))
if (composer) {
setDockProximity(dockProximityOf(composer.getBoundingClientRect()))
}
}
@ -297,13 +301,15 @@ export function useComposerPopoutGestures({
cancelRaf()
if (state.armed && state.mode === 'float') {
const rect = composerRef.current?.getBoundingClientRect()
const composer = composerRef.current
const rect = composer?.getBoundingClientRect()
if (rect && dockProximityOf(rect) >= 1) {
onDock()
} else {
// Persist the resting position once, on release — never per move.
setComposerPopoutPosition(liveRef.current, true)
const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined
setComposerPopoutPosition(liveRef.current, { persist: true, size })
}
}

View file

@ -40,7 +40,13 @@ import {
isBrowsingHistory,
resetBrowseState
} from '@/store/composer-input-history'
import { $composerPopoutPosition, $composerPoppedOut, POPOUT_WIDTH_REM, setComposerPoppedOut } from '@/store/composer-popout'
import {
$composerPopoutPosition,
$composerPoppedOut,
POPOUT_WIDTH_REM,
setComposerPoppedOut,
setComposerPopoutPosition
} from '@/store/composer-popout'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@ -536,6 +542,27 @@ export function ChatBar({
syncComposerMetrics()
}, [poppedOut, syncComposerMetrics])
// Keep the floating box on-screen: re-clamp (with the real measured size) when
// it pops out and whenever the window resizes — so a position persisted on a
// bigger/other monitor, or a shrunk window, can never strand it out of reach.
useEffect(() => {
if (!poppedOut) {
return undefined
}
const reclamp = (persist: boolean) => {
const el = composerRef.current
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
setComposerPopoutPosition($composerPopoutPosition.get(), { persist, size })
}
reclamp(true)
const onResize = () => reclamp(false)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [poppedOut])
useEffect(() => {
return () => {
const root = document.documentElement

View file

@ -33,7 +33,9 @@ function readPosition(): PopoutPosition {
const parsed = JSON.parse(raw) as Partial<PopoutPosition>
if (typeof parsed.bottom === 'number' && typeof parsed.right === 'number') {
return { bottom: parsed.bottom, right: parsed.right }
// Clamp on load — a position persisted on a larger/other monitor must not
// strand the box off-screen on this one.
return clampPosition({ bottom: parsed.bottom, right: parsed.right })
}
} catch {
// Corrupt value — fall back to the default corner.
@ -42,6 +44,40 @@ function readPosition(): PopoutPosition {
return DEFAULT_POSITION
}
export interface PopoutSize {
height: number
width: number
}
interface SetPositionOptions {
persist?: boolean
/** Measured box size; falls back to the compact width + a min height so the
* box stays grabbable even when the caller can't measure it. */
size?: PopoutSize
}
// Keep at least this much of every edge between the box and the viewport, so the
// floating composer can never be dragged (or restored) out of reach.
const EDGE_MARGIN = 8
// Height floor used when the real box height is unknown (init / load).
const MIN_VISIBLE_HEIGHT = 56
const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max(value, lo), Math.max(lo, hi))
const rootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
// Bound the bottom-right inset so the WHOLE box stays on-screen — the corner
// anchor alone would let the box's width/height push it past the left/top edges.
function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize): PopoutPosition {
const width = size?.width || POPOUT_WIDTH_REM * rootFontSize()
const height = size?.height || MIN_VISIBLE_HEIGHT
return {
bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN),
right: clampRange(right, EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN)
}
}
export const $composerPoppedOut = atom(storedBoolean(POPOUT_ENABLED_STORAGE_KEY, false))
export const $composerPopoutPosition = atom<PopoutPosition>(readPosition())
@ -50,19 +86,12 @@ export function setComposerPoppedOut(value: boolean) {
persistBoolean(POPOUT_ENABLED_STORAGE_KEY, value)
}
const clamp = (value: number, max: number) => Math.min(Math.max(0, value), Math.max(0, max))
// Clamp the corner inset so a viewport shrink (or a stale persisted value) can't
// strand the box fully off-screen.
const clampPosition = ({ bottom, right }: PopoutPosition): PopoutPosition => ({
bottom: clamp(bottom, window.innerHeight - 60),
right: clamp(right, window.innerWidth - 80)
})
/** Move the box (state only). Used per-frame during a drag no IO. Returns the
* clamped position so callers can keep their live ref in sync. */
export function setComposerPopoutPosition(position: PopoutPosition, persist = false): PopoutPosition {
const next = clampPosition(position)
/** Move the box (state only by default). Used per-frame during a drag no IO
* unless `persist`. Returns the clamped position so callers can sync their live
* ref. Pass the measured `size` for exact bounds; otherwise a fallback keeps it
* on-screen. */
export function setComposerPopoutPosition(position: PopoutPosition, { persist, size }: SetPositionOptions = {}): PopoutPosition {
const next = clampPosition(position, size)
$composerPopoutPosition.set(next)
if (persist) {