mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
eefcc098a7
13 changed files with 185 additions and 324 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue