From 79f270f5496267ca9713d40af277e8453e528d8f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 13:37:31 -0500 Subject: [PATCH 1/4] fix(desktop): portal floating composer to body so it can't be clipped off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popped-out composer is position:fixed, but the chat content wrapper sets `contain: layout paint`, which makes it a containing block for — and clips — fixed descendants. Inline, the floating composer was positioned/clipped relative to the chat column (which shifts with the sidebars), not the viewport, so the viewport-based bounds clamp from #50466 couldn't keep it reachable: users still lost it off-screen. Portal it to when popped out so fixed positioning and the clamp finally share the viewport as their reference. Docked stays inline (it's absolute within the chat column by design). --- apps/desktop/src/app/chat/composer/index.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 44ad0fa2a39..f6a5c5ff48d 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -12,6 +12,7 @@ import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' @@ -1923,7 +1924,7 @@ export function ChatBar({ ) - return ( + const composerOverlay = ( <> {dragging && poppedOut && (
+ + ) + + return ( + <> + {/* Floating: portal to so position:fixed resolves against the + viewport. The chat content wrapper sets `contain: layout paint`, which + makes it a containing block for (and clips) fixed descendants — left + inline, the popped-out composer is positioned/clipped relative to the + chat column (which shifts with the sidebars), not the viewport, so the + viewport-based clamp can't keep it on-screen. Docked stays inline: it's + `absolute` within that column by design. */} + {poppedOut ? createPortal(composerOverlay, document.body) : composerOverlay} Date: Mon, 22 Jun 2026 13:41:53 -0500 Subject: [PATCH 2/4] fix(desktop): move composer out of contain wrapper instead of portaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the body-portal approach: render ChatBar as a sibling of the contain:[layout paint] chat wrapper (inside the same runtime boundary) rather than portaling the floating instance to . The wrapper is a containing block for — and clips — position:fixed descendants, which is what stranded the popped-out composer off-screen. As a sibling it anchors to the outer relative container: docked stays absolute (identical placement), floating resolves against the viewport. Both states stay mounted, so dock<->float no longer remounts the editor (the portal toggle did). --- apps/desktop/src/app/chat/composer/index.tsx | 16 +-- apps/desktop/src/app/chat/index.tsx | 120 ++++++++++--------- 2 files changed, 65 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index f6a5c5ff48d..44ad0fa2a39 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -12,7 +12,6 @@ import { useRef, useState } from 'react' -import { createPortal } from 'react-dom' import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' @@ -1924,7 +1923,7 @@ export function ChatBar({
) - const composerOverlay = ( + return ( <> {dragging && poppedOut && (
- - ) - - return ( - <> - {/* Floating: portal to so position:fixed resolves against the - viewport. The chat content wrapper sets `contain: layout paint`, which - makes it a containing block for (and clips) fixed descendants — left - inline, the popped-out composer is positioned/clipped relative to the - chat column (which shifts with the sidebars), not the viewport, so the - viewport-based clamp can't keep it on-screen. Docked stays inline: it's - `absolute` within that column by design. */} - {poppedOut ? createPortal(composerOverlay, document.body) : composerOverlay} -
- - {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 && } - - -
+ ) } From ea5fa505d9743d1f6e0036480a36eaebc60d79af Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 13:57:53 -0500 Subject: [PATCH 3/4] fix(desktop): clamp floating composer to the thread area, not the whole window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the popped-out composer is fixed to the viewport, clamping against the window let it slide under a pinned sidebar. Confine it to the thread region (data-slot="composer-bounds") instead — its rect already excludes a pinned sidebar and the header — falling back to the full window before it's measured. This subsumes the old titlebar top-margin (the thread rect starts below the header). --- .../chat/composer/hooks/use-popout-drag.ts | 9 ++-- apps/desktop/src/app/chat/composer/index.tsx | 3 +- apps/desktop/src/app/chat/index.tsx | 1 + apps/desktop/src/store/composer-popout.ts | 50 +++++++++++++------ 4 files changed, 42 insertions(+), 21 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 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..ae175c902eb 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' @@ -553,7 +554,7 @@ 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) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 10421d3d91f..2b6586cf5a1 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -443,6 +443,7 @@ export function ChatView({ >
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, left, right, top } = el.getBoundingClientRect() + + return { bottom, left, right, top } } -// 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 +120,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) { From de7ad8b78eaeab96324b9800e28f12d8b92e83a7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 13:59:26 -0500 Subject: [PATCH 4/4] fix(desktop): guarantee out-of-bounds composer is reclamped on load Re-clamp once more on the next frame after pop-out so layout (sidebar widths, fonts) has settled, and treat a degenerate pre-layout bounds rect as "unknown" (fall back to the window) so we never clamp the box into a collapsed area. Net: anyone who loads in with a stranded position is pulled back on-screen and the fix is persisted, even if the first measure was premature. --- apps/desktop/src/app/chat/composer/index.tsx | 15 +++++++++++---- apps/desktop/src/store/composer-popout.ts | 6 ++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ae175c902eb..1ecc76de8bc 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -543,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 @@ -558,10 +561,14 @@ export function ChatBar({ } 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/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index 1cc2d5f2f96..a739f2f3cb8 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -88,9 +88,11 @@ export function readPopoutBounds(composer: Element | null): PopoutBounds | undef return undefined } - const { bottom, left, right, top } = el.getBoundingClientRect() + const { bottom, height, left, right, top, width } = el.getBoundingClientRect() - return { bottom, left, right, top } + // 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 inside `area` (the thread