Merge pull request #73711 from NousResearch/bb/composer-micro-actions

Composer contribution seams: micro-action pills and an underside strip
This commit is contained in:
brooklyn! 2026-07-28 19:28:42 -05:00 committed by GitHub
commit ded2314910
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 389 additions and 46 deletions

View file

@ -3,13 +3,16 @@
* through the SAME registry schema as every other surface (statusbar, titlebar,
* panes, layouts):
*
* render areas (`render`): composer.top banner strip above the input
* composer.bottom row below the input grid
* composer.leading inline after the "+" menu
* composer.actions inline before the model pill
* render areas (`render`): composer.top banner strip above the input
* composer.bottom row below the input grid
* composer.underside floating strip BELOW the
* whole composer (no chrome)
* composer.leading inline after the "+" menu
* composer.actions inline before the model pill
*
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
* composer.microActions (ComposerMicroActionProvider)
*
* Core keeps ownership of the transcript, input, and submit engine these
* seams AUGMENT the composer, they never replace it. Middleware runs as an
@ -17,17 +20,23 @@
* draft, pass it through, or cancel the send by returning null.
*/
import { useMemo } from 'react'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import type { TodoItem } from '@/lib/todos'
import type { ComposerAttachment } from '@/store/composer'
import type { ComposerAction } from '@/store/composer-actions'
export const COMPOSER_AREAS = {
top: 'composer.top',
bottom: 'composer.bottom',
underside: 'composer.underside',
leading: 'composer.leading',
actions: 'composer.actions',
middleware: 'composer.middleware',
attachments: 'composer.attachments'
attachments: 'composer.attachments',
microActions: 'composer.microActions'
} as const
export interface ComposerDraft {
@ -92,3 +101,42 @@ export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvid
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
.filter(p => Boolean(p.label && p.run))
}
/**
* Payload of a `composer.microActions` data contribution the pill strip at
* the top of the composer's overlay lane.
*
* `resolve` is called with the live session context and returns the badges to
* show right now, or `[]` for "nothing from me". Returning a list rather than
* a static badge is what lets a provider be conditional ("only while idle",
* "only with unfinished tasks") without a reactive `when()`, which the
* registry deliberately doesn't offer.
*/
export interface ComposerMicroActionProvider {
resolve: (ctx: ComposerMicroActionContext) => ComposerAction[]
}
/** What a micro-action provider gets to branch on. Deliberately small: every
* field here is a standing compatibility promise to the plugins using it. */
export interface ComposerMicroActionContext {
/** A turn is currently running in this session. */
busy: boolean
sessionId: string
/** Live todo list for the session (empty when there is none). */
todos: readonly TodoItem[]
}
/** Micro-action providers, memoised against the registry's own stable
* snapshot the strip re-resolves on every composer render, so a fresh array
* here would defeat that. */
export function useComposerMicroActionProviders(): ComposerMicroActionProvider[] {
const contributions = useContributions(COMPOSER_AREAS.microActions)
return useMemo(
() =>
contributions
.map(c => c.data as ComposerMicroActionProvider)
.filter(p => typeof p?.resolve === 'function'),
[contributions]
)
}

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

@ -49,7 +49,15 @@ function gestureTargetOk(target: EventTarget | null) {
return false
}
return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
// `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"]'
)
}
/** Floating composer's 5px outer frame — grab here to drag without long-press. */

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'
)

View file

@ -0,0 +1,69 @@
import { atom } from 'nanostores'
/**
* Composer micro actions the floating pill strip at the top of the composer's
* overlay lane.
*
* A badge is a label and a `run`; what it does is entirely the registrar's
* business. Core ships none the strip is a seam, filled by contributions to
* the `composer.microActions` area (see `composer/contrib.ts`).
*
* Session-scoped like every other stack feed: keyed by RUNTIME session id, and
* written immutably per key so `useSessionSlice` bails out on other sessions'
* churn.
*/
export interface ComposerAction {
/** Stable within a session; also the render key. */
id: string
label: string
/** Codicon name rendered before the label. */
icon?: string
disabled?: boolean
/** What the badge does. Anything: submit a prompt, open a pane, call the
* gateway. Receives the session it was registered for. */
run: (sessionId: string) => Promise<void> | void
}
export const $composerActionsBySession = atom<Record<string, ComposerAction[]>>({})
/**
* Two badge sets that would render identically.
*
* `run` is excluded on purpose: it's a fresh closure on every resolve, so
* including it would make every comparison false and defeat the bail-out
* below. The invariant that buys: a provider must not change what `run` DOES
* without also changing a visible field.
*/
const same = (a: readonly ComposerAction[], b: readonly ComposerAction[]) =>
a.length === b.length &&
a.every((x, i) => {
const y = b[i]!
return x.id === y.id && x.label === y.label && x.icon === y.icon && x.disabled === y.disabled
})
/**
* Publish this session's badge set.
*
* A no-op resolve must not write: providers are re-resolved on every composer
* render, and an equal-but-fresh array would re-render the stack on every
* keystroke. An unchanged set keeps its previous reference, so the store stays
* quiet.
*/
export function setComposerActions(sid: string, actions: ComposerAction[]) {
const current = $composerActionsBySession.get()
if (!sid || (current[sid] ? same(current[sid], actions) : actions.length === 0)) {
return
}
const next = { ...current }
if (actions.length > 0) {
next[sid] = actions
} else {
delete next[sid]
}
$composerActionsBySession.set(next)
}