diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 22c10985f82..3866e2814b5 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 6d748c73b5f..7bef1e82767 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 00000000000..650089e5d96 --- /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 dc3f0a490cb..93da3cedbd0 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 f04b6e2302b..8e28ac9699a 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 00000000000..d51ae46af0e --- /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 2aff7a21c77..6cfdbef6135 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)); }