Merge pull request #74611 from NousResearch/bb/composer-strips-outside

Move the composer strips out of the pop-out drag region
This commit is contained in:
brooklyn! 2026-07-30 01:05:45 -05:00 committed by GitHub
commit eefcc098a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 185 additions and 324 deletions

View file

@ -14,6 +14,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
interface UseComposerMetricsArgs {
composerDockRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<HTMLFormElement | null>
composerSurfaceRef: RefObject<HTMLDivElement | null>
editorRef: RefObject<HTMLDivElement | null>
@ -28,7 +29,13 @@ interface UseComposerMetricsArgs {
* tree's computed style, and `tight` only flips when it crosses the breakpoint.
* Returns `stacked` (the only value the render needs).
*/
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
export function useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
}: UseComposerMetricsArgs): {
compactPill: boolean
stacked: boolean
} {
@ -89,8 +96,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
// The dock is the full docked footprint — strips, status stack, composer —
// so it, not the composer alone, is what the thread has to clear.
const dock = composerDockRef.current
if (!composer) {
if (!composer || !dock) {
return
}
@ -108,7 +118,8 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
return
}
const { height, width } = composer.getBoundingClientRect()
const { height } = dock.getBoundingClientRect()
const { width } = composer.getBoundingClientRect()
const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height
if (width > 0) {
@ -156,9 +167,9 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`)
}
}
}, [composerRef, composerSurfaceRef, editorRef])
}, [composerDockRef, composerRef, composerSurfaceRef, editorRef])
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
useResizeObserver(syncComposerMetrics, composerDockRef, composerRef, composerSurfaceRef, editorRef)
// Toggling pop-out changes whether the composer reserves thread clearance.
// The ResizeObserver may not fire (the box can keep the same box size), so

View file

@ -49,15 +49,7 @@ function gestureTargetOk(target: EventTarget | null) {
return false
}
// `composer-no-drag`: chrome that lives inside the composer root but isn't
// part of the draggable frame — the floating pill strips. The pills are
// `button`s and already excluded, but the strip's own box (the gaps between
// pills) isn't, so without this a press landing between two badges still
// drags. The strips are `w-fit`, so this costs the grab band only the width
// of the badges themselves.
return !target.closest(
'button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper], [data-slot="composer-no-drag"]'
)
return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
}
/** Floating composer's 5px outer frame — grab here to drag without long-press. */

View file

@ -53,6 +53,7 @@ import { useEmojiCompletions } from './hooks/use-emoji-completions'
import { useComposerMicroActions } from './hooks/use-micro-actions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useSessionStatusPresence } from './hooks/use-status-presence'
import { ActionBadges } from './micro-actions'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
@ -162,6 +163,9 @@ export function ChatBar({
useComposerMicroActions(statusSessionId, busy)
const composerRef = useRef<HTMLFormElement | null>(null)
// The dock wraps the strips + status stack + composer; the thread's bottom
// clearance measures this, while the pop-out drag still tracks the composer.
const composerDockRef = useRef<HTMLDivElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
// Pop-out engine: docked↔floating state, dock/float/toggle, drag gestures, and
@ -279,7 +283,14 @@ export function ChatBar({
return onCancel()
}, [activeQueueSessionKeyRef, onCancel])
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const { compactPill, stacked } = useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
})
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
@ -1020,36 +1031,29 @@ export function ChatBar({
/>
)}
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
{/* Dock column: owns the composer's POSITION and stacks, bottom-up,
[micro actions] · [status stack] · [composer] · [underside].
Anchored at the bottom, so in-flow children grow upward and still
overlay the thread no absolute lane needed.
The strips are siblings of the composer, not children: the pop-out
drag region is `absolute inset-0` INSIDE the composer, so anything
rendered in there is inside the grab area by construction. Keeping
them out here is what makes that impossible rather than excluded. */}
<div
className={cn(
'group/composer z-30 overflow-visible rounded-2xl',
'z-30 flex flex-col',
poppedOut
? // Floating: the composer (with its own border) floats with an even
// 5px transparent grab margin around it — drag that to move it.
'fixed w-[var(--composer-popout-width)] max-w-[calc(100vw-1.5rem)] bg-transparent p-[5px]'
: 'absolute bottom-0 left-1/2 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]',
dragging && 'cursor-grabbing select-none touch-none'
? 'fixed max-w-[calc(100vw-1.5rem)]'
: 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
)}
data-drag-active={dragActive ? '' : undefined}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-slot="composer-dock"
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
// Measured for the thread's bottom clearance: the dock is the box
// that contains the strips, the status stack, AND the composer, so
// one measurement covers everything the thread must clear.
ref={composerDockRef}
style={
poppedOut
? {
@ -1061,22 +1065,16 @@ export function ChatBar({
: undefined
}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{/* Aligned to the composer SURFACE, which sits inside the composer's
5px transparent grab margin so both strips carry the same inset
and share one left edge with it. */}
<div className={cn(composerFloatingStrip, 'px-[5px] pb-1.5 empty:hidden')}>
<ActionBadges sessionId={statusSessionId} />
</div>
{/* Session-scoped status stack (todos, subagents, background tasks,
queue). Out of flow so it never inflates the composer's measured
height; it overlays the chat instead of pushing it, and publishes
its own --status-stack-measured-height so the thread's clearance
accounts for it. Collapses to nothing when every status is empty. */}
queue). An in-flow dock child: the dock is bottom-anchored, so it
grows upward over the thread and the dock's own measurement covers
it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
@ -1106,6 +1104,44 @@ export function ChatBar({
}
sessionId={statusSessionId}
/>
<ComposerPrimitive.Root
className={cn(
'group/composer relative w-full overflow-visible rounded-2xl',
poppedOut && 'bg-transparent',
dragging && 'cursor-grabbing select-none touch-none'
)}
data-drag-active={dragActive ? '' : undefined}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
@ -1123,14 +1159,7 @@ export function ChatBar({
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={event => {
// The pill strips paint above this region; a double-click that
// lands on one must not float the composer. onPointerDown goes
// through gestureTargetOk, but this handler doesn't.
if (!(event.target as Element).closest('[data-slot="composer-no-drag"]')) {
handleComposerToggle()
}
}}
onDoubleClick={handleComposerToggle}
/>
)}
<div className="relative w-full rounded-[inherit]">
@ -1222,18 +1251,15 @@ export function ChatBar({
</div>
</div>
</div>
{/* Underside: a floating strip BELOW the whole composer surface.
Chrome-free by design contributions bring their own pill/skin,
like the micro-action strip above. In flow (the root is
bottom-anchored, so this grows the composer upward and stays on
screen) but OUTSIDE the surface, so it escapes the surface's
clipping, border, and scroll fade. Shares the micro-action
strip's grid so the two bracket the composer on one vertical
line. Renders nothing until something contributes. */}
<div className={cn(composerFloatingStrip, 'pt-1.5 empty:hidden')} data-slot="composer-no-drag">
</ComposerPrimitive.Root>
{/* Underside: chrome-free strip BELOW the composer. Outside the root
for the same reason as the micro actions it must not fall inside
the pop-out drag region. Same px as the strip above, so the two
bracket the composer on one vertical line. */}
<div className={cn(composerFloatingStrip, 'px-[5px] pt-1.5 empty:hidden')}>
<ContribSlot area={COMPOSER_AREAS.underside} />
</div>
</ComposerPrimitive.Root>
</div>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
<UrlDialog

View file

@ -1,8 +1,9 @@
import { memo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import type { ComposerAction } from '@/store/composer-actions'
import { $composerActionsBySession, type ComposerAction } from '@/store/composer-actions'
import { notifyError } from '@/store/notifications'
/**
@ -31,19 +32,14 @@ const PILL = cn(
* (`composerFloatingStrip`), this owns only the pills, so the strip above the
* surface and the `composer.underside` strip below it can't drift apart.
*/
export const ActionBadges = memo(function ActionBadges({
actions,
sessionId
}: {
actions: ComposerAction[]
sessionId: string
}) {
export const ActionBadges = memo(function ActionBadges({ sessionId }: { sessionId: null | string }) {
const actions = useSessionSlice($composerActionsBySession, sessionId)
// A pill can kick off async work (a gateway call, a submit). Track which one
// is in flight so it can spin and lock instead of double-firing.
const [runningId, setRunningId] = useState<null | string>(null)
const run = async (action: ComposerAction) => {
if (runningId) {
if (runningId || !sessionId) {
return
}

View file

@ -1,12 +1,11 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { type ReactNode, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard, composerFloatingStrip } from '@/components/chat/composer-dock'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -15,7 +14,6 @@ import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import { $composerActionsBySession } from '@/store/composer-actions'
import {
$statusItemsBySession,
type ComposerStatusItem,
@ -30,7 +28,6 @@ import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { ActionBadges } from './action-badges'
import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
@ -95,7 +92,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const actions = useSessionSlice($composerActionsBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
@ -154,10 +150,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
// Micro actions ride at the top of the stack — the one block you press
// rather than read. Rendered OUTSIDE the card (see `actionStrip`) so the
// pills float; a blocked account still gets the billing wall above them.
for (const group of groups) {
sections.push({
key: group.type,
@ -219,47 +211,11 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// status card, above the billing wall, above everything. They're the only
// rows up here you press instead of read, so nothing may ever stack on top
// of them. Rendered outside the card (below) so the pills float.
const actionStrip = actions.length > 0 && sessionId ? <ActionBadges actions={actions} sessionId={sessionId} /> : null
const visible = sections.length > 0
const visible = sections.length > 0 || Boolean(actionStrip)
const stackRef = useRef<HTMLDivElement | null>(null)
// The stack is out of flow (overlays the thread), so the composer's measured
// height never sees it. Publish our own measured height — bucketed like the
// composer's, to avoid style invalidation churn — so the thread's
// last-message clearance can add it and the stack never hides messages.
// Scoped to THIS surface: tiles render their own stack (see surface-vars.ts).
useLayoutEffect(() => {
const el = stackRef.current
if (!visible || !el) {
return
}
// Resolve the owning surface NOW, while the node is attached: the cleanup
// below runs after the stack collapsed and React removed the div, and a
// detached node can no longer find its [data-chat-surface].
const root = chatSurfaceRoot(el)
let last = -1
const sync = () => {
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
if (bucket !== last) {
last = bucket
setSurfaceVar(el, STATUS_STACK_VAR, `${bucket}px`)
}
}
const observer = new ResizeObserver(sync)
observer.observe(el)
sync()
return () => {
observer.disconnect()
clearSurfaceVar(root, STATUS_STACK_VAR)
}
}, [visible])
// No height to publish: the stack is an in-flow child of the composer dock,
// so the dock's own measurement (--composer-measured-height) already covers
// it and the thread clears both with one number.
if (!visible) {
return null
@ -267,56 +223,34 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
// pl matches the surface's own left edge: `inset-x-0` resolves against the
// root's PADDING box, while the surface and the underside strip sit in its
// CONTENT box, so without it the lane hangs 5px further left than both.
className="absolute inset-x-0 bottom-full z-3 flex max-h-[40vh] flex-col translate-y-2 pl-[0.3125rem]"
// In flow in the dock column, directly above the composer. The dock is
// bottom-anchored, so this grows upward over the thread without needing
// to be positioned — and it shares the dock's left edge for free.
className="flex max-h-[40vh] min-h-0 flex-col overflow-y-auto pb-2"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* FIRST in the lane and OUTSIDE the scroller, so nothing can ever sit
above the pills not the status card, not the billing wall and a
long todo list can't scroll them out of view. Outside the card too:
they carry their own fill, so they must not paint on its background. */}
{actionStrip && (
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerFloatingStrip,
'shrink-0 pb-1.5 transition-opacity duration-200 ease-out',
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{actionStrip}
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
{/* Everything else scrolls under them. */}
<div className="min-h-0 overflow-y-auto">
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
</div>
</div>
)
}

View file

@ -1,94 +0,0 @@
import { act, cleanup, render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { I18nProvider } from '@/i18n'
import { $goalsBySession, type SessionGoal } from '@/store/goals'
import { ComposerStatusStack } from './index'
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const SID = 'sess-height-1'
const goal = (): SessionGoal => ({ status: 'active', title: 'ship the feature', updatedAt: Date.now() })
/**
* Regression: when the stack collapses (its last item finishes), React removes
* the stack div BEFORE the layout-effect cleanup runs. Resolving the surface
* from the ref at cleanup time then walks a DETACHED node, misses
* [data-chat-surface], and clears the document root instead the stale height
* stays on the surface and keeps inflating the thread's bottom clearance
* (`--thread-last-message-clearance`) until the next publish. The effect must
* capture its surface root while the node is still attached.
*/
describe('ComposerStatusStack surface-var lifecycle', () => {
beforeEach(() => {
$goalsBySession.set({})
})
afterEach(() => {
cleanup()
$goalsBySession.set({})
document.documentElement.style.removeProperty(STATUS_STACK_VAR)
})
function renderOnSurface() {
const surface = document.createElement('div')
surface.setAttribute('data-chat-surface', '')
document.body.append(surface)
const view = render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={SID} />
</I18nProvider>
</MemoryRouter>,
{ container: surface }
)
return { surface, view }
}
it('publishes its measured height onto the owning surface while visible', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface } = renderOnSurface()
// jsdom measures 0 — the value is irrelevant, the target element is not.
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
it('clears the surface var when the stack collapses to nothing', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface } = renderOnSurface()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
// Last status item goes away → the component renders null and React
// detaches the stack div before the cleanup runs.
act(() => $goalsBySession.set({}))
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
it('clears the surface var on unmount', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface, view } = renderOnSurface()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
view.unmount()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
})

View file

@ -542,7 +542,7 @@ export function ChatView({
config={COMPOSER_HEART_CONFIG}
style={{
top: 0,
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
bottom: 'calc(var(--composer-measured-height) + 0.25rem)'
}}
/>
)}

View file

@ -11,8 +11,8 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
/**
* Floating "jump to bottom" control. Sits centered just above the composer,
* clearing the out-of-flow status stack via the same measured-height CSS vars
* the thread's bottom clearance uses (`--composer-measured-height` +
* `--status-stack-measured-height`), so it never overlaps the queue / subagent
* the thread's bottom clearance uses (`--composer-measured-height`, which
* covers the whole dock), so it never overlaps the queue / subagent
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
@ -62,7 +62,7 @@ export function ScrollToBottomButton() {
requestScrollToBottom()
}}
style={{
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.625rem)'
bottom: 'calc(var(--composer-measured-height) + 0.625rem)'
}}
tabIndex={visible ? 0 : -1}
type="button"

View file

@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from './surface-vars'
import { chatSurfaceRoot, clearSurfaceVar, COMPOSER_HEIGHT_VAR, setSurfaceVar } from './surface-vars'
/**
* Regression: the thread's bottom gap going randomly huge and staying huge.
@ -17,7 +17,7 @@ import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } fro
*/
describe('surface measured-height vars', () => {
afterEach(() => {
document.documentElement.style.removeProperty(STATUS_STACK_VAR)
document.documentElement.style.removeProperty(COMPOSER_HEIGHT_VAR)
document.body.replaceChildren()
})
@ -35,10 +35,10 @@ describe('surface measured-height vars', () => {
it('publishes onto the owning surface, not the document root', () => {
const { publisher, root } = surface()
setSurfaceVar(publisher, STATUS_STACK_VAR, '176px')
setSurfaceVar(publisher, COMPOSER_HEIGHT_VAR, '176px')
expect(root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('176px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
expect(root.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('176px')
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
it('never poisons the document root from a detached publisher', () => {
@ -48,18 +48,18 @@ describe('surface measured-height vars', () => {
// collapses, orphaning it from its surface, and a pending measurement
// publishes anyway. `closest()` from an orphan finds nothing.
publisher.remove()
setSurfaceVar(publisher, STATUS_STACK_VAR, '176px')
setSurfaceVar(publisher, COMPOSER_HEIGHT_VAR, '176px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
it('never poisons the document root from a publisher outside any surface', () => {
const orphan = document.createElement('div')
document.body.append(orphan)
setSurfaceVar(orphan, STATUS_STACK_VAR, '176px')
setSurfaceVar(orphan, COMPOSER_HEIGHT_VAR, '176px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
it('reports no owner for an orphaned or unowned node', () => {
@ -78,12 +78,12 @@ describe('surface measured-height vars', () => {
it('clears from the captured surface and tolerates a missing one', () => {
const { publisher, root } = surface()
setSurfaceVar(publisher, STATUS_STACK_VAR, '176px')
setSurfaceVar(publisher, COMPOSER_HEIGHT_VAR, '176px')
clearSurfaceVar(root, STATUS_STACK_VAR)
expect(root.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
clearSurfaceVar(root, COMPOSER_HEIGHT_VAR)
expect(root.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
expect(() => clearSurfaceVar(null, STATUS_STACK_VAR)).not.toThrow()
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
expect(() => clearSurfaceVar(null, COMPOSER_HEIGHT_VAR)).not.toThrow()
expect(document.documentElement.style.getPropertyValue(COMPOSER_HEIGHT_VAR)).toBe('')
})
})

View file

@ -26,7 +26,6 @@
export const COMPOSER_HEIGHT_VAR = '--composer-measured-height'
export const COMPOSER_SURFACE_HEIGHT_VAR = '--composer-surface-measured-height'
export const STATUS_STACK_VAR = '--status-stack-measured-height'
/**
* The surface owning `el`, or null when `el` is detached or outside one.

View file

@ -84,7 +84,7 @@ export const PendingApprovalFallback: FC = () => {
<div
className="pointer-events-none absolute left-1/2 z-30 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2"
data-slot="tool-approval-fallback"
style={{ bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.875rem)' }}
style={{ bottom: 'calc(var(--composer-measured-height) + 0.875rem)' }}
>
<div className="pointer-events-auto rounded-xl border border-primary/30 bg-(--ui-chat-surface-background) px-3 py-2 shadow-lg backdrop-blur-xl [-webkit-backdrop-filter:blur(1rem)]">
<div className="flex min-w-0 items-center gap-2 text-sm text-primary">

View file

@ -39,26 +39,15 @@ export const composerPanelCard = cn(
* the micro-action pills above the surface and the `composer.underside` slot
* below it.
*
* Both strips are in-flow children of the SAME box (the composer root's
* content box), which is the whole point: they previously lived in different
* parents the pills inside the status stack's absolute overlay lane, the
* chip in the root so "no padding" resolved to two different left edges and
* they never lined up. Same parent, no inset, one constant: the left edges are
* identical by construction, not by matching numbers in two places.
* Both are in-flow children of the composer DOCK, siblings of the composer
* itself rather than children of it. That's deliberate: the pop-out drag
* region is `absolute inset-0` inside the composer, so anything rendered in
* there is inside the grab area by construction. Living outside makes that
* impossible instead of something the gesture has to exclude.
*
* `relative z-1` at the call sites is load-bearing, not styling. The pop-out
* drag region is an `absolute` sibling, and positioned elements paint above
* static in-flow ones whatever the DOM order so without a stacking context
* these strips sit UNDER it and their contents never receive hover or clicks
* (the region does, and hatches).
*
* The strip is full-width so a contribution can push itself to the right
* (`ml-auto`), but it is `pointer-events-none` with its CHILDREN re-enabled:
* the chips are interactive, while the empty space between and beside them
* falls through to the drag region and stays grab area. That combination is
* why the composer is still draggable by the band its badges live in.
* One parent and one constant means the two strips share a left edge without
* anyone matching numbers across files. Vertical spacing stays at the call
* site; the horizontal inset matches the composer's 5px grab margin so the
* strips line up with the surface rather than the margin's outer edge.
*/
export const composerFloatingStrip = cn(
'relative z-1 flex w-full flex-wrap items-center gap-1.5',
'pointer-events-none [&>*]:pointer-events-auto'
)
export const composerFloatingStrip = 'flex flex-wrap items-center gap-1.5'

View file

@ -400,11 +400,11 @@
to the value the app actually renders at. */
--radius-scalar: 0.2;
/* Space under last message vs overlay composer driven by the measured composer height (see composer/index.tsx)
plus the out-of-flow status stack's measured height (see status-stack/index.tsx) when one is showing.
These are the defaults; each chat surface re-declares the calc against its own measurements (see below). */
--status-stack-measured-height: 0px;
--thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem);
/* Space under last message vs overlay composer driven by the measured composer DOCK height (see
composer/index.tsx), which covers the micro-action strip, the status stack, the composer, and the
underside strip in one number.
This is the default; each chat surface re-declares the calc against its own measurement (see below). */
--thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem);
--composer-shell-pad-block-end: 0.625rem;
--message-text-indent: 0.75rem;
@ -1277,21 +1277,29 @@ text-* variant utilities. */ .btn-arc {
inherits: true;
}
[data-slot='composer-root'] {
/* +10px width compensates the 5px side padding so the visible surface keeps
its exact width/position the inline padding is just transparent grab space
for the peel-out drag, matching the floating composer's 5px platform. */
/* The dock column owns the composer's width; the composer keeps the 5px
transparent grab margin that the peel-out drag needs. +10px width compensates
that padding so the visible surface keeps its exact width/position. */
[data-slot='composer-dock'] {
width: calc(min(var(--composer-width), calc(100% - 2rem)) + 10px);
padding-inline: 5px;
padding-bottom: var(--composer-shell-pad-block-end);
}
[data-slot='composer-root'] {
width: 100%;
padding-inline: 5px;
}
/* Popped-out (floating) composer: compact width + an even 5px transparent grab
platform. The higher-specificity selector resets the base rule's padding-bottom
so the inset is equal on all four sides (not 5px sides / shell-pad bottom). */
[data-slot='composer-root'][data-popped-out] {
[data-slot='composer-dock'][data-popped-out] {
width: var(--composer-popout-width, 24rem);
max-width: calc(100vw - 1.5rem);
padding-bottom: 0;
}
[data-slot='composer-root'][data-popped-out] {
padding: 5px;
}
@ -1733,13 +1741,13 @@ button[data-slot='aui_msg-reactions'] svg {
}
/* Re-declare the clearance calc on every chat surface. `:root` computes it
once against the ROOT measurements, so scoping only the inputs would leave
once against the ROOT measurement, so scoping only the input would leave
every thread reading the same substituted value. Redeclaring here makes each
surface resolve the calc against its own composer + status-stack heights,
falling back to the root defaults until this surface publishes its first
measurement. See surface-vars.ts. */
surface resolve the calc against its own dock height, falling back to the
root default until this surface publishes its first measurement. See
surface-vars.ts. */
[data-chat-surface] {
--thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem);
--thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem);
}
/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows