From 236f0597e562c2db449e0b38ac15b3dfc20ceb89 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 01:35:30 -0500 Subject: [PATCH 1/3] feat(desktop): pop the composer out into a draggable floating window Gesture-driven: drag the docked composer up to peel it out, drag it back to the bottom-center dock zone (radial glow ramps with proximity) to redock, and double-click the grab area to toggle. Floating composer is compact, grows upward as it wraps, and can be moved by its 5px transparent grab platform (diagonal hatch on hover). Position + popped state persist; secondary windows always start docked. rAF-coalesced drag, persisted only on release. --- .../src/app/chat/composer/context-menu.tsx | 2 +- .../src/app/chat/composer/controls.tsx | 20 +- .../chat/composer/hooks/use-popout-drag.ts | 323 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 126 ++++++- .../src/app/chat/composer/model-pill.tsx | 27 +- apps/desktop/src/store/composer-popout.ts | 69 ++++ apps/desktop/src/styles.css | 61 +++- 7 files changed, 599 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts create mode 100644 apps/desktop/src/store/composer-popout.ts diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 22c10985f829..3866e2814b5c 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -54,7 +54,7 @@ export function ContextMenu({ type="button" variant="ghost" > - + diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 6d748c73b5f6..7bef1e827674 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -43,6 +43,7 @@ export function ComposerControls({ busyAction, canSteer, canSubmit, + compactModelPill = false, conversation, disabled, hasComposerPayload, @@ -55,6 +56,7 @@ export function ComposerControls({ busyAction: 'queue' | 'stop' canSteer: boolean canSubmit: boolean + compactModelPill?: boolean conversation: ConversationProps disabled: boolean hasComposerPayload: boolean @@ -83,7 +85,7 @@ export function ComposerControls({ return (
- + {/* While the agent runs and the user is typing, steer takes over the mic's slot rather than crowding the row with an extra button. */} {canSteer ? ( @@ -97,7 +99,7 @@ export function ComposerControls({ type="button" variant="ghost" > - + ) : ( @@ -116,7 +118,7 @@ export function ComposerControls({ size="icon" type="button" > - + ) : ( @@ -129,12 +131,12 @@ export function ComposerControls({ > {busy ? ( busyAction === 'queue' ? ( - + ) : ( - + ) ) : ( - + )} @@ -293,11 +295,11 @@ function DictationButton({ variant="ghost" > {status === 'recording' ? ( - + ) : status === 'transcribing' ? ( - + ) : ( - + )} 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 new file mode 100644 index 000000000000..650089e5d96b --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -0,0 +1,323 @@ +import { + type PointerEvent as ReactPointerEvent, + type RefObject, + useCallback, + useEffect, + useRef, + useState +} from 'react' + +import type { PopoutPosition } from '@/store/composer-popout' +import { setComposerPopoutPosition } from '@/store/composer-popout' + +// Floating surface long-press before it becomes draggable (the 5px platform drags +// instantly; this only covers grabbing the composer body itself). +const LONG_PRESS_MS = 360 +const LONG_PRESS_MOVE_TOLERANCE = 10 +// Upward drag distance from the docked composer that peels it off into a float. +const PEEL_OUT_PX = 16 +const DOCK_ZONE_BOTTOM_PX = 72 +// How close the composer's center must be to the viewport center (px) to count as +// "over the dock". Kept tight so the bottom-left/right corners stay free. +const DOCK_ZONE_CENTER_TOLERANCE_PX = 150 +// Falloff distances over which dock proximity ramps from 1 (in-zone) down to 0. +const DOCK_VERTICAL_FALLOFF_PX = 260 +const DOCK_HORIZONTAL_FALLOFF_PX = 220 + +interface PressState { + armed: boolean + mode: 'dock' | 'float' + pointerId: number + startBottom: number + startRight: number + startX: number + startY: number +} + +interface ComposerPopoutGesturesOptions { + composerRef: RefObject + onDock: () => void + onPopOut: () => void + poppedOut: boolean + position: PopoutPosition +} + +function gestureTargetOk(target: EventTarget | null) { + if (!(target instanceof Element)) { + return false + } + + 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. */ +function isFloatDragPlatform(target: EventTarget | null) { + if (!(target instanceof Element)) { + return false + } + + if (!target.closest('[data-slot="composer-root"][data-popped-out]')) { + return false + } + + if (target.closest('[data-slot="composer-surface"], [data-slot="composer-rich-input"]')) { + return false + } + + return gestureTargetOk(target) +} + +function positionFromRect(rect: DOMRect): PopoutPosition { + return { + bottom: window.innerHeight - rect.bottom, + right: window.innerWidth - rect.right + } +} + +/** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the + * release-to-dock test (which fires at proximity 1). */ +function dockProximityOf(rect: DOMRect) { + const horizontalDist = Math.abs(rect.left + rect.width / 2 - window.innerWidth / 2) + const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom + + const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX) + const h = + horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX + ? 1 + : Math.max(0, 1 - (horizontalDist - DOCK_ZONE_CENTER_TOLERANCE_PX) / DOCK_HORIZONTAL_FALLOFF_PX) + + return v * h +} + +/** + * Gesture pop-out / dock for the composer — fully gestural, no hold-to-toggle. + * + * Docked: drag the composer upward (off the dock) to peel it out into a float, + * then keep dragging in the same motion. + * Floating: drag the 5px frame to move instantly, or long-press the body then + * drag; release over the bottom-center dock band to snap back in. + */ +export function useComposerPopoutGestures({ + composerRef, + onDock, + onPopOut, + poppedOut, + position +}: ComposerPopoutGesturesOptions) { + const [dragging, setDragging] = useState(false) + const [dockProximity, setDockProximity] = useState(0) + + const stateRef = useRef(null) + const timerRef = useRef(null) + const liveRef = useRef(position) + liveRef.current = position + + const onPopOutRef = useRef(onPopOut) + onPopOutRef.current = onPopOut + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + const resetGesture = useCallback(() => { + clearTimer() + stateRef.current = null + setDragging(false) + setDockProximity(0) + }, [clearTimer]) + + const beginFloatDrag = useCallback( + (state: PressState, clientX: number, clientY: number, next: PopoutPosition) => { + clearTimer() + liveRef.current = setComposerPopoutPosition(next) + + state.mode = 'float' + state.armed = true + state.startBottom = next.bottom + state.startRight = next.right + state.startX = clientX + state.startY = clientY + + setDragging(true) + }, + [clearTimer] + ) + + const peelOffFromDock = useCallback( + (state: PressState, clientX: number, clientY: number) => { + const composer = composerRef.current + + if (!composer) { + return + } + + const next = positionFromRect(composer.getBoundingClientRect()) + onPopOutRef.current() + beginFloatDrag(state, clientX, clientY, next) + }, + [beginFloatDrag, composerRef] + ) + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0 || !gestureTargetOk(event.target)) { + return + } + + // Floating: grabbing the 5px platform drags immediately. + if (poppedOut && isFloatDragPlatform(event.target)) { + stateRef.current = { + armed: true, + mode: 'float', + pointerId: event.pointerId, + startBottom: liveRef.current.bottom, + startRight: liveRef.current.right, + startX: event.clientX, + startY: event.clientY + } + setDragging(true) + + return + } + + stateRef.current = { + armed: false, + mode: poppedOut ? 'float' : 'dock', + pointerId: event.pointerId, + startBottom: liveRef.current.bottom, + startRight: liveRef.current.right, + startX: event.clientX, + startY: event.clientY + } + + clearTimer() + + // Docked has NO timer — pop-out is purely the upward peel gesture (handled + // in pointermove). Floating arms a long-press to drag the body. + if (poppedOut) { + timerRef.current = window.setTimeout(() => { + const state = stateRef.current + + if (!state || state.armed) { + return + } + + state.armed = true + setDragging(true) + }, LONG_PRESS_MS) + } + }, + [clearTimer, poppedOut] + ) + + useEffect(() => { + // Coalesce drag updates to one per frame — pointermove can fire several times + // between paints on high-Hz mice, and each update re-renders + clamps. + let raf: number | null = null + let pending: { x: number; y: number } | null = null + + const cancelRaf = () => { + if (raf !== null) { + cancelAnimationFrame(raf) + raf = null + } + } + + const flush = () => { + raf = null + const state = stateRef.current + + if (!state?.armed || state.mode !== 'float' || !pending) { + return + } + + liveRef.current = setComposerPopoutPosition({ + bottom: state.startBottom - (pending.y - state.startY), + right: state.startRight - (pending.x - state.startX) + }) + + const rect = composerRef.current?.getBoundingClientRect() + + if (rect) { + setDockProximity(dockProximityOf(rect)) + } + } + + const handleMove = (event: PointerEvent) => { + const state = stateRef.current + + if (!state || event.pointerId !== state.pointerId) { + return + } + + // Pre-arm: cheap threshold checks run inline (no per-frame work yet). + if (!state.armed) { + const deltaX = event.clientX - state.startX + const deltaY = event.clientY - state.startY + + if (state.mode === 'dock') { + // Peel off only on a clear upward drag — not a sideways/down wiggle. + if (-deltaY > PEEL_OUT_PX && -deltaY > Math.abs(deltaX)) { + peelOffFromDock(state, event.clientX, event.clientY) + } else if (Math.abs(deltaX) > PEEL_OUT_PX || deltaY > LONG_PRESS_MOVE_TOLERANCE) { + resetGesture() + } + } else if (Math.abs(deltaX) > LONG_PRESS_MOVE_TOLERANCE || Math.abs(deltaY) > LONG_PRESS_MOVE_TOLERANCE) { + // Float body long-press pending: movement cancels the hold. + resetGesture() + } + + return + } + + if (state.mode !== 'float') { + return + } + + event.preventDefault() + pending = { x: event.clientX, y: event.clientY } + raf ??= requestAnimationFrame(flush) + } + + const handleUp = (event: PointerEvent) => { + const state = stateRef.current + + if (!state || event.pointerId !== state.pointerId) { + return + } + + cancelRaf() + + if (state.armed && state.mode === 'float') { + const rect = composerRef.current?.getBoundingClientRect() + + if (rect && dockProximityOf(rect) >= 1) { + onDock() + } else { + // Persist the resting position once, on release — never per move. + setComposerPopoutPosition(liveRef.current, true) + } + } + + resetGesture() + } + + window.addEventListener('pointermove', handleMove) + window.addEventListener('pointerup', handleUp) + window.addEventListener('pointercancel', handleUp) + + return () => { + cancelRaf() + window.removeEventListener('pointermove', handleMove) + window.removeEventListener('pointerup', handleUp) + window.removeEventListener('pointercancel', handleUp) + } + }, [composerRef, onDock, peelOffFromDock, resetGesture]) + + useEffect(() => clearTimer, [clearTimer]) + + return { dockProximity, dragging, onPointerDown } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index dc3f0a490cbc..93da3cedbd0e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -40,6 +40,7 @@ import { isBrowsingHistory, resetBrowseState } from '@/store/composer-input-history' +import { $composerPopoutPosition, $composerPoppedOut, setComposerPoppedOut } from '@/store/composer-popout' import { $queuedPromptsBySession, enqueueQueuedPrompt, @@ -55,6 +56,7 @@ import { $statusItemsBySession } from '@/store/composer-status' import { notify } from '@/store/notifications' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' +import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' @@ -73,6 +75,7 @@ import { } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' @@ -185,6 +188,13 @@ export function ChatBar({ const queuedPromptsBySession = useStore($queuedPromptsBySession) const statusItemsBySession = useStore($statusItemsBySession) const scrolledUp = useStore($threadScrolledUp) + // Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N + // tiny window, subagent watch windows) always start docked and can't pop out: + // a floating composer makes no sense in a single-session side window, and it + // would otherwise write the shared atom and yank the main window's composer out. + const popoutAllowed = !isSecondaryWindow() + const poppedOut = useStore($composerPoppedOut) && popoutAllowed + const popoutPosition = useStore($composerPopoutPosition) const activeQueueSessionKey = queueSessionKey || sessionId || null const queuedPrompts = useMemo( @@ -206,6 +216,32 @@ export function ChatBar({ const composerRef = useRef(null) const composerSurfaceRef = useRef(null) const editorRef = useRef(null) + + const handleComposerPopOut = useCallback(() => { + triggerHaptic('open') + setComposerPoppedOut(true) + }, []) + + const handleComposerDock = useCallback(() => { + triggerHaptic('success') + setComposerPoppedOut(false) + }, []) + + // Double-click the grab area toggles dock/float. Undocking restores the last + // position (the persisted atom is never cleared on dock). + const handleComposerToggle = useCallback(() => { + poppedOut ? handleComposerDock() : handleComposerPopOut() + }, [handleComposerDock, handleComposerPopOut, poppedOut]) + + const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } = + useComposerPopoutGestures({ + composerRef, + onDock: handleComposerDock, + onPopOut: handleComposerPopOut, + poppedOut, + position: popoutPosition + }) + const draftRef = useRef(draft) const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) @@ -428,6 +464,20 @@ export function ChatBar({ return } + // Floating composer is out of the thread's flow — it must not reserve any + // bottom clearance. Zero the measured vars so the thread reclaims the space. + // (Read globals here so the callback stays stable; mirror the popoutAllowed + // gate since secondary windows are forced docked.) + if ($composerPoppedOut.get() && !isSecondaryWindow()) { + const root = document.documentElement + lastBucketedHeightRef.current = 0 + lastBucketedSurfaceHeightRef.current = 0 + root.style.setProperty('--composer-measured-height', '0px') + root.style.setProperty('--composer-surface-measured-height', '0px') + + return + } + const { height, width } = composer.getBoundingClientRect() const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height const root = document.documentElement @@ -474,6 +524,14 @@ export function ChatBar({ useResizeObserver(syncComposerMetrics, 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 + // re-sync explicitly: docked republishes the measured height, floating zeroes + // it so the thread reclaims the bottom space. + useEffect(() => { + syncComposerMetrics() + }, [poppedOut, syncComposerMetrics]) + useEffect(() => { return () => { const root = document.documentElement @@ -1720,6 +1778,7 @@ export function ChatBar({ busyAction={busyAction} canSteer={canSteer} canSubmit={canSubmit} + compactModelPill={poppedOut} conversation={{ active: voiceConversationActive, level: conversation.level, @@ -1750,7 +1809,7 @@ export function ChatBar({ autoCapitalize="off" autoCorrect="off" className={cn( - 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', + 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', '**:data-ref-text:cursor-default', stacked && 'pl-3', @@ -1819,10 +1878,34 @@ export function ChatBar({ return ( <> + {dragging && poppedOut && ( +
+ )} { e.preventDefault() @@ -1840,6 +1924,16 @@ export function ChatBar({ submitDraft() }} ref={composerRef} + style={ + poppedOut + ? { + bottom: `${popoutPosition.bottom}px`, + right: `${popoutPosition.right}px`, + // A compact one-sentence width when floating. + ['--composer-popout-width' as string]: '19.5rem' + } + : undefined + } > {showHelpHint && } {trigger && !argStageEmpty && ( @@ -1876,11 +1970,27 @@ export function ChatBar({ } sessionId={statusSessionId} /> -
-
+ {!poppedOut && ( +
+ )} + {/* Drag region: covers the transparent grab margin around the surface. + The surface sits on top (z-4) so only the exposed ring receives this + element's hover/cursor — grab cursor + a diagonal hatch (/////) + appear when you hover the draggable margin, never over the input. + The hatch pattern + opacity ladder live in styles.css. */} + {popoutAllowed && ( +
+ )} +
-
{contextMenu}
+
{contextMenu}
{input}
{controls}
diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index f04b6e2302b1..8e28ac9699ad 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -29,7 +29,15 @@ const PILL = cn( * `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the * full picker when the gateway is closed and no live menu exists. */ -export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) { +export function ModelPill({ + compact = false, + disabled, + model +}: { + compact?: boolean + disabled: boolean + model: ChatBarState['model'] +}) { const copy = useI18n().t.shell.statusbar const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) @@ -40,7 +48,9 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB // The model resolves a beat after the gateway/session comes up. Rather than // flash a literal "No model", show a quiet loader (inherits the pill text // color at half opacity) until a model lands. - const label = ( + const label = compact ? ( + + ) : ( <> {currentModel.trim() ? ( {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} @@ -51,13 +61,22 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB ) + // Compact (floating composer): a snug square holding just the chevron — no pill + // padding, sized to match the other composer icon buttons. + const pillClass = compact + ? cn( + 'size-(--composer-control-size) shrink-0 justify-center gap-0 rounded-md p-0', + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' + ) + : PILL + const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel if (!model.modelMenuContent) { return ( diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts new file mode 100644 index 000000000000..d51ae46af0e1 --- /dev/null +++ b/apps/desktop/src/store/composer-popout.ts @@ -0,0 +1,69 @@ +import { atom } from 'nanostores' + +import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' + +const POPOUT_ENABLED_STORAGE_KEY = 'hermes.desktop.composerPopout.enabled' +const POPOUT_POSITION_STORAGE_KEY = 'hermes.desktop.composerPopout.position' + +/** Where the floating composer's bottom-right corner sits, measured as an inset + * from the viewport's bottom/right edges. Anchoring to the bottom-right keeps + * the box visually pinned to its default corner as the window resizes and as + * the box grows upward while typing (the corner stays put, height climbs). */ +export interface PopoutPosition { + bottom: number + right: number +} + +// Default pop-out placement: tucked into the bottom-right of the thread, clear +// of the window chrome. Matches the brief's "default to the right bottom". +const DEFAULT_POSITION: PopoutPosition = { bottom: 24, right: 24 } + +function readPosition(): PopoutPosition { + const raw = storedString(POPOUT_POSITION_STORAGE_KEY) + + if (!raw) { + return DEFAULT_POSITION + } + + try { + const parsed = JSON.parse(raw) as Partial + + if (typeof parsed.bottom === 'number' && typeof parsed.right === 'number') { + return { bottom: parsed.bottom, right: parsed.right } + } + } catch { + // Corrupt value — fall back to the default corner. + } + + return DEFAULT_POSITION +} + +export const $composerPoppedOut = atom(storedBoolean(POPOUT_ENABLED_STORAGE_KEY, false)) +export const $composerPopoutPosition = atom(readPosition()) + +export function setComposerPoppedOut(value: boolean) { + $composerPoppedOut.set(value) + persistBoolean(POPOUT_ENABLED_STORAGE_KEY, value) +} + +const clamp = (value: number, max: number) => Math.min(Math.max(0, value), Math.max(0, max)) + +// Clamp the corner inset so a viewport shrink (or a stale persisted value) can't +// strand the box fully off-screen. +const clampPosition = ({ bottom, right }: PopoutPosition): PopoutPosition => ({ + bottom: clamp(bottom, window.innerHeight - 60), + right: clamp(right, window.innerWidth - 80) +}) + +/** Move the box (state only). Used per-frame during a drag — no IO. Returns the + * clamped position so callers can keep their live ref in sync. */ +export function setComposerPopoutPosition(position: PopoutPosition, persist = false): PopoutPosition { + const next = clampPosition(position) + $composerPopoutPosition.set(next) + + if (persist) { + persistString(POPOUT_POSITION_STORAGE_KEY, JSON.stringify(next)) + } + + return next +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2aff7a21c777..6cfdbef61356 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -337,8 +337,8 @@ --file-tree-row-height: 1.375rem; --composer-width: 48.75rem; - --composer-control-size: 1.75rem; - --composer-control-primary-size: 1.875rem; + --composer-control-size: 1.5rem; + --composer-control-primary-size: 1.625rem; --composer-control-gap: 0.25rem; --composer-row-gap: 0.25rem; --composer-ring-strength: 1; @@ -1002,10 +1002,55 @@ canvas { } [data-slot='composer-root'] { - width: min(var(--composer-width), calc(100% - 2rem)); + /* +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. */ + width: calc(min(var(--composer-width), calc(100% - 2rem)) + 10px); + padding-inline: 5px; padding-bottom: var(--composer-shell-pad-block-end); } +/* 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] { + width: var(--composer-popout-width, 24rem); + max-width: calc(100vw - 1.5rem); + padding: 5px; +} + +/* Dock glow intensity scale — dimmer in light mode (the primary glow reads + much stronger over a light backdrop), full strength in dark mode. */ +:root { + --dock-glow-scale: 0.55; +} + +.dark { + --dock-glow-scale: 1; +} + +/* Drag-region hatch — a diagonal ///// pattern (Photoshop-style) that fades into + the transparent grab margin on hover (and stays while dragging) to signal the + composer is draggable. Inherits the root radius so it clips to the corners. */ +[data-slot='composer-drag-region'] { + /* Hatch frame radius (tuned by hand). */ + border-radius: 0.4rem; + opacity: 0; + transition: opacity 150ms ease; + background-image: repeating-linear-gradient( + -45deg, + color-mix(in srgb, var(--ui-text-tertiary) 38%, transparent) 0, + color-mix(in srgb, var(--ui-text-tertiary) 38%, transparent) 1px, + transparent 1px, + transparent 3.5px + ); +} + +[data-slot='composer-drag-region']:hover, +[data-slot='composer-drag-region'][data-dragging] { + opacity: 0.33; +} + [data-slot='composer-root'] > .pointer-events-none { background: linear-gradient( to bottom, @@ -1018,6 +1063,12 @@ canvas { border-color: var(--ui-stroke-secondary) !important; } +/* On focus we don't change the fill — just shift the border ~15% toward the + foreground, which darkens it in light mode and lightens it in dark mode. */ +[data-slot='composer-surface']:focus-within { + border-color: color-mix(in srgb, var(--ui-stroke-secondary) 85%, var(--dt-foreground)) !important; +} + [data-slot='composer-fade'] { min-height: 2.375rem; } @@ -1051,10 +1102,6 @@ canvas { --composer-fill: color-mix(in srgb, var(--dt-card) 48%, transparent); } -[data-slot='composer-root']:has([data-slot='composer-surface']:focus-within) { - --composer-fill: var(--ui-chat-bubble-background); -} - [data-slot='composer-root']:has([data-slot='composer-completion-drawer']) { --composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background)); } From f697c97e02f0b484d5efdad9ed702269cb1359ba Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 01:36:29 -0500 Subject: [PATCH 2/3] fix(desktop): keep floating composer radius consistent with docked --- apps/desktop/src/app/chat/composer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 93da3cedbd0e..dc3d46d2e6a0 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1990,7 +1990,7 @@ export function ChatBar({ onDoubleClick={handleComposerToggle} /> )} -
+
Date: Sat, 20 Jun 2026 02:10:38 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(desktop):=20composer=20popout=20polish?= =?UTF-8?q?=20=E2=80=94=20peel-off=20placement,=20panels,=20chip=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Peel-off undock drops the floating composer under the cursor (centered horizontally, preserving the vertical grab offset) instead of snapping to the docked corner. - Unify the / · @ · ? completion drawer and the attach (+) menu onto one shared glassy panel primitive (composerPanelCard): smallest theme font, hairline border, nous shadow; floats off the composer, inset from the left. - Directive chips: Backspace removes the chip + its auto-inserted trailing space atomically (no orphaned space), and a phantom trailing block left by contenteditable no longer falsely expands the composer to two rows. - Model picker: scroll area capped at max(150px, 30dvh); footer rows aligned (matching icons, dropped a redundant margin). - Composer focus shifts the border ~15% toward foreground (no fill change); input is cursor-text; trimmed control icon/button sizes. --- .../app/chat/composer/completion-drawer.tsx | 21 ++-- .../src/app/chat/composer/context-menu.tsx | 12 +- .../chat/composer/hooks/use-popout-drag.ts | 23 ++-- apps/desktop/src/app/chat/composer/index.tsx | 27 +++- .../src/app/chat/composer/model-pill.tsx | 9 +- .../src/app/chat/composer/rich-editor.ts | 119 +++++++++++++++--- .../src/app/chat/composer/trigger-popover.tsx | 4 +- .../src/app/shell/model-menu-panel.tsx | 5 +- .../src/components/chat/composer-dock.ts | 22 ++-- apps/desktop/src/store/composer-popout.ts | 4 + apps/desktop/src/styles.css | 4 - 11 files changed, 189 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index 021af0bda568..1f07c235bfdb 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -2,21 +2,20 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core' import { ComposerPrimitive } from '@assistant-ui/react' import type { ReactNode } from 'react' -import { composerFusedDockCard } from '@/components/chat/composer-dock' +import { composerPanelCard } from '@/components/chat/composer-dock' import { cn } from '@/lib/utils' -// Same docked chrome as the queue/status stack, but its own thing: a narrow, -// left-aligned card (not full width) that fuses to the composer's edge instead -// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative -// margin overlaps the seam so the composer's (now-transparent) edge border reads -// as shared. Fused (opaque) fill — the composer surface swaps to the same fill -// while a drawer is open, so the two paint as one panel. -const DRAWER_SHELL = - 'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground' +// A standalone glassy panel floating just off the composer edge, inset from the +// left. Skin is the shared composerPanelCard (also used by the attach menu). +const DRAWER_SHELL = cn( + 'absolute left-2 z-50 w-80 max-w-[calc(100%-1rem)] max-h-[min(22rem,calc(100vh-8rem))]', + 'overflow-y-auto overscroll-contain p-1 text-popover-foreground', + composerPanelCard +) -export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top')) +export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full mb-1') -export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom')) +export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full mt-1') export function ComposerCompletionDrawer({ adapter, diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 3866e2814b5c..5b22fca953ec 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' +import { composerPanelCard } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -57,8 +58,8 @@ export function ContextMenu({ - - + + {c.attachLabel} @@ -142,7 +143,12 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippe export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) { return ( - + // Override font size + highlight to match the / · @ completion rows exactly. + {children} 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 650089e5d96b..3333995e3c1d 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 @@ -8,7 +8,7 @@ import { } from 'react' import type { PopoutPosition } from '@/store/composer-popout' -import { setComposerPopoutPosition } from '@/store/composer-popout' +import { POPOUT_WIDTH_REM, setComposerPopoutPosition } from '@/store/composer-popout' // Floating surface long-press before it becomes draggable (the 5px platform drags // instantly; this only covers grabbing the composer body itself). @@ -67,13 +67,6 @@ function isFloatDragPlatform(target: EventTarget | null) { return gestureTargetOk(target) } -function positionFromRect(rect: DOMRect): PopoutPosition { - return { - bottom: window.innerHeight - rect.bottom, - right: window.innerWidth - rect.right - } -} - /** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the * release-to-dock test (which fires at proximity 1). */ function dockProximityOf(rect: DOMRect) { @@ -154,7 +147,19 @@ export function useComposerPopoutGestures({ return } - const next = positionFromRect(composer.getBoundingClientRect()) + // The docked composer is full-width; the floating one is compact. Center it + // horizontally on the cursor (the docked grab-X is meaningless at the new + // width), but preserve the vertical grab offset so the pointer keeps its + // spot (grab the top → stay at the top). + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 + const rect = composer.getBoundingClientRect() + const boxWidth = POPOUT_WIDTH_REM * rem + const grabY = Math.min(Math.max(0, state.startY - rect.top), rect.height) + const next: PopoutPosition = { + bottom: window.innerHeight - (clientY - grabY + rect.height), + right: window.innerWidth - clientX - boxWidth / 2 + } + onPopOutRef.current() beginFloatDrag(state, clientX, clientY, next) }, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index dc3d46d2e6a0..1427a21b01ac 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -40,7 +40,7 @@ import { isBrowsingHistory, resetBrowseState } from '@/store/composer-input-history' -import { $composerPopoutPosition, $composerPoppedOut, setComposerPoppedOut } from '@/store/composer-popout' +import { $composerPopoutPosition, $composerPoppedOut, POPOUT_WIDTH_REM, setComposerPoppedOut } from '@/store/composer-popout' import { $queuedPromptsBySession, enqueueQueuedPrompt, @@ -88,6 +88,7 @@ import { import { QueuePanel } from './queue-panel' import { composerPlainText, + deleteChipBeforeCaret, deleteSelectionInEditor, insertPlainTextAtCaret, normalizeComposerEditorDom, @@ -441,7 +442,10 @@ export function ChatBar({ return } - if (draft.includes('\n')) { + // Only a non-trailing newline forces an immediate expand. A trailing newline + // (or phantom \n from contenteditable junk) is left to the ResizeObserver, + // which expands only when the editor's real height actually grows. + if (draft.trimEnd().includes('\n')) { setExpanded(true) } }, [draft, expanded]) @@ -890,6 +894,22 @@ export function ChatBar({ return } + // Plain Backspace right after a directive chip: remove the chip + its + // auto-inserted trailing space as one unit, so deleting a directive never + // leaves an orphaned space. (Modified backspaces stay native.) + if ( + event.key === 'Backspace' && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + deleteChipBeforeCaret(event.currentTarget) + ) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + // Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large // drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through. if ( @@ -1930,7 +1950,7 @@ export function ChatBar({ bottom: `${popoutPosition.bottom}px`, right: `${popoutPosition.right}px`, // A compact one-sentence width when floating. - ['--composer-popout-width' as string]: '19.5rem' + ['--composer-popout-width' as string]: `${POPOUT_WIDTH_REM}rem` } : undefined } @@ -1995,7 +2015,6 @@ export function ChatBar({ className={cn( 'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]', COMPOSER_DROP_FADE_CLASS, - 'group-has-data-[state=open]/composer:border-t-transparent', dragActive && COMPOSER_DROP_ACTIVE_CLASS )} data-slot="composer-surface" diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 8e28ac9699ad..53a76db1b0f9 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -91,7 +91,14 @@ export function ModelPill({ return ( - diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index f74d2ee5bf74..2587202c96a2 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -172,6 +172,60 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { } } +/** Backspace at a collapsed caret immediately after a chip: delete the chip AND + * the single trailing space we auto-insert after it, atomically — so removing a + * directive never strands an orphaned space (the contenteditable-driven cleanup + * was unreliable). Returns whether it ran. */ +export function deleteChipBeforeCaret(editor: HTMLElement): boolean { + const hit = composerSelectionRange(editor) + + if (!hit || !hit.range.collapsed) { + return false + } + + const { startContainer, startOffset } = hit.range + let chip: ChildNode | null = null + + if (startContainer === editor) { + chip = startOffset > 0 ? editor.childNodes[startOffset - 1] : null + } else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) { + chip = startContainer.previousSibling + } + + if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) { + return false + } + + const after = chip.nextSibling + chip.remove() + + // Drop the auto-inserted trailing space; keep any real following text. + if (after?.nodeType === Node.TEXT_NODE) { + const text = after.textContent ?? '' + + if (text === ' ') { + after.remove() + } else if (text.startsWith(' ')) { + after.textContent = text.slice(1) + } + } + + const caret = document.createRange() + + if (after?.isConnected) { + caret.setStartBefore(after) + } else { + caret.selectNodeContents(editor) + caret.collapse(false) + } + + caret.collapse(true) + hit.selection.removeAllRanges() + hit.selection.addRange(caret) + + return true +} + /** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/ * line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */ export function deleteSelectionInEditor(editor: HTMLElement) { @@ -242,35 +296,68 @@ export function placeCaretEnd(element: HTMLElement) { selection?.addRange(range) } -/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */ -export function normalizeComposerEditorDom(editor: HTMLElement) { - if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { - editor.replaceChildren() +/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */ +function isBlankNode(node: ChildNode | null): boolean { + if (!node) { + return false + } + + if (node.nodeName === 'BR') { + return true + } + + if (node.nodeType === Node.TEXT_NODE) { + return !(node.textContent || '').trim() + } - return + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement + + return !el.dataset.refText && Array.from(el.childNodes).every(isBlankNode) + } + + return false +} + +/** Drop contenteditable junk that serializes as `\n` and falsely expands the + * composer. Editing around a contenteditable=false chip makes Chromium wrap the + * remainder in stray block
s / trailing
s — none of which our own + * rendering emits (we use text nodes +
+ chips). Real
line breaks + * (Shift+Enter, which sit after actual text) are preserved. */ +export function normalizeComposerEditorDom(editor: HTMLElement) { + // A trailing block wrapper holding only a break/whitespace is the phantom + // "new line" Chromium adds after a chip on backspace — drop it. + const tailBlock = editor.lastChild as HTMLElement | null + + if ( + tailBlock?.nodeType === Node.ELEMENT_NODE && + (tailBlock.tagName === 'DIV' || tailBlock.tagName === 'P') && + isBlankNode(tailBlock) + ) { + editor.removeChild(tailBlock) } + // Unwrap a lone block wrapper back to inline content. if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) { const wrapper = editor.firstChild as HTMLElement - if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) { + if ((wrapper.tagName === 'DIV' || wrapper.tagName === 'P') && wrapper.dataset.slot !== RICH_INPUT_SLOT) { editor.replaceChildren(...Array.from(wrapper.childNodes)) } } + // A trailing
right after a chip / only whitespace is a phantom line. const last = editor.lastChild - if (last?.nodeName !== 'BR') { - return - } - - let prev: ChildNode | null = last.previousSibling + if (last?.nodeName === 'BR') { + let prev: ChildNode | null = last.previousSibling - while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) { - prev = prev.previousSibling - } + while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) { + prev = prev.previousSibling + } - if ((prev as HTMLElement | null)?.dataset.refText) { - editor.removeChild(last) + if (!prev || (prev as HTMLElement).dataset?.refText) { + editor.removeChild(last) + } } } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index 6f08a7e03471..da52f1dd088c 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -137,7 +137,7 @@ export function ComposerTriggerPopover({ floating tooltip. */} @@ -146,7 +146,7 @@ export function ComposerTriggerPopover({ {description && ( diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 577d98f14957..6f785e8fabfa 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -207,7 +207,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model {copy.noModels} ) : ( -
+
{groups.map(group => ( {group.provider.name} @@ -310,7 +310,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model void refreshModels() }} > - + {copy.refreshModels} @@ -318,6 +318,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')} onSelect={() => setModelVisibilityOpen(true)} > + {copy.editModels} diff --git a/apps/desktop/src/components/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts index 8eb2b24e7eef..ca02cdea8d6d 100644 --- a/apps/desktop/src/components/chat/composer-dock.ts +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -1,12 +1,9 @@ import { cn } from '@/lib/utils' /** - * The composer surface and everything docked to it (slash·@ popover, `?` help) - * paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled / - * focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`, - * so the two layers can never disagree — drawer-open forces an opaque fill via - * `:has()`, because translucent glass sampling different backdrops (thread vs - * fade gradient) renders as different colors even with identical tints. + * The composer surface and the status/queue stack paint ONE shared + * `--composer-fill` var. The state ladder (rest / scrolled) lives in styles.css + * on `[data-slot='composer-root']`, so the layers can never disagree. */ export const composerFill = 'bg-(--composer-fill)' @@ -26,6 +23,13 @@ const composerDockEdge = (edge: 'bottom' | 'top') => export const composerDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill, composerSurfaceGlass) -/** Fused docked card — completion drawers. Shares `--composer-fill` with the - * composer surface, which goes opaque while a drawer is open. */ -export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill) +/** Floating composer panel skin — the `/`·`@`·`?` completion drawer and the + * attach (`+`) menu. Glassy translucent card, hairline border, full radius, + * smallest type, soft nous shadow. Uses an explicit fill (not `--composer-fill`) + * so it renders identically whether mounted inside the composer or portaled out + * of it. Visual skin only — consumers add their own size/position/padding. */ +export const composerPanelCard = cn( + 'rounded-2xl border border-border/65 shadow-nous text-[length:var(--conversation-tool-font-size)]', + 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', + composerSurfaceGlass +) diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index d51ae46af0e1..9327cdce55b2 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -14,6 +14,10 @@ export interface PopoutPosition { right: number } +// Floating composer width (rem). Shared by the inline style that sets +// --composer-popout-width and the peel-off drag math (to center it on the cursor). +export const POPOUT_WIDTH_REM = 19.5 + // Default pop-out placement: tucked into the bottom-right of the thread, clear // of the window chrome. Matches the brief's "default to the right bottom". const DEFAULT_POSITION: PopoutPosition = { bottom: 24, right: 24 } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 6cfdbef61356..36ef859ce127 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1102,10 +1102,6 @@ canvas { --composer-fill: color-mix(in srgb, var(--dt-card) 48%, transparent); } -[data-slot='composer-root']:has([data-slot='composer-completion-drawer']) { - --composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background)); -} - /* Tool/thinking blocks now live at message-text alignment (no leading chevron column to escape into), so their headers and bodies share a common left edge with the model's text. */