feat(desktop): render the micro-action and underside strips

Pills pin to the top of the composer's overlay lane, outside the status
card and outside its scroller, so nothing stacks above them and a long
todo list can't scroll them away. The underside slot sits below the
surface. Both share one grid constant and one parent, so their left
edges match by construction rather than by matching numbers in two
files.

The strips take pointer events while their empty space falls through to
the pop-out drag region, which keeps the composer draggable by the band
its badges live in.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 19:20:00 -05:00
parent 1cefad5523
commit 9b01c74f00
6 changed files with 256 additions and 38 deletions

View file

@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { useSessionSlice } from '@/lib/use-session-slice'
import { setComposerActions } from '@/store/composer-actions'
import { $todosBySession } from '@/store/todos'
import { type ComposerMicroActionContext, useComposerMicroActionProviders } from '../contrib'
/**
* Resolve every registered micro-action provider for this session and publish
* the result to `$composerActionsBySession`, which the pill strip renders.
*
* Core registers nothing, so the strip stays empty until something contributes
* to `composer.microActions`. Providers are pure functions of the session
* context and the set is recomputed rather than mutated, so there are no
* ordering games between registrars and a provider that stops returning a
* badge withdraws it. One that throws is skipped, so a broken plugin loses
* only its own badge.
*/
export function useComposerMicroActions(sessionId: null | string, busy: boolean) {
const todos = useSessionSlice($todosBySession, sessionId)
const providers = useComposerMicroActionProviders()
useEffect(() => {
if (!sessionId) {
return
}
const ctx: ComposerMicroActionContext = { busy, sessionId, todos }
setComposerActions(
sessionId,
providers.flatMap(provider => {
try {
return provider.resolve(ctx) ?? []
} catch {
return []
}
})
)
}, [busy, providers, sessionId, todos])
// Withdraw on unmount / session switch ONLY. Clearing in the resolve effect's
// cleanup would publish an empty set before every republish — two store
// writes and two stack re-renders for what is usually a no-op.
useEffect(() => (sessionId ? () => setComposerActions(sessionId, []) : undefined), [sessionId])
}

View file

@ -1,26 +1,37 @@
import { useSyncExternalStore } from 'react'
import { $composerActionsBySession } from '@/store/composer-actions'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
/** Structural view of the three per-session feeds they hold different item
* types, and all this hook needs from each is "does this key have rows". */
interface PresenceFeed {
get(): Record<string, undefined | unknown[]>
listen(listener: () => void): () => void
}
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
const subscribe = (onChange: () => void) => {
const offItems = $statusItemsBySession.listen(onChange)
const offPreviews = $previewStatusBySession.listen(onChange)
const offs = FEEDS.map(feed => feed.listen(onChange))
return () => {
offItems()
offPreviews()
for (const off of offs) {
off()
}
}
}
/**
* Whether a session has any status items or previews, as a coarse *edge*: the
* boolean only flips when the stack appears/disappears. ChatBar uses it to
* toggle a styling data-attr subscribing to the whole `$statusItemsBySession`
* (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps
* re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a
* 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails
* out of all of that, re-rendering only on the actual show/hide transition.
* Whether a session has any status items, micro actions, or previews, as a
* coarse *edge*: the boolean only flips when the stack appears/disappears.
* ChatBar uses it to toggle a styling data-attr subscribing to the whole
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
* per-item mutation (a subagent tick, a 5s background poll) and on churn in
* OTHER sessions. The boolean snapshot bails out of all of that, re-rendering
* only on the actual show/hide transition.
*/
export function useSessionStatusPresence(sessionId: string | null): boolean {
return useSyncExternalStore(subscribe, () => {
@ -28,9 +39,6 @@ export function useSessionStatusPresence(sessionId: string | null): boolean {
return false
}
return (
($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 ||
($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0
)
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
})
}

View file

@ -2,7 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { composerFill, composerFloatingStrip, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
@ -48,6 +48,7 @@ import { useComposerTrigger } from './hooks/use-composer-trigger'
import { useComposerUndo } from './hooks/use-composer-undo'
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
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 { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
@ -133,6 +134,10 @@ export function ChatBar({
// every per-item status mutation or other sessions' churn (see the hook).
const statusPresent = useSessionStatusPresence(statusSessionId)
// Publishes contributed micro actions for this session; the status stack
// renders them as the pill strip at the top of the overlay lane.
useComposerMicroActions(statusSessionId, busy)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
@ -1076,7 +1081,14 @@ 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={handleComposerToggle}
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()
}
}}
/>
)}
<div className="relative w-full rounded-[inherit]">
@ -1168,6 +1180,17 @@ 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">
<ContribSlot area={COMPOSER_AREAS.underside} />
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

View file

@ -0,0 +1,78 @@
import { memo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import type { ComposerAction } from '@/store/composer-actions'
import { notifyError } from '@/store/notifications'
/**
* Floating pill the treatment the thread's jump/approval button uses for a
* control that sits over scrolling content: full radius, hairline border, the
* shared composer fill behind a blur so thread text never bleeds through.
* Sized against the composer's own control height so a row of pills lines up
* with the chrome it floats above.
*
* NEVER `pointer-events-none`, not even when disabled. The pop-out drag region
* is an `absolute` sibling behind these pills, so a pill that stops taking
* pointer events hands the hit test straight to it and a dead-looking badge
* becomes a grab handle that floats the composer.
*/
const PILL = cn(
'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
'hover:bg-(--chrome-action-hover) hover:text-foreground',
'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)',
'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50'
)
/**
* The micro-action pills. Layout-free on purpose the composer owns the strip
* (`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
}) {
// 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) {
return
}
setRunningId(action.id)
try {
await action.run(sessionId)
} catch (error) {
notifyError(error, action.label)
} finally {
setRunningId(null)
}
}
return actions.map(action => {
const running = runningId === action.id
const glyph = running ? 'loading' : action.icon
return (
<button
className={PILL}
disabled={action.disabled || Boolean(runningId)}
key={action.id}
onClick={() => void run(action)}
type="button"
>
{glyph && <Codicon className="shrink-0 opacity-70" name={glyph} size="0.75rem" spinning={running} />}
<span className="truncate">{action.label}</span>
</button>
)
})
})

View file

@ -6,7 +6,7 @@ import { blurComposerInput } from '@/app/chat/composer/focus'
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { composerDockCard, composerFloatingStrip } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -15,6 +15,7 @@ import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import { $composerActionsBySession } from '@/store/composer-actions'
import {
$statusItemsBySession,
type ComposerStatusItem,
@ -29,6 +30,7 @@ import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { ActionBadges } from './action-badges'
import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
@ -93,6 +95,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const actions = useSessionSlice($composerActionsBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
@ -151,6 +154,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
// Micro actions ride at the top of the stack — the one block you press
// rather than read. Rendered OUTSIDE the card (see `actionStrip`) so the
// pills float; a blocked account still gets the billing wall above them.
for (const group of groups) {
sections.push({
key: group.type,
@ -208,7 +215,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'queue', node: queue })
}
const visible = sections.length > 0
// Micro actions are the TOP-MOST thing in the whole overlay lane — above the
// status card, above the billing wall, above everything. They're the only
// rows up here you press instead of read, so nothing may ever stack on top
// of them. Rendered outside the card (below) so the pills float.
const actionStrip = actions.length > 0 && sessionId ? <ActionBadges actions={actions} sessionId={sessionId} /> : null
const visible = sections.length > 0 || Boolean(actionStrip)
const stackRef = useRef<HTMLDivElement | null>(null)
// The stack is out of flow (overlays the thread), so the composer's measured
@ -260,29 +273,49 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
className="absolute inset-x-0 bottom-full z-3 flex max-h-[40vh] flex-col translate-y-2"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
{/* FIRST in the lane and OUTSIDE the scroller, so nothing can ever sit
above the pills not the status card, not the billing wall and a
long todo list can't scroll them out of view. Outside the card too:
they carry their own fill, so they must not paint on its background. */}
{actionStrip && (
<div
className={cn(
composerFloatingStrip,
'shrink-0 pb-1.5 transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{actionStrip}
</div>
)}
{/* Everything else scrolls under them. */}
<div className="min-h-0 overflow-y-auto">
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
)

View file

@ -33,3 +33,32 @@ export const composerPanelCard = cn(
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
composerSurfaceGlass
)
/**
* Shared grid for the chrome-free floating strips that bracket the composer
* the micro-action pills above the surface and the `composer.underside` slot
* below it.
*
* Both strips are in-flow children of the SAME box (the composer root's
* content box), which is the whole point: they previously lived in different
* parents the pills inside the status stack's absolute overlay lane, the
* chip in the root so "no padding" resolved to two different left edges and
* they never lined up. Same parent, no inset, one constant: the left edges are
* identical by construction, not by matching numbers in two places.
*
* `relative z-1` at the call sites is load-bearing, not styling. The pop-out
* drag region is an `absolute` sibling, and positioned elements paint above
* static in-flow ones whatever the DOM order so without a stacking context
* these strips sit UNDER it and their contents never receive hover or clicks
* (the region does, and hatches).
*
* The strip is full-width so a contribution can push itself to the right
* (`ml-auto`), but it is `pointer-events-none` with its CHILDREN re-enabled:
* the chips are interactive, while the empty space between and beside them
* falls through to the drag region and stays grab area. That combination is
* why the composer is still draggable by the band its badges live in.
*/
export const composerFloatingStrip = cn(
'relative z-1 flex w-full flex-wrap items-center gap-1.5',
'pointer-events-none [&>*]:pointer-events-auto'
)