@wildix/wilma-web
v0.1.2
Published
The Wilma assistant as a themed React widget on @wildix/uikit: compound components, default leaves and the DOM platform.
Maintainers
Keywords
Readme
@wildix/wilma-web
The Wilma assistant as a themed React widget: the DOM distribution package, built on
@wildix/uikit and the wilma-assistant event protocol.
Three packages, and which one you want depends on how much of a UI you need:
| Package | Holds | Platform | React |
|---|---|---|---|
| @wildix/wilma-core | events · reducer · thread items · markdown tokenizer · platform ports · traces and the test simulator | none | no |
| @wildix/wilma-headless | provider · session engine · registries · selectors · item routing | none | yes |
| @wildix/wilma-web | <Wilma.*> · default leaves · <WilmaAssistant> · webPlatform | DOM | yes |
Each re-exports the ones below it, so installing this one is enough for a web app. Fold a
recorded log in a Lambda and you want wilma-core alone — it has no React peer at all,
which is the whole point of the split.
import {WilmaAssistant} from '@wildix/wilma-web';
<WilmaAssistant client={client} />;One prop. The consumer constructs and injects the WilmaAssistantClient — the SDK
owns no transport, no tokens and no environment configuration, because both consumer
apps already have a client factory and a second one here would be a worse copy of it.
Install
pnpm add @wildix/wilma-web@wildix/wilma-core, @wildix/wilma-headless, @wildix/uikit,
@wildix/wilma-assistant-client, react and react-dom are peer dependencies. Two of
those matter more than the rest: a duplicate uikit means duplicated Tailwind output and
two sets of CSS-variable themes, and a duplicate wilma-headless means two
WilmaContext objects and a useWilma() that throws "must be rendered inside
<Wilma.Root>" from inside one.
Add the stylesheet next to uikit's, so your Tailwind build scans the SDK's classes:
@import 'tailwindcss';
@import '@wildix/uikit/styles/globals.css';
@import '@wildix/wilma-web/styles/globals.css';Where the design comes from
The visual layer is a port of the assistant in rnc/packages/shared-baseline/src/wilma,
not a new design. That UI has been through real use, and its decisions are the reason
the thread reads well:
- an execution block for what the agent did and a response block for what it said, so a long tool sequence never buries the answer. The block opens while the turn runs, collapses itself when it finishes, and arrives already collapsed — silently — when a session is loaded from history;
- tool activity drawn as a timeline: an icon rail with a connector line, and the
call's input inline as
label | valuechips, so a row reads as what was searched rather than "Searched contacts" with a disclosure to open. The result collapses to128 words, one click from the full output; - a right-aligned user bubble with
10px 10px 2pxcorners, capped atmin(70%, 480px); - a 24px-radius composer whose border picks up the focus colour, with a two-row autosizing textarea and a round 36px send button;
- an 800px column, 16px between messages, and the 32px action row reserved on every turn so revealing it on hover never shifts the transcript.
Two things are deliberately not ported. rnc's raw
--gray117__LM_gray162__DM variables become uikit's semantic classes, so the SDK themes
with the design system rather than against it — the same components render x-bees,
x-hoppers, Collaboration and WMS with no per-brand code. And a call the user has to
answer is lifted out of the collapsible block into the response area: rnc had no gated
tools, and burying an approval in a block that collapses itself would make the session
unresumable.
Three layers, all public
| Import | What it gives you |
| --- | --- |
| @wildix/wilma-core | Raw AssistantSessionEvents, the SSE decoder, the incremental JSON parser, the pure (state, event) => state reducer and the derived visual items. No React. |
| @wildix/wilma-headless | The provider, the session engine, the registries and the selectors. All behaviour, no markup. |
| @wildix/wilma-web | Compound components, the default leaves and <WilmaAssistant>. |
They are layers, not tiers: the widget is written against the same public hooks you would use, and nothing in it has privileged access.
import {replaySessionEvents, buildThreadItems} from '@wildix/wilma-core';
// Fold a recorded log with no React and no transport at all.
const state = replaySessionEvents(events);
const items = buildThreadItems(state);Also exported: ./settings, ./connectors (loads the optional
@nangohq/frontend peer lazily, so a host that renders no connectors never pays for
it) and ./testing (the protocol's worked traces plus an in-memory assistant).
Customising it
Three mechanisms with three different jobs. Picking by job is what keeps any one of them from becoming a dumping ground.
1. Compound components own structure and order.
<Wilma.Root client={client}>
<Wilma.Thread>
<Wilma.Viewport>
<Wilma.Messages />
</Wilma.Viewport>
<Wilma.Error />
<Wilma.Composer />
</Wilma.Thread>
</Wilma.Root>Every part takes base-ui's render prop — the convention uikit uses, not Radix's
asChild — so any of them can be re-based onto a different element:
<Wilma.ComposerSend render={<Button size="sm">Send</Button>} />One placement is load-bearing rather than a matter of taste: <Wilma.QuestionDock>
has to be somewhere. <Wilma.Composer> renders it, and a host that replaces the
composer wholesale has to render it too — the transcript deliberately leaves the live
question out (see The timeline below), so without the dock a suspended session has
no way to be answered.
2. The components registry owns leaf rendering. Message text, the tool row, the
reasoning block, the question form, the getting-started screen. Everything in it is a
pure presentational leaf with no SDK state: props in, elements out. Anything that
owns state or order is a compound component instead.
<Wilma.Root client={client} components={{MessageText: MyText, Composer: MyComposer}} />3. Type-keyed registries own domain extension — tools, artifacts, mentions.
Two leaves worth knowing about. MessageText is the smallest and most-used entry, and
a replacement has exactly one obligation — honour streaming, or streamed text will
look stuck. ToolRow is the timeline's whole visual grammar, so replacing it re-skins
every piece of agent activity at once.
Registration is also how features switch on. A capability you did not supply produces no affordance rather than a broken one: no artifact renderer means no artifact button, not a button that opens an empty panel.
The timeline
The transcript renders the assistant's parts in the order they happened, one line each. Three decisions carry most of the difference from a plain message list, and each one is worth knowing before you restyle it:
Activity is a line, not a card. A tool call is Title · subtitle args on one 20px
row, with a chevron only when there is something under it. The subtitle is the point:
Read says nothing, Read · session-turn.tsx says whether the agent looked in the
right place, and costs no vertical space.
Consecutive activity calls fold into one row. Four reads and two searches become
Worked · Read file ×4 · Search ×2, expandable. Grouping keys off surface, never off
tool names — a call you want kept out of the fold is surface: 'content'. A call that
failed never folds either: activity was a claim about intent, made before anyone
knew the outcome, and filing a failure under Worked would say the opposite of what
happened.
Reasoning is inline and muted. 13px in the muted colour, in the flow. Thinking is
worth reading and is never the answer, so it sits one step quieter than the reply
rather than behind a disclosure nobody opens. showReasoning={false} drops it.
A live question is in the composer, not the transcript. The turn is stopped until
it is answered, so the form has to be next to the cursor and reachable from the
keyboard (↑/↓ to move, ⌘↵ to advance, Esc to dismiss). The transcript leaves a
hole for it and shows the question with its answer once it resolves.
A sub-agent is a place. A delegation is the one thing in the timeline that is not
a line, because it is not something this agent did — it is something a different agent
did. So it draws as opencode's task-tool-card: a tinted pill with the agent, its brief
and an arrow that appears on hover. Clicking it swaps the transcript for that task's own
session — back control, breadcrumb, the brief as the opening user message, the
sub-agent's own timeline, its answer, and a composer that reads Subagent sessions
cannot be prompted. Back to main session.
It does not unfold in place: a sub-agent has its own reasoning, its own calls and its own
answer, so inlining it puts a second transcript inside the first. Escape steps out one
level, and a sub-agent that delegated again opens exactly the same way.
The card's icon takes --wilma-task-agent-color (opencode's --task-agent-color), with
nothing invented behind it — our protocol carries no agent colour, so a host that has
one sets the variable and gets per-agent tinting, and a host that does not gets the
muted default rather than a palette the SDK made up.
Delegation reaches the stream twice — the delegate call, and the taskStarted for the
execution that call kicked off — so buildThreadItems folds the call into the task and
draws one row for the pair. mergeTaskInvocations: false keeps both, for an inspector.
Every part carries a stable data-slot, so spacing and typography can be tuned from a
stylesheet without wrapping anything — which is what the Timeline → Playground story
in Storybook does, and what it exports.
Writing a tool
A generative-UI tool
invocationDelta.soFar is a prefix of raw JSON, so the SDK parses it incrementally
and your component renders the input as the model writes it — the difference
between "the assistant froze for six seconds" and "the assistant is writing".
const showEmail = defineTool<EmailInput>({
name: 'show_email',
fulfilment: 'synthetic', // nothing executes; the runtime answers with `ack`
gate: 'none', // …so no turn suspends and no round trip happens
surface: 'content', // the call *is* user-facing output
stream: true, // emit deltas: the input carries content
ack: '<instructions>Draft shown with its own send control.</instructions>',
schema: {/* JSON Schema */},
render: ({input, isStreaming}) => <EmailCard email={input} writing={isStreaming} />,
});
<WilmaAssistant client={client} tools={[showEmail]} />;input is the incremental parse while it streams and the parsed document afterwards —
the same field either way, and always a prefix of the eventual value, never a
different one.
A caller-executed tool
const getAccountSummary = defineTool({
name: 'get_account_summary',
fulfilment: 'client', // the caller executes it
gate: 'result', // …so the turn suspends until it answers
surface: 'activity',
schema: {/* … */},
execute: async ({accountId}) => myApp.accounts.summary(accountId),
});The SDK suspends, runs your handler, and resumes with the result. A client tool with
no registered handler is left visible and waiting rather than answered with something
invented.
What the protocol does and does not support
The SDK implements the contract in wilma-services/docs/assistant/events.md, and
where the protocol has no operation, the SDK has no affordance. Nothing is faked.
Supported: start / continue / steer / stop / resume · streamed text and reasoning · the full invocation lifecycle including generative UI, caller-executed tools, questions and approvals · delegated sub-agents · sandbox and compaction · artifacts (through a registry slot) · attachments · session history and rehydration · settings and connectors.
Not supported, because the assistant channel has no operation for it:
- message feedback — there is no feedback operation, so thumbs up/down has nowhere to post; a local-only rating would be a lie told to the user. rnc's message row had both; here it carries copy only;
- edit and regenerate — messages have no identity and there is no branching, so rnc's regenerate button has no operation behind it;
- backend-driven non-tool UI events — there is no
custom/data escape hatch, so generative UI goes through an invocation or an artifact.
capabilities.feedback and capabilities.branching are therefore hard false.
Testing
pnpm test— reducer and parser unit tests, including a replay-equivalence test: folding a recorded log from index 0 must produce the same state as streaming it.pnpm e2e— Playwright against a Vite app with the network mocked by@msw/playwright.- Storybook lives in
packages/uikit-storybookunderwilma/*, with MSW serving a real streamed SSE body — which is where the incremental streaming states are exercised, since Playwright's routing buffers response bodies. One story per feature (Timeline → Tool rows / Reasoning / Subagents / Attachments, Composer → Questions), plus Timeline → Playground: a sidebar that assembles a turn out of real protocol events and tunes the layout live.
@wildix/wilma-core/testing ships the same fixtures, so a consumer can test its own
tools against the protocol's traces rather than hand-rolling mocks that drift from it.
