mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
6c2a3b4bb2
commit
1cefad5523
2 changed files with 124 additions and 7 deletions
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
69
apps/desktop/src/store/composer-actions.ts
Normal file
69
apps/desktop/src/store/composer-actions.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue