feat(desktop): pop the composer out into a draggable floating window (#49488)
* 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. * fix(desktop): keep floating composer radius consistent with docked * fix(desktop): composer popout polish — peel-off placement, panels, chip editing - 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.
This commit is contained in:
commit
15852722d4
12 changed files with 776 additions and 78 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -54,11 +55,11 @@ 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}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
{c.attachLabel}
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
|
|
@ -142,7 +143,12 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippe
|
|||
|
||||
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
// Override font size + highlight to match the / · @ completion rows exactly.
|
||||
<DropdownMenuItem
|
||||
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Icon />
|
||||
<span>{children}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
328
apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
Normal file
328
apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import type { PopoutPosition } 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).
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
},
|
||||
[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, POPOUT_WIDTH_REM, 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'
|
||||
|
|
@ -85,6 +88,7 @@ import {
|
|||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
|
|
@ -185,6 +189,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 +217,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)
|
||||
|
|
@ -405,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])
|
||||
|
|
@ -428,6 +468,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 +528,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
|
||||
|
|
@ -832,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 (
|
||||
|
|
@ -1720,6 +1798,7 @@ export function ChatBar({
|
|||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
level: conversation.level,
|
||||
|
|
@ -1750,7 +1829,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 +1898,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 +1933,7 @@ export function ChatBar({
|
|||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
|
||||
|
|
@ -1840,6 +1944,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]: `${POPOUT_WIDTH_REM}rem`
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
|
|
@ -1876,16 +1990,31 @@ export function ChatBar({
|
|||
}
|
||||
sessionId={statusSessionId}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
/>
|
||||
{!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="relative w-full 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)]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
|
|
@ -1941,7 +2070,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,14 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
return
|
||||
/** 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()
|
||||
}
|
||||
|
||||
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 <div>s / trailing <br>s — none of which our own
|
||||
* rendering emits (we use text nodes + <br> + chips). Real <br> 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 <br> right after a chip / only whitespace is a phantom line.
|
||||
const last = editor.lastChild
|
||||
|
||||
if (last?.nodeName !== 'BR') {
|
||||
return
|
||||
}
|
||||
if (last?.nodeName === 'BR') {
|
||||
let prev: ChildNode | null = last.previousSibling
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function ComposerTriggerPopover({
|
|||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||
'font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
|
|
@ -146,7 +146,7 @@ export function ComposerTriggerPopover({
|
|||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||
'leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
{copy.noModels}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto py-0.5">
|
||||
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5">
|
||||
{groups.map(group => (
|
||||
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
|
||||
|
|
@ -310,7 +310,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
void refreshModels()
|
||||
}}
|
||||
>
|
||||
<Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" />
|
||||
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
|
||||
{copy.refreshModels}
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
|
@ -318,6 +318,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
<Codicon name="settings-gear" size="0.75rem" />
|
||||
{copy.editModels}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
73
apps/desktop/src/store/composer-popout.ts
Normal file
73
apps/desktop/src/store/composer-popout.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
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,14 +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));
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue