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.
This commit is contained in:
Brooklyn Nicholson 2026-06-20 01:35:30 -05:00
parent c253b07380
commit 236f0597e5
7 changed files with 599 additions and 29 deletions

View file

@ -54,7 +54,7 @@ export function ContextMenu({
type="button"
variant="ghost"
>
<Codicon name="add" size="1rem" />
<Codicon name="add" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>

View file

@ -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 (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<ModelPill disabled={disabled} model={state.model} />
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{/* 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"
>
<SteeringWheel size={16} />
<SteeringWheel size={14} />
</Button>
</Tip>
) : (
@ -116,7 +118,7 @@ export function ComposerControls({
size="icon"
type="button"
>
<AudioLines size={17} />
<AudioLines size={15} />
</Button>
</Tip>
) : (
@ -129,12 +131,12 @@ export function ComposerControls({
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
<Layers3 size={14} />
) : (
<span className="block size-3 rounded-[0.1875rem] bg-current" />
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
)
) : (
<Codicon name="arrow-up" size="1rem" />
<Codicon name="arrow-up" size="0.875rem" />
)}
</Button>
</Tip>
@ -293,11 +295,11 @@ function DictationButton({
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
<Square className="fill-current" size={11} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
<Loader2 className="animate-spin" size={14} />
) : (
<Codicon name="mic" size="1rem" />
<Codicon name="mic" size="0.875rem" />
)}
</Button>
</Tip>

View file

@ -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<HTMLFormElement | null>
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<PressState | null>(null)
const timerRef = useRef<number | null>(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<HTMLElement>) => {
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 }
}

View file

@ -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<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(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 && (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-32"
style={{
// A bottom-centered radial glow — soft on every side by construction,
// so it reads as the dock target without any hard band edges. Its
// intensity tracks how close the composer is to the dock (1 = peak).
background:
'radial-gradient(64% 130% at 50% 100%, color-mix(in srgb, var(--color-primary) 26%, transparent) 0%, transparent 70%)',
// Scaled by --dock-glow-scale (lower in light mode — see styles.css).
opacity: `calc(${0.1 + dockProximity * 0.57} * var(--dock-glow-scale, 1))`
}}
/>
)}
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
className={cn(
'group/composer z-30 overflow-visible rounded-2xl',
poppedOut
? // Floating: the composer (with its own border) floats with an even
// 5px transparent grab margin around it — drag that to move it.
'fixed w-[var(--composer-popout-width)] max-w-[calc(100vw-1.5rem)] bg-transparent p-[5px]'
: 'absolute bottom-0 left-1/2 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]',
dragging && 'cursor-grabbing select-none touch-none'
)}
data-drag-active={dragActive ? '' : undefined}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-thread-scrolled-up={scrolledUp ? '' : undefined}
@ -1830,6 +1913,7 @@ export function ChatBar({
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
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 && <HelpHint />}
{trigger && !argStageEmpty && (
@ -1876,11 +1970,27 @@ export function ChatBar({
}
sessionId={statusSessionId}
/>
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
<div className="relative w-full rounded-[inherit]">
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
)}
{/* 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 && (
<div
aria-hidden
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={handleComposerToggle}
/>
)}
<div className={cn('relative w-full', poppedOut ? 'rounded-[11px]' : 'rounded-[inherit]')}>
<div
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)]',
@ -1941,7 +2051,7 @@ export function ChatBar({
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>
<div className="flex translate-y-[3px] items-start self-start [grid-area:menu]">{contextMenu}</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end [grid-area:controls]">{controls}</div>
</div>

View file

@ -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 ? (
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
) : (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
@ -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 (
<Button
aria-label={copy.openModelPicker}
className={PILL}
className={pillClass}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
@ -72,7 +91,7 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
<Button aria-label={title} className={pillClass} disabled={disabled} title={title} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>

View file

@ -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<PopoutPosition>
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<PopoutPosition>(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
}

View file

@ -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));
}