From 7785655b4ece4deb7e8bbeeaa2a6a8342746d465 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 21 Jun 2026 18:35:33 -0500 Subject: [PATCH 1/2] fix(desktop): keep the floating composer in-bounds so it can't be lost off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../chat/composer/hooks/use-popout-drag.ts | 24 +++++--- apps/desktop/src/app/chat/composer/index.tsx | 29 +++++++++- apps/desktop/src/store/composer-popout.ts | 57 ++++++++++++++----- 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 3333995e3..2988a0715 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -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 }) } } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1427a21b0..44ad0fa2a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index 9327cdce5..6df9dc4d3 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -33,7 +33,9 @@ function readPosition(): PopoutPosition { const parsed = JSON.parse(raw) as Partial 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(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) { From 16aeba17078d5470f21eedb504142554169e748c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 21 Jun 2026 18:52:01 -0500 Subject: [PATCH 2/2] fix(desktop): clamp composer peel-off under cursor Keep the floating composer bounded from the first peel-off frame and leave titlebar clearance when recovering bad persisted positions. --- .../chat/composer/hooks/use-popout-drag.ts | 50 +++++++++++++------ apps/desktop/src/store/composer-popout.ts | 20 ++++++-- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 2988a0715..1c6f99320 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -7,8 +7,13 @@ import { useState } from 'react' -import type { PopoutPosition } from '@/store/composer-popout' -import { POPOUT_WIDTH_REM, setComposerPopoutPosition } from '@/store/composer-popout' +import { + POPOUT_ESTIMATED_HEIGHT, + POPOUT_WIDTH_REM, + setComposerPopoutPosition, + type PopoutPosition, + type PopoutSize +} from '@/store/composer-popout' // Floating surface long-press before it becomes draggable (the 5px platform drags // instantly; this only covers grabbing the composer body itself). @@ -82,6 +87,23 @@ function dockProximityOf(rect: DOMRect) { return v * h } +const clampOffset = (value: number, max: number) => Math.min(Math.max(0, value), max) + +/** Fixed-position composer uses bottom/right insets; keep the grab point under the pointer. */ +function popoutPositionUnderPointer( + clientX: number, + clientY: number, + grabX: number, + grabY: number, + boxWidth: number, + boxHeight: number +): PopoutPosition { + return { + bottom: window.innerHeight - clientY + grabY - boxHeight, + right: window.innerWidth - clientX + grabX - boxWidth + } +} + /** * Gesture pop-out / dock for the composer — fully gestural, no hold-to-toggle. * @@ -123,14 +145,15 @@ export function useComposerPopoutGestures({ }, [clearTimer]) const beginFloatDrag = useCallback( - (state: PressState, clientX: number, clientY: number, next: PopoutPosition) => { + (state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => { clearTimer() - liveRef.current = setComposerPopoutPosition(next) + const clamped = setComposerPopoutPosition(next, { size }) + liveRef.current = clamped state.mode = 'float' state.armed = true - state.startBottom = next.bottom - state.startRight = next.right + state.startBottom = clamped.bottom + state.startRight = clamped.right state.startX = clientX state.startY = clientY @@ -147,21 +170,16 @@ export function useComposerPopoutGestures({ return } - // The docked composer is full-width; the floating one is compact. Center it - // horizontally on the cursor (the docked grab-X is meaningless at the new - // width), but preserve the vertical grab offset so the pointer keeps its - // spot (grab the top → stay at the top). const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 const rect = composer.getBoundingClientRect() const boxWidth = POPOUT_WIDTH_REM * rem - const grabY = Math.min(Math.max(0, state.startY - rect.top), rect.height) - const next: PopoutPosition = { - bottom: window.innerHeight - (clientY - grabY + rect.height), - right: window.innerWidth - clientX - boxWidth / 2 - } + const boxHeight = POPOUT_ESTIMATED_HEIGHT + const grabX = clampOffset(state.startX - rect.left, boxWidth) + const grabY = clampOffset(state.startY - rect.top, boxHeight) + const next = popoutPositionUnderPointer(clientX, clientY, grabX, grabY, boxWidth, boxHeight) + beginFloatDrag(state, clientX, clientY, next, { height: boxHeight, width: boxWidth }) onPopOutRef.current() - beginFloatDrag(state, clientX, clientY, next) }, [beginFloatDrag, composerRef] ) diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index 6df9dc4d3..66e758aa1 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -15,7 +15,7 @@ export interface PopoutPosition { } // Floating composer width (rem). Shared by the inline style that sets -// --composer-popout-width and the peel-off drag math (to center it on the cursor). +// --composer-popout-width and the peel-off drag math. export const POPOUT_WIDTH_REM = 19.5 // Default pop-out placement: tucked into the bottom-right of the thread, clear @@ -59,21 +59,33 @@ interface SetPositionOptions { // 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 TITLEBAR_HEIGHT_FALLBACK = 34 +const TITLEBAR_CLEARANCE_REM = 0.75 +// Height floor used when the real box height is unknown (init / load / peel-off). +export const POPOUT_ESTIMATED_HEIGHT = 56 +const MIN_VISIBLE_HEIGHT = POPOUT_ESTIMATED_HEIGHT 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 +function titlebarTopMargin() { + const raw = getComputedStyle(document.documentElement).getPropertyValue('--titlebar-height').trim() + const titlebarHeight = Number.parseFloat(raw) + const breathingRoom = TITLEBAR_CLEARANCE_REM * rootFontSize() + + return Math.max(EDGE_MARGIN, (Number.isFinite(titlebarHeight) ? titlebarHeight : TITLEBAR_HEIGHT_FALLBACK) + breathingRoom) +} + // 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 + const topMargin = titlebarTopMargin() return { - bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN), + bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - topMargin), right: clampRange(right, EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN) } }