Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'

interface UseComposerMetricsArgs {
composerDockRef: RefObject<HTMLDivElement | null>
composerRef: RefObject<HTMLFormElement | null>
composerSurfaceRef: RefObject<HTMLDivElement | null>
editorRef: RefObject<HTMLDivElement | null>
Expand All @@ -28,7 +29,13 @@ interface UseComposerMetricsArgs {
* tree's computed style, and `tight` only flips when it crosses the breakpoint.
* Returns `stacked` (the only value the render needs).
*/
export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): {
export function useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
}: UseComposerMetricsArgs): {
compactPill: boolean
stacked: boolean
} {
Expand Down Expand Up @@ -89,8 +96,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,

const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
// The dock is the full docked footprint — strips, status stack, composer —
// so it, not the composer alone, is what the thread has to clear.
const dock = composerDockRef.current

if (!composer) {
if (!composer || !dock) {
return
}

Expand All @@ -108,7 +118,8 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
return
}

const { height, width } = composer.getBoundingClientRect()
const { height } = dock.getBoundingClientRect()
const { width } = composer.getBoundingClientRect()
const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height

if (width > 0) {
Expand Down Expand Up @@ -156,9 +167,9 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`)
}
}
}, [composerRef, composerSurfaceRef, editorRef])
}, [composerDockRef, composerRef, composerSurfaceRef, editorRef])

useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
useResizeObserver(syncComposerMetrics, composerDockRef, 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
Expand Down
10 changes: 1 addition & 9 deletions apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,7 @@ function gestureTargetOk(target: EventTarget | null) {
return false
}

// `composer-no-drag`: chrome that lives inside the composer root but isn't
// part of the draggable frame — the floating pill strips. The pills are
// `button`s and already excluded, but the strip's own box (the gaps between
// pills) isn't, so without this a press landing between two badges still
// drags. The strips are `w-fit`, so this costs the grab band only the width
// of the badges themselves.
return !target.closest(
'button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper], [data-slot="composer-no-drag"]'
)
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. */
Expand Down
144 changes: 85 additions & 59 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { useComposerVoice } from './hooks/use-composer-voice'
import { useComposerMicroActions } from './hooks/use-micro-actions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useSessionStatusPresence } from './hooks/use-status-presence'
import { ActionBadges } from './micro-actions'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
Expand Down Expand Up @@ -161,6 +162,9 @@ export function ChatBar({
useComposerMicroActions(statusSessionId, busy)

const composerRef = useRef<HTMLFormElement | null>(null)
// The dock wraps the strips + status stack + composer; the thread's bottom
// clearance measures this, while the pop-out drag still tracks the composer.
const composerDockRef = useRef<HTMLDivElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)

// Pop-out engine: docked↔floating state, dock/float/toggle, drag gestures, and
Expand Down Expand Up @@ -277,7 +281,14 @@ export function ChatBar({
return onCancel()
}, [activeQueueSessionKeyRef, onCancel])

const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const { compactPill, stacked } = useComposerMetrics({
composerDockRef,
composerRef,
composerSurfaceRef,
editorRef,
poppedOut
})

const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload

Expand Down Expand Up @@ -1018,36 +1029,29 @@ export function ChatBar({
/>
)}
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
{/* Dock column: owns the composer's POSITION and stacks, bottom-up,
[micro actions] · [status stack] · [composer] · [underside].
Anchored at the bottom, so in-flow children grow upward and still
overlay the thread — no absolute lane needed.

The strips are siblings of the composer, not children: the pop-out
drag region is `absolute inset-0` INSIDE the composer, so anything
rendered in there is inside the grab area by construction. Keeping
them out here is what makes that impossible rather than excluded. */}
<div
className={cn(
'group/composer z-30 overflow-visible rounded-2xl',
'z-30 flex flex-col',
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'
? 'fixed max-w-[calc(100vw-1.5rem)]'
: 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
)}
data-drag-active={dragActive ? '' : undefined}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-root"
data-status-stack={statusStackVisible ? '' : undefined}
data-slot="composer-dock"
data-thread-scrolled-up={scrolledUp ? '' : undefined}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
e.preventDefault()

if (composingRef.current) {
return
}

submitDraft()
}}
ref={composerRef}
// Measured for the thread's bottom clearance: the dock is the box
// that contains the strips, the status stack, AND the composer, so
// one measurement covers everything the thread must clear.
ref={composerDockRef}
style={
poppedOut
? {
Expand All @@ -1059,22 +1063,16 @@ export function ChatBar({
: undefined
}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{/* Aligned to the composer SURFACE, which sits inside the composer's
5px transparent grab margin — so both strips carry the same inset
and share one left edge with it. */}
<div className={cn(composerFloatingStrip, 'px-[5px] pb-1.5 empty:hidden')}>
<ActionBadges sessionId={statusSessionId} />
</div>
{/* Session-scoped status stack (todos, subagents, background tasks,
queue). Out of flow so it never inflates the composer's measured
height; it overlays the chat instead of pushing it, and publishes
its own --status-stack-measured-height so the thread's clearance
accounts for it. Collapses to nothing when every status is empty. */}
queue). An in-flow dock child: the dock is bottom-anchored, so it
grows upward over the thread and the dock's own measurement covers
it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
Expand Down Expand Up @@ -1104,6 +1102,44 @@ export function ChatBar({
}
sessionId={statusSessionId}
/>
<ComposerPrimitive.Root
className={cn(
'group/composer relative w-full overflow-visible rounded-2xl',
poppedOut && 'bg-transparent',
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}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDown={popoutAllowed ? onComposerGesturePointerDown : undefined}
onSubmit={e => {
e.preventDefault()

if (composingRef.current) {
return
}

submitDraft()
}}
ref={composerRef}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
Expand All @@ -1121,14 +1157,7 @@ export function ChatBar({
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={event => {
// The pill strips paint above this region; a double-click that
// lands on one must not float the composer. onPointerDown goes
// through gestureTargetOk, but this handler doesn't.
if (!(event.target as Element).closest('[data-slot="composer-no-drag"]')) {
handleComposerToggle()
}
}}
onDoubleClick={handleComposerToggle}
/>
)}
<div className="relative w-full rounded-[inherit]">
Expand Down Expand Up @@ -1220,18 +1249,15 @@ export function ChatBar({
</div>
</div>
</div>
{/* Underside: a floating strip BELOW the whole composer surface.
Chrome-free by design — contributions bring their own pill/skin,
like the micro-action strip above. In flow (the root is
bottom-anchored, so this grows the composer upward and stays on
screen) but OUTSIDE the surface, so it escapes the surface's
clipping, border, and scroll fade. Shares the micro-action
strip's grid so the two bracket the composer on one vertical
line. Renders nothing until something contributes. */}
<div className={cn(composerFloatingStrip, 'pt-1.5 empty:hidden')} data-slot="composer-no-drag">
</ComposerPrimitive.Root>
{/* Underside: chrome-free strip BELOW the composer. Outside the root
for the same reason as the micro actions — it must not fall inside
the pop-out drag region. Same px as the strip above, so the two
bracket the composer on one vertical line. */}
<div className={cn(composerFloatingStrip, 'px-[5px] pt-1.5 empty:hidden')}>
<ContribSlot area={COMPOSER_AREAS.underside} />
</div>
</ComposerPrimitive.Root>
</div>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

<UrlDialog
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { memo, useState } from 'react'

import { Codicon } from '@/components/ui/codicon'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import type { ComposerAction } from '@/store/composer-actions'
import { $composerActionsBySession, type ComposerAction } from '@/store/composer-actions'
import { notifyError } from '@/store/notifications'

/**
Expand Down Expand Up @@ -31,19 +32,14 @@ const PILL = cn(
* (`composerFloatingStrip`), this owns only the pills, so the strip above the
* surface and the `composer.underside` strip below it can't drift apart.
*/
export const ActionBadges = memo(function ActionBadges({
actions,
sessionId
}: {
actions: ComposerAction[]
sessionId: string
}) {
export const ActionBadges = memo(function ActionBadges({ sessionId }: { sessionId: null | string }) {
const actions = useSessionSlice($composerActionsBySession, sessionId)
// A pill can kick off async work (a gateway call, a submit). Track which one
// is in flight so it can spin and lock instead of double-firing.
const [runningId, setRunningId] = useState<null | string>(null)

const run = async (action: ComposerAction) => {
if (runningId) {
if (runningId || !sessionId) {
return
}

Expand Down
Loading
Loading