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 1c6f99320ac..38feb50d9ae 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 @@ -10,6 +10,7 @@ import { import { POPOUT_ESTIMATED_HEIGHT, POPOUT_WIDTH_REM, + readPopoutBounds, setComposerPopoutPosition, type PopoutPosition, type PopoutSize @@ -147,7 +148,7 @@ export function useComposerPopoutGestures({ const beginFloatDrag = useCallback( (state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => { clearTimer() - const clamped = setComposerPopoutPosition(next, { size }) + const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size }) liveRef.current = clamped state.mode = 'float' @@ -159,7 +160,7 @@ export function useComposerPopoutGestures({ setDragging(true) }, - [clearTimer] + [clearTimer, composerRef] ) const peelOffFromDock = useCallback( @@ -265,7 +266,7 @@ export function useComposerPopoutGestures({ bottom: state.startBottom - (pending.y - state.startY), right: state.startRight - (pending.x - state.startX) }, - { size } + { area: readPopoutBounds(composer), size } ) if (composer) { @@ -327,7 +328,7 @@ export function useComposerPopoutGestures({ } else { // Persist the resting position once, on release — never per move. const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined - setComposerPopoutPosition(liveRef.current, { persist: true, size }) + setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size }) } } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 44ad0fa2a39..1ecc76de8bc 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -44,6 +44,7 @@ import { $composerPopoutPosition, $composerPoppedOut, POPOUT_WIDTH_REM, + readPopoutBounds, setComposerPoppedOut, setComposerPopoutPosition } from '@/store/composer-popout' @@ -542,9 +543,12 @@ 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. + // Keep the floating box on-screen: re-clamp (with the real measured size + + // thread bounds) when it pops out and on every window resize — so a position + // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar + // can never strand it. The rAF pass re-clamps after layout settles (sidebar + // widths, fonts), so anyone loading in out of bounds is pulled back + saved + // even if the first measure was premature. useEffect(() => { if (!poppedOut) { return undefined @@ -553,14 +557,18 @@ export function ChatBar({ const reclamp = (persist: boolean) => { const el = composerRef.current const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined - setComposerPopoutPosition($composerPopoutPosition.get(), { persist, size }) + setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) } reclamp(true) + const raf = requestAnimationFrame(() => reclamp(true)) const onResize = () => reclamp(false) window.addEventListener('resize', onResize) - return () => window.removeEventListener('resize', onResize) + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', onResize) + } }, [poppedOut]) useEffect(() => { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 4ae3817c888..2b6586cf5a1 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -433,17 +433,18 @@ export function ChatView({ -
- - {showChatBar && ( - }> - - + {resumeExhausted && routedSessionId && ( +
+ +
+ +
+
+
)} -
- {resumeExhausted && routedSessionId && ( -
- -
- -
-
-
+ {showChatBar && } + + +
+ {/* Composer renders OUTSIDE the contain:[layout paint] wrapper above: + that wrapper is a containing block for — and clips — position:fixed + descendants, so the popped-out (fixed) composer would anchor to the + chat column (which shifts/resizes with the sidebars) and get clipped + off-screen instead of floating against the viewport. As a sibling it + anchors to the outer relative container instead: docked is absolute + (identical placement), floating resolves against the viewport. Both + states stay mounted here, so dock⇄float never remounts the editor. */} + {showChatBar && ( + }> + + )} - {showChatBar && } - - - + ) } diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index 66e758aa1f0..a739f2f3cb8 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -49,18 +49,28 @@ export interface PopoutSize { width: number } +/** Viewport-space rect the floating composer is confined to. Defaults to the + * whole window; pass the thread area so the box can't slide under a pinned + * sidebar or behind the header. */ +export interface PopoutBounds { + bottom: number + left: number + right: number + top: number +} + interface SetPositionOptions { + /** Thread-area rect to confine the box to; falls back to the full window. */ + area?: PopoutBounds 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 +// Keep at least this much between the box and every edge of its bounds, so the // floating composer can never be dragged (or restored) out of reach. const EDGE_MARGIN = 8 -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 @@ -69,24 +79,34 @@ const clampRange = (value: number, lo: number, hi: number) => Math.min(Math.max( 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() +/** The thread area's viewport rect (excludes a pinned sidebar + the header), or + * undefined before it mounts — callers then fall back to the full window. */ +export function readPopoutBounds(composer: Element | null): PopoutBounds | undefined { + const el = (composer?.parentElement ?? document).querySelector('[data-slot="composer-bounds"]') - return Math.max(EDGE_MARGIN, (Number.isFinite(titlebarHeight) ? titlebarHeight : TITLEBAR_HEIGHT_FALLBACK) + breathingRoom) + if (!el) { + return undefined + } + + const { bottom, height, left, right, top, width } = el.getBoundingClientRect() + + // Pre-layout (mount before first layout) the rect is empty — fall back to the + // window rather than clamping the box into a collapsed area. + return width > 0 && height > 0 ? { bottom, left, right, top } : undefined } -// 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 { +// Bound the bottom/right inset so the WHOLE box stays inside `area` (the thread +// region, or the window by default) — the corner anchor alone would let the +// box's width/height push it past the opposite edges. +function clampPosition({ bottom, right }: PopoutPosition, size?: PopoutSize, area?: PopoutBounds): PopoutPosition { const width = size?.width || POPOUT_WIDTH_REM * rootFontSize() const height = size?.height || MIN_VISIBLE_HEIGHT - const topMargin = titlebarTopMargin() + const { innerHeight: vh, innerWidth: vw } = window + const a = area ?? { bottom: vh, left: 0, right: vw, top: 0 } return { - bottom: clampRange(bottom, EDGE_MARGIN, window.innerHeight - height - topMargin), - right: clampRange(right, EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN) + bottom: clampRange(bottom, vh - a.bottom + EDGE_MARGIN, vh - a.top - height - EDGE_MARGIN), + right: clampRange(right, vw - a.right + EDGE_MARGIN, vw - a.left - width - EDGE_MARGIN) } } @@ -102,8 +122,8 @@ export function setComposerPoppedOut(value: boolean) { * 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) +export function setComposerPopoutPosition(position: PopoutPosition, { area, persist, size }: SetPositionOptions = {}): PopoutPosition { + const next = clampPosition(position, size, area) $composerPopoutPosition.set(next) if (persist) {