mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
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:
parent
c253b07380
commit
236f0597e5
7 changed files with 599 additions and 29 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
323
apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
Normal file
323
apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
69
apps/desktop/src/store/composer-popout.ts
Normal file
69
apps/desktop/src/store/composer-popout.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue