@econ-v1/chat-ui
v0.2.2
Published
Framework-free chat UI for Node client stages and shell surfaces: streamed replies, tool rows, approval cards and browser-tool calls over one injected transport.
Maintainers
Readme
@econ-v1/chat-ui
Framework-free chat UI for Node client stages and shell surfaces (Node Assistant sub-project B). It renders an agent conversation — streamed replies, tool rows, approval cards, browser-tool calls, report previews — inside the caller's shadow root, with no timers, no I/O of its own, and no shared state: everything goes through one injected ChatTransport, and every mount owns all of its state (no module-level singletons, caches or counters; two chats on one page share nothing). It only reads standard platform APIs (document, ShadowRoot, CSSStyleSheet, matchMedia, getComputedStyle, and Date.now as the controller's default clock) and never writes a global, document.head, or storage.
- Checked JavaScript (
// @ts-check+ JSDoc), ESM,.d.tsshipped. No runtime dependencies. - Consumers bundle it with Vite into their stage or surface
dist; the consuming app's build stamps integrity. It is not an installable app. - Runs unchanged under the PWA CSP (
script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline').
Install
npm install @econ-v1/chat-uiUse it in a stage or surface
import { mountChat, createStageTransport } from "@econ-v1/chat-ui";
const tabId = crypto.randomUUID(); // once per mount (C §5.3); pass the same value to both
const transport = createStageTransport(ctx, { agent: "node-assistant", tabId });
const chat = mountChat(ctx.host.root, {
transport,
sessionId, // create sessions up front; omitted = the first prompt mints one
mode: "normal", // or "advanced"
tabId,
context: () => latestShellContext, // sent as page_context on every prompt
onEvent: (e) => {
if (e.type === "client-call") runProbe(e.call); // D executes browser tools, then:
if (e.type === "client-call.takeover") claim(e.callId); // D calls agent.tool_claim itself
if (e.type === "navigate") ctx.host.navigate(e.path);
},
});
// after a probe: await chat.completeClientCall(callId, { result }) or ({ error: { code, message } })
// on panel visibility: chat.reconcile()
// on unmount: chat.dispose()Manifest requires for createStageTransport
{
"capabilities": ["agent.prompt", "agent.abort", "agent.session.get", "agent.session.turn_state", "agent.card.resolve", "agent.tool_result"],
"streams": ["app.agent_session", "transport.state-changed"]
}agent.tool_claim is not needed by the library. A consumer that offers "Run here" calls it directly and adds it to its own requires.
API
mountChat(root, options) → ChatHandle
| Option | Type | |
|---|---|---|
| transport | ChatTransport | required; seven members |
| mode | "normal" \| "advanced" | required |
| sessionId | string | optional |
| tabId | string | compared with each client call's ownerTab |
| scheduler | { after(ms, fn): cancel } | optional quiet-stream check; surfaces must not pass one |
| onEvent | (ChatEvent) => void | exceptions are swallowed |
| labels | Partial<ChatLabels> | every user-visible string; defaults are English |
| context | () => JsonObject \| undefined | read on every send |
The handle exposes dispose(), send(text), focus(), stop(), reconcile(), openSession(id | null), setMode(mode), completeClientCall(callId, outcome) and snapshot ({ sessionId, phase, gap, restored, pendingCards, pendingClientCalls }).
ChatEvent is one of: state, session, client-call, client-call.takeover, card (an unknown card kind), navigate, envelope (a stream kind the library does not render, e.g. turn.started), or error. Ignore types you do not know; new types are a minor release.
ChatTransport
The transport has exactly seven members: prompt, abort (never rejects), subscribe, history, turnState (never rejects), resolveCard and toolResult. The other four members reject with ChatTransportError { code }, where code ∈ responder_mismatch | not_found | not_pending | forbidden | unavailable | invalid | aborted | unknown.
Components and helpers
The view components return a handle { el, update(next), dispose() } and never append themselves; the caller places el. dispose() removes listeners and el.
| Export | Returns |
|---|---|
| createToolRow(row, { labels? }) | { el, update(row), dispose() } |
| renderQuestionCard(card, { answered, onAnswer(choice) → boolean, idPrefix? }) | { el, update({ answered }), dispose() } |
| renderApprovalCard(card, { local, onDecision(d) → boolean \| void, labels?, onNavigate?, idPrefix? }) | { el, update({ card, local }), dispose() }; onDecision returning false (refused) re-enables the buttons |
| renderClientCallRow(model, { onTakeover(callId), tabId?, labels? }) | { el, update(model), dispose() } |
| renderReportPreview(args, resolved, { labels?, onNavigate? }) | { el, update({ args, resolved }), dispose() } |
| renderSessionList(rows, { onSelect(id), onNew(), activeId?, labels? }) | { el, update({ rows, activeId? }), dispose() } |
| createComposer({ onSend(text) → boolean, onStop(), labels? }) | { el, update(composerView), focus(), dispose() } |
| renderMarkdown(text, { onNavigate? }) | a DocumentFragment (no handle) |
| setMarkdown(target, text, { onNavigate? }) | void; replaces target's children |
| createScrollStick(logEl) | { noteScroll(), stick(), toBottom(), jumpState(busy) → "hidden" \| "arrow" \| "busy", pinned } (no element) |
| createChatController(options) | a ChatController: { state, subscribe(listener) → unsubscribe, start(), send(text) → { accepted, settled }, stop(), reconcile(), openSession(id), setMode(mode), resolveCard(cardId, decision), completeClientCall(callId, outcome), answerQuestion(key, reply) → boolean, snapshot(), dispose() } |
| adoptStyles(root) | a () => void that removes exactly what it added |
| questionReply(question, choice) / questionDismiss(question) | the reply string to send |
idPrefix makes a card's element ids unique; mountChat passes a per-mount prefix plus the item key.
@econ-v1/chat-ui/wire
The DOM-free entry: the decoders (unwrapEnvelopeFrame, decodeHistoryPage, decodeTurnState, decodeSessionList, decodeApprovalCard, decodeCardResolved, extractQuestionCard, readToolEvent, toolDisplay, SayMessageExtractor, …), the verdicts, and the pure turn machine (reduce, initialState).
Styling
There is one stylesheet, wrapped in @layer chat-ui, adopted into the host shadow root and never document.head. Restyle through the --ncu-* variables, which map onto the skin's --ds-* tokens. CSS class names are not part of the API.
Without a skin, the fallback accent is #B8423E: Send (#FFFFFF on it) and Approve (it on the #FFFFFF card) are 5.40:1, WCAG AA. A skin that sets --ds-accent owns that contrast; set --ds-on-accent if white text does not suit it.
Size budgets
npm run size builds each entry with Vite (esbuild minify) and fails when the gzipped bundle is over budget:
| Entry | Budget (gzip) |
|---|---|
| mountChat (everything it pulls in) | 32 KiB |
| @econ-v1/chat-ui/wire | 12 KiB |
Raising a budget is a reviewed change (scripts/size-report.mjs).
Provenance
Copied files carry a provenance header. Sources are pinned to econ-v1/node-app-stage-agent-builder@ae86a1d and econ-v1/node-app-agent@33cb98a.
| Library file | Source | Adaptation |
|---|---|---|
| src/view/markdown.js | Builder ui/src/lib/markdown.js | anchors only for http(s)://; /path → navigate button; //host is text; table alignment by class; iterative, linear-time inline scan; redaction chips |
| src/wire/envelope.js | Builder builder-client.js:753-818 | none |
| src/wire/transcript.js | Builder builder-client.js:1318-1446 | message_id kept; decodeHistoryPage + next_cursor |
| src/wire/stream-text.js | Builder chat-real.js:575-689 | "assistant" wording |
| src/model/verdicts.js | Builder chat-real.js:213-247, 394-531, 691-745 | client_tool, card, card_resolved roles; no ack-grace clock |
| src/wire/tool-event.js | Builder turn-trace.js:91-152, 248-424 | assistant tool labels; client/claim/error fields |
| src/view/tool-row.js | Builder turn-trace.js:470-529, 826-857 | rewritten row; no hide timer; no knowledge diff view |
| src/view/scroll-stick.js | Builder chat.js:65-168 | per instance; no settle clock |
| src/wire/cards.js | Builder canvas-intents.js:278-307 | approval + resolved decoders are new |
| src/view/question-reply.js | Builder question-reply.js:1-36 | agentPickReply not taken |
| src/view/question-card.js | Builder popups.js:715-746 | own listeners; submits via the controller |
| src/wire/say-extractor.js | agent ui/src/lib/say-message-extractor.ts | TS → checked JS |
| src/wire/json.js, src/transport/errors.js, src/transport/stage-transport.js (helpers) | Builder builder-client.js | see headers |
Run npm run drift (needs gh) to list watched upstream files that changed on main since the pins.
Releasing
Release is strictly tag-driven: the version is the vX.Y.Z tag and nothing else.
npm run check && npm run lint && npm run guard && npm test && npm run check:consumer && npm run pack:drynpm run drift, and record any upstream changes to copied files in the release notes.- One-time setup (mirrors the node repo's
publish-sdks.yml— no GitHub Environment involved):- Configure the npm Trusted Publisher on npmjs.com: package
@econ-v1/chat-ui→ Settings → Trusted publishing → Add trusted publisher → GitHub Actions, with organizationecon-v1, repositorynode-app-lib-chat-ui, workflow filenamepublish.yml, and environment name left blank. Equivalent CLI:npm trust github @econ-v1/chat-ui --file publish.yml --repo econ-v1/node-app-lib-chat-ui --allow-publish. - Trusted publishing only works on a package that already exists on npm, so the very first version is published once by a maintainer, not by the workflow: from a clean checkout of the
v0.1.0tag, runnpm ci && npm run build && npm publish --access public. Every later release goes throughpublish.yml.
- Configure the npm Trusted Publisher on npmjs.com: package
- Publish a GitHub release
vX.Y.Z.publish.ymlverifies the tag (the whole gate, includingcheck:consumer), then stamps the tag's version intopackage.jsonand publishes with OIDC from a GitHub-hosted runner. A manual run must be started on the tag (Run workflow → Use workflow from → Tags →vX.Y.Z); on any other ref it fails. Runs for one ref never overlap, and a version already on npm is skipped.
The first release is 0.1.0; 1.0.0 follows once the Assistant ships on node main. Before 1.0, a breaking change bumps the minor version.
Adoption notes for Agent Builder
Owner: huynamboz. Nothing here is required of the Builder.
- Direct swaps:
lib/markdown.js→renderMarkdown/setMarkdown.- The frame, envelope and transcript decoders →
@econ-v1/chat-ui/wire. - The pure verdicts in
chat-real.js→ thewireverdicts. question-reply.js→ the same names.
- Transport: a
ChatTransportoverbuilder-client.jsis a thin object (prompt:2279,abortTurn:2340,turnState:2364,sessionMessages:1457,subscribeSession:3136). - What stays in the Builder: the BUILD/RUN split, crew-voice routing, wave frames, canvas ceremony, the studio binding and the thinking-word animation.
- Upstream reports from this work: the protocol-relative link in
SAFE_HREF, the shared scroll-pin module state, and the quadratic inline-mark scan.
License
MIT OR Apache-2.0.
Workspace view state and evidence
Architecture: ADR-001.
handle.viewState captures { draft, scroll: { key, offset, scrollTop, pinned } }.
Pass it to mountChat(..., { viewState }) when moving a conversation to another host,
or call handle.restoreViewState(view) to restore the current session without new
subscriptions. Hosts must keep this state associated with its originating session.
citation(id) returns consumer DOM for an opaque evidence token; the library adds
data-ncu-citation. Consumers authorize and load evidence themselves. Uncited
completed answers display a neutral verification footer when a citation factory is
installed. Citation presence never establishes the truth of the assistant's inference.
