fix(desktop): portal floating composer to body so it can't be clipped off-screen

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 <body> 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).
This commit is contained in:
Brooklyn Nicholson 2026-06-22 13:37:31 -05:00
parent 5937b95192
commit 79f270f549

View file

@ -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({
</div>
)
return (
const composerOverlay = (
<>
{dragging && poppedOut && (
<div
@ -2106,6 +2107,19 @@ export function ChatBar({
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
</>
)
return (
<>
{/* Floating: portal to <body> 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}
<UrlDialog
inputRef={urlInputRef}