diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 685644691ef..7353dc102e6 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -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 composerRef: RefObject composerSurfaceRef: RefObject editorRef: RefObject @@ -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 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 4af64604043..e4a53889e5b 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 @@ -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. */ diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c249a7a25a7..dbcef7bfe28 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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(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(null) const composerSurfaceRef = useRef(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({ /> )} - { - 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 && } - {trigger && !argStageEmpty && ( - - )} + {/* 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. */} +
+ +
{/* 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. */} 0 ? ( @@ -1106,6 +1104,44 @@ export function ChatBar({ } sessionId={statusSessionId} /> + { + e.preventDefault() + + if (composingRef.current) { + return + } + + submitDraft() + }} + ref={composerRef} + > + {isHelpHint && } + {trigger && !argStageEmpty && ( + + )} {!poppedOut && (
{ - // 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} /> )}
@@ -1222,18 +1251,15 @@ export function ChatBar({
- {/* 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. */} -
+ + {/* 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. */} +
- +
(null) const run = async (action: ComposerAction) => { - if (runningId) { + if (runningId || !sessionId) { return } diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index f3b0cf4f365..7814674bbfd 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -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: }) } - // 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 ? : null + const visible = sections.length > 0 - const visible = sections.length > 0 || Boolean(actionStrip) - const stackRef = useRef(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 (
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 && (
- {actionStrip} + {sections.map(section => ( +
{section.node}
+ ))}
)} - {/* Everything else scrolls under them. */} -
- {/* 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 && ( -
- {sections.map(section => ( -
{section.node}
- ))} -
- )} -
) } diff --git a/apps/desktop/src/app/chat/composer/status-stack/surface-var-lifecycle.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/surface-var-lifecycle.test.tsx deleted file mode 100644 index a01563aedc3..00000000000 --- a/apps/desktop/src/app/chat/composer/status-stack/surface-var-lifecycle.test.tsx +++ /dev/null @@ -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( - - - - - , - { 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('') - }) -}) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 40a8922d453..5c0bc521485 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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)' }} /> )} diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx index dfe8b4e4d0b..eaab1def046 100644 --- a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx @@ -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" diff --git a/apps/desktop/src/app/chat/surface-vars.test.ts b/apps/desktop/src/app/chat/surface-vars.test.ts index 0f0acadcbdd..fc8385e3397 100644 --- a/apps/desktop/src/app/chat/surface-vars.test.ts +++ b/apps/desktop/src/app/chat/surface-vars.test.ts @@ -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('') }) }) diff --git a/apps/desktop/src/app/chat/surface-vars.ts b/apps/desktop/src/app/chat/surface-vars.ts index 16c972e19d3..45ff7bf91b8 100644 --- a/apps/desktop/src/app/chat/surface-vars.ts +++ b/apps/desktop/src/app/chat/surface-vars.ts @@ -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. diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index 645dd2f89f4..50de684f9d9 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -84,7 +84,7 @@ export const PendingApprovalFallback: FC = () => {
diff --git a/apps/desktop/src/components/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts index 59084ca764c..88db91d2dac 100644 --- a/apps/desktop/src/components/chat/composer-dock.ts +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -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' diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index aaa00f9fc8c..4fea8ed294f 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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