feat(desktop): composer.microActions + composer.underside contribution areas

Two new seams on the composer, both through the existing contribution
registry: a data area whose providers resolve badge descriptors per
session, and a render area for a chrome-free strip below the surface.
Core registers nothing in either — they stay empty until something
contributes.

Providers resolve from live session context rather than registering
static entries, so a contribution can be conditional without the
reactive when() the registry deliberately doesn't offer.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 19:19:52 -05:00
parent 6c2a3b4bb2
commit 1cefad5523
2 changed files with 124 additions and 7 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,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)
}