fix(desktop): move composer out of contain wrapper instead of portaling

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 <body>. 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).
This commit is contained in:
Brooklyn Nicholson 2026-06-22 13:41:53 -05:00
parent 79f270f549
commit aff5ae692f
2 changed files with 65 additions and 71 deletions

View file

@ -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({
</div>
)
const composerOverlay = (
return (
<>
{dragging && poppedOut && (
<div
@ -2107,19 +2106,6 @@ 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}

View file

@ -433,17 +433,17 @@ export function ChatView({
<PromptOverlays />
<div
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
{...dropHandlers}
<ChatRuntimeBoundary
busy={busy}
onCancel={onCancel}
onEdit={onEdit}
onReload={onReload}
onThreadMessagesChange={onThreadMessagesChange}
suppressMessages={routeSessionMismatch}
>
<ChatRuntimeBoundary
busy={busy}
onCancel={onCancel}
onEdit={onEdit}
onReload={onReload}
onThreadMessagesChange={onThreadMessagesChange}
suppressMessages={routeSessionMismatch}
<div
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
{...dropHandlers}
>
<Thread
clampToComposer={showChatBar}
@ -458,54 +458,62 @@ export function ChatView({
sessionId={activeSessionId}
sessionKey={threadKey}
/>
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
focusKey={activeSessionId}
gateway={gateway}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSteer={onSteer}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
queueSessionKey={selectedSessionId}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
)}
</ChatRuntimeBoundary>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
{/* 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 dockfloat never remounts the editor. */}
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
focusKey={activeSessionId}
gateway={gateway}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSteer={onSteer}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
queueSessionKey={selectedSessionId}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
)}
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
</ChatRuntimeBoundary>
</div>
)
}