-
+
{/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
@@ -97,7 +99,7 @@ export function ComposerControls({
type="button"
variant="ghost"
>
-
+
) : (
@@ -116,7 +118,7 @@ export function ComposerControls({
size="icon"
type="button"
>
-
+
) : (
@@ -129,12 +131,12 @@ export function ComposerControls({
>
{busy ? (
busyAction === 'queue' ? (
-
+
) : (
-
+
)
) : (
-
+
)}
@@ -293,11 +295,11 @@ function DictationButton({
variant="ghost"
>
{status === 'recording' ? (
-
+
) : status === 'transcribing' ? (
-
+
) : (
-
+
)}
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
new file mode 100644
index 00000000000..650089e5d96
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
@@ -0,0 +1,323 @@
+import {
+ type PointerEvent as ReactPointerEvent,
+ type RefObject,
+ useCallback,
+ useEffect,
+ useRef,
+ useState
+} from 'react'
+
+import type { PopoutPosition } from '@/store/composer-popout'
+import { setComposerPopoutPosition } from '@/store/composer-popout'
+
+// Floating surface long-press before it becomes draggable (the 5px platform drags
+// instantly; this only covers grabbing the composer body itself).
+const LONG_PRESS_MS = 360
+const LONG_PRESS_MOVE_TOLERANCE = 10
+// Upward drag distance from the docked composer that peels it off into a float.
+const PEEL_OUT_PX = 16
+const DOCK_ZONE_BOTTOM_PX = 72
+// How close the composer's center must be to the viewport center (px) to count as
+// "over the dock". Kept tight so the bottom-left/right corners stay free.
+const DOCK_ZONE_CENTER_TOLERANCE_PX = 150
+// Falloff distances over which dock proximity ramps from 1 (in-zone) down to 0.
+const DOCK_VERTICAL_FALLOFF_PX = 260
+const DOCK_HORIZONTAL_FALLOFF_PX = 220
+
+interface PressState {
+ armed: boolean
+ mode: 'dock' | 'float'
+ pointerId: number
+ startBottom: number
+ startRight: number
+ startX: number
+ startY: number
+}
+
+interface ComposerPopoutGesturesOptions {
+ composerRef: RefObject
+ onDock: () => void
+ onPopOut: () => void
+ poppedOut: boolean
+ position: PopoutPosition
+}
+
+function gestureTargetOk(target: EventTarget | null) {
+ if (!(target instanceof Element)) {
+ return false
+ }
+
+ return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
+}
+
+/** Floating composer's 5px outer frame — grab here to drag without long-press. */
+function isFloatDragPlatform(target: EventTarget | null) {
+ if (!(target instanceof Element)) {
+ return false
+ }
+
+ if (!target.closest('[data-slot="composer-root"][data-popped-out]')) {
+ return false
+ }
+
+ if (target.closest('[data-slot="composer-surface"], [data-slot="composer-rich-input"]')) {
+ return false
+ }
+
+ return gestureTargetOk(target)
+}
+
+function positionFromRect(rect: DOMRect): PopoutPosition {
+ return {
+ bottom: window.innerHeight - rect.bottom,
+ right: window.innerWidth - rect.right
+ }
+}
+
+/** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the
+ * release-to-dock test (which fires at proximity 1). */
+function dockProximityOf(rect: DOMRect) {
+ const horizontalDist = Math.abs(rect.left + rect.width / 2 - window.innerWidth / 2)
+ const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
+
+ const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
+ const h =
+ horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
+ ? 1
+ : Math.max(0, 1 - (horizontalDist - DOCK_ZONE_CENTER_TOLERANCE_PX) / DOCK_HORIZONTAL_FALLOFF_PX)
+
+ return v * h
+}
+
+/**
+ * Gesture pop-out / dock for the composer — fully gestural, no hold-to-toggle.
+ *
+ * Docked: drag the composer upward (off the dock) to peel it out into a float,
+ * then keep dragging in the same motion.
+ * Floating: drag the 5px frame to move instantly, or long-press the body then
+ * drag; release over the bottom-center dock band to snap back in.
+ */
+export function useComposerPopoutGestures({
+ composerRef,
+ onDock,
+ onPopOut,
+ poppedOut,
+ position
+}: ComposerPopoutGesturesOptions) {
+ const [dragging, setDragging] = useState(false)
+ const [dockProximity, setDockProximity] = useState(0)
+
+ const stateRef = useRef(null)
+ const timerRef = useRef(null)
+ const liveRef = useRef(position)
+ liveRef.current = position
+
+ const onPopOutRef = useRef(onPopOut)
+ onPopOutRef.current = onPopOut
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== null) {
+ window.clearTimeout(timerRef.current)
+ timerRef.current = null
+ }
+ }, [])
+
+ const resetGesture = useCallback(() => {
+ clearTimer()
+ stateRef.current = null
+ setDragging(false)
+ setDockProximity(0)
+ }, [clearTimer])
+
+ const beginFloatDrag = useCallback(
+ (state: PressState, clientX: number, clientY: number, next: PopoutPosition) => {
+ clearTimer()
+ liveRef.current = setComposerPopoutPosition(next)
+
+ state.mode = 'float'
+ state.armed = true
+ state.startBottom = next.bottom
+ state.startRight = next.right
+ state.startX = clientX
+ state.startY = clientY
+
+ setDragging(true)
+ },
+ [clearTimer]
+ )
+
+ const peelOffFromDock = useCallback(
+ (state: PressState, clientX: number, clientY: number) => {
+ const composer = composerRef.current
+
+ if (!composer) {
+ return
+ }
+
+ const next = positionFromRect(composer.getBoundingClientRect())
+ onPopOutRef.current()
+ beginFloatDrag(state, clientX, clientY, next)
+ },
+ [beginFloatDrag, composerRef]
+ )
+
+ const onPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (event.button !== 0 || !gestureTargetOk(event.target)) {
+ return
+ }
+
+ // Floating: grabbing the 5px platform drags immediately.
+ if (poppedOut && isFloatDragPlatform(event.target)) {
+ stateRef.current = {
+ armed: true,
+ mode: 'float',
+ pointerId: event.pointerId,
+ startBottom: liveRef.current.bottom,
+ startRight: liveRef.current.right,
+ startX: event.clientX,
+ startY: event.clientY
+ }
+ setDragging(true)
+
+ return
+ }
+
+ stateRef.current = {
+ armed: false,
+ mode: poppedOut ? 'float' : 'dock',
+ pointerId: event.pointerId,
+ startBottom: liveRef.current.bottom,
+ startRight: liveRef.current.right,
+ startX: event.clientX,
+ startY: event.clientY
+ }
+
+ clearTimer()
+
+ // Docked has NO timer — pop-out is purely the upward peel gesture (handled
+ // in pointermove). Floating arms a long-press to drag the body.
+ if (poppedOut) {
+ timerRef.current = window.setTimeout(() => {
+ const state = stateRef.current
+
+ if (!state || state.armed) {
+ return
+ }
+
+ state.armed = true
+ setDragging(true)
+ }, LONG_PRESS_MS)
+ }
+ },
+ [clearTimer, poppedOut]
+ )
+
+ useEffect(() => {
+ // Coalesce drag updates to one per frame — pointermove can fire several times
+ // between paints on high-Hz mice, and each update re-renders + clamps.
+ let raf: number | null = null
+ let pending: { x: number; y: number } | null = null
+
+ const cancelRaf = () => {
+ if (raf !== null) {
+ cancelAnimationFrame(raf)
+ raf = null
+ }
+ }
+
+ const flush = () => {
+ raf = null
+ const state = stateRef.current
+
+ if (!state?.armed || state.mode !== 'float' || !pending) {
+ return
+ }
+
+ liveRef.current = setComposerPopoutPosition({
+ bottom: state.startBottom - (pending.y - state.startY),
+ right: state.startRight - (pending.x - state.startX)
+ })
+
+ const rect = composerRef.current?.getBoundingClientRect()
+
+ if (rect) {
+ setDockProximity(dockProximityOf(rect))
+ }
+ }
+
+ const handleMove = (event: PointerEvent) => {
+ const state = stateRef.current
+
+ if (!state || event.pointerId !== state.pointerId) {
+ return
+ }
+
+ // Pre-arm: cheap threshold checks run inline (no per-frame work yet).
+ if (!state.armed) {
+ const deltaX = event.clientX - state.startX
+ const deltaY = event.clientY - state.startY
+
+ if (state.mode === 'dock') {
+ // Peel off only on a clear upward drag — not a sideways/down wiggle.
+ if (-deltaY > PEEL_OUT_PX && -deltaY > Math.abs(deltaX)) {
+ peelOffFromDock(state, event.clientX, event.clientY)
+ } else if (Math.abs(deltaX) > PEEL_OUT_PX || deltaY > LONG_PRESS_MOVE_TOLERANCE) {
+ resetGesture()
+ }
+ } else if (Math.abs(deltaX) > LONG_PRESS_MOVE_TOLERANCE || Math.abs(deltaY) > LONG_PRESS_MOVE_TOLERANCE) {
+ // Float body long-press pending: movement cancels the hold.
+ resetGesture()
+ }
+
+ return
+ }
+
+ if (state.mode !== 'float') {
+ return
+ }
+
+ event.preventDefault()
+ pending = { x: event.clientX, y: event.clientY }
+ raf ??= requestAnimationFrame(flush)
+ }
+
+ const handleUp = (event: PointerEvent) => {
+ const state = stateRef.current
+
+ if (!state || event.pointerId !== state.pointerId) {
+ return
+ }
+
+ cancelRaf()
+
+ if (state.armed && state.mode === 'float') {
+ const rect = composerRef.current?.getBoundingClientRect()
+
+ if (rect && dockProximityOf(rect) >= 1) {
+ onDock()
+ } else {
+ // Persist the resting position once, on release — never per move.
+ setComposerPopoutPosition(liveRef.current, true)
+ }
+ }
+
+ resetGesture()
+ }
+
+ window.addEventListener('pointermove', handleMove)
+ window.addEventListener('pointerup', handleUp)
+ window.addEventListener('pointercancel', handleUp)
+
+ return () => {
+ cancelRaf()
+ window.removeEventListener('pointermove', handleMove)
+ window.removeEventListener('pointerup', handleUp)
+ window.removeEventListener('pointercancel', handleUp)
+ }
+ }, [composerRef, onDock, peelOffFromDock, resetGesture])
+
+ useEffect(() => clearTimer, [clearTimer])
+
+ return { dockProximity, dragging, onPointerDown }
+}
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index dc3f0a490cb..93da3cedbd0 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -40,6 +40,7 @@ import {
isBrowsingHistory,
resetBrowseState
} from '@/store/composer-input-history'
+import { $composerPopoutPosition, $composerPoppedOut, setComposerPoppedOut } from '@/store/composer-popout'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@@ -55,6 +56,7 @@ import { $statusItemsBySession } from '@/store/composer-status'
import { notify } from '@/store/notifications'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
+import { isSecondaryWindow } from '@/store/windows'
import { useTheme } from '@/themes'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
@@ -73,6 +75,7 @@ import {
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
+import { useComposerPopoutGestures } from './hooks/use-popout-drag'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
@@ -185,6 +188,13 @@ export function ChatBar({
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
const scrolledUp = useStore($threadScrolledUp)
+ // Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
+ // tiny window, subagent watch windows) always start docked and can't pop out:
+ // a floating composer makes no sense in a single-session side window, and it
+ // would otherwise write the shared atom and yank the main window's composer out.
+ const popoutAllowed = !isSecondaryWindow()
+ const poppedOut = useStore($composerPoppedOut) && popoutAllowed
+ const popoutPosition = useStore($composerPopoutPosition)
const activeQueueSessionKey = queueSessionKey || sessionId || null
const queuedPrompts = useMemo(
@@ -206,6 +216,32 @@ export function ChatBar({
const composerRef = useRef(null)
const composerSurfaceRef = useRef(null)
const editorRef = useRef(null)
+
+ const handleComposerPopOut = useCallback(() => {
+ triggerHaptic('open')
+ setComposerPoppedOut(true)
+ }, [])
+
+ const handleComposerDock = useCallback(() => {
+ triggerHaptic('success')
+ setComposerPoppedOut(false)
+ }, [])
+
+ // Double-click the grab area toggles dock/float. Undocking restores the last
+ // position (the persisted atom is never cleared on dock).
+ const handleComposerToggle = useCallback(() => {
+ poppedOut ? handleComposerDock() : handleComposerPopOut()
+ }, [handleComposerDock, handleComposerPopOut, poppedOut])
+
+ const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } =
+ useComposerPopoutGestures({
+ composerRef,
+ onDock: handleComposerDock,
+ onPopOut: handleComposerPopOut,
+ poppedOut,
+ position: popoutPosition
+ })
+
const draftRef = useRef(draft)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
@@ -428,6 +464,20 @@ export function ChatBar({
return
}
+ // Floating composer is out of the thread's flow — it must not reserve any
+ // bottom clearance. Zero the measured vars so the thread reclaims the space.
+ // (Read globals here so the callback stays stable; mirror the popoutAllowed
+ // gate since secondary windows are forced docked.)
+ if ($composerPoppedOut.get() && !isSecondaryWindow()) {
+ const root = document.documentElement
+ lastBucketedHeightRef.current = 0
+ lastBucketedSurfaceHeightRef.current = 0
+ root.style.setProperty('--composer-measured-height', '0px')
+ root.style.setProperty('--composer-surface-measured-height', '0px')
+
+ return
+ }
+
const { height, width } = composer.getBoundingClientRect()
const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height
const root = document.documentElement
@@ -474,6 +524,14 @@ export function ChatBar({
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
+ // Toggling pop-out changes whether the composer reserves thread clearance.
+ // The ResizeObserver may not fire (the box can keep the same box size), so
+ // re-sync explicitly: docked republishes the measured height, floating zeroes
+ // it so the thread reclaims the bottom space.
+ useEffect(() => {
+ syncComposerMetrics()
+ }, [poppedOut, syncComposerMetrics])
+
useEffect(() => {
return () => {
const root = document.documentElement
@@ -1720,6 +1778,7 @@ export function ChatBar({
busyAction={busyAction}
canSteer={canSteer}
canSubmit={canSubmit}
+ compactModelPill={poppedOut}
conversation={{
active: voiceConversationActive,
level: conversation.level,
@@ -1750,7 +1809,7 @@ export function ChatBar({
autoCapitalize="off"
autoCorrect="off"
className={cn(
- 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
+ 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
@@ -1819,10 +1878,34 @@ export function ChatBar({
return (
<>
+ {dragging && poppedOut && (
+
+ )}
{
e.preventDefault()
@@ -1840,6 +1924,16 @@ export function ChatBar({
submitDraft()
}}
ref={composerRef}
+ style={
+ poppedOut
+ ? {
+ bottom: `${popoutPosition.bottom}px`,
+ right: `${popoutPosition.right}px`,
+ // A compact one-sentence width when floating.
+ ['--composer-popout-width' as string]: '19.5rem'
+ }
+ : undefined
+ }
>
{showHelpHint && }
{trigger && !argStageEmpty && (
@@ -1876,11 +1970,27 @@ export function ChatBar({
}
sessionId={statusSessionId}
/>
-
-
+ {!poppedOut && (
+
+ )}
+ {/* Drag region: covers the transparent grab margin around the surface.
+ The surface sits on top (z-4) so only the exposed ring receives this
+ element's hover/cursor — grab cursor + a diagonal hatch (/////)
+ appear when you hover the draggable margin, never over the input.
+ The hatch pattern + opacity ladder live in styles.css. */}
+ {popoutAllowed && (
+
+ )}
+
-
{contextMenu}
+
{contextMenu}
{input}
{controls}
diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx
index f04b6e2302b..8e28ac9699a 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.tsx
@@ -29,7 +29,15 @@ const PILL = cn(
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*/
-export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
+export function ModelPill({
+ compact = false,
+ disabled,
+ model
+}: {
+ compact?: boolean
+ disabled: boolean
+ model: ChatBarState['model']
+}) {
const copy = useI18n().t.shell.statusbar
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
@@ -40,7 +48,9 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
- const label = (
+ const label = compact ? (
+
+ ) : (
<>
{currentModel.trim() ? (
{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}
@@ -51,13 +61,22 @@ export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatB
>
)
+ // Compact (floating composer): a snug square holding just the chevron — no pill
+ // padding, sized to match the other composer icon buttons.
+ const pillClass = compact
+ ? cn(
+ 'size-(--composer-control-size) shrink-0 justify-center gap-0 rounded-md p-0',
+ 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
+ )
+ : PILL
+
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (