diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts index 893f5174c200..b7cba9d89e6f 100644 --- a/apps/desktop/src/app/chat/composer/contrib.ts +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -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 ({ 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] + ) +} diff --git a/apps/desktop/src/store/composer-actions.ts b/apps/desktop/src/store/composer-actions.ts new file mode 100644 index 000000000000..b181a45f3be8 --- /dev/null +++ b/apps/desktop/src/store/composer-actions.ts @@ -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 +} + +export const $composerActionsBySession = atom>({}) + +/** + * 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) +}