@ade-dev/chat-ui
v0.5.1
Published
Embeddable React chat components for the ADE SDK.
Readme
@ade-dev/chat-ui
Embeddable React chat components over @ade-dev/sdk.
This package renders an agent conversation for your users, not for ADE
developers. There are no lanes, projects, repos, or worktrees in any prop or
any string it can display. Tool activity is renamed through a label map, so a
customer sees "Searching your invoices…" where the agent ran server.tool.
- Zero runtime dependencies. React and React DOM are peers;
@ade-dev/sdkis an optional peer used for types only. - Theming is CSS custom properties only — no Tailwind, no class overrides.
- Every component is importable standalone;
<AdeChat>is one assembly of them.
npm install @ade-dev/chat-uiimport { AdeChat, createTheme } from "@ade-dev/chat-ui";
<AdeChat
client={client}
threadKey="support-42"
labels={{ map: { "server.*": "Looking that up…" } }}
theme={createTheme({ accent: "#7c5cff", background: "#0e0f13" })}
/>;Layout
packages/chat-ui/
src/
index.ts public surface
sdkTypes.ts copied SDK contract (see below)
AdeChat.tsx composed default
composer/
Composer.tsx
composerState.ts pure send/steer/key decisions
transcript/
Transcript.tsx scroll container + row views + ActivityIndicator
ToolChip.tsx
ApprovalCard.tsx inline approval card
transcriptRows.ts ported row collapsing/grouping
markdown.tsx dependency-free markdown renderer
links.tsx AdeLink + onLinkClick context (every link chat-ui draws)
models/
ModelPicker.tsx rail + search + grouped list
ProviderCard.tsx ProviderCard, ProviderCards
modelSearch.ts ported scoring + provider grouping
activity/labels.ts label map, wildcards, phases, elapsed
theme/
createTheme.ts token generator
styles.ts the stylesheet
AdeChatStyles.tsx
context/AdeChatContext.tsx provider + useAdeProviders + useAdeThread
examples/
fakeClient.ts typed in-memory client
basic.tsx full assembly, both shapes
test/ 225 testsPorted files carry a provenance header naming their ADE desktop source and what
was trimmed. transcriptRows.ts and modelSearch.ts are ports;
Composer.tsx and the model picker components are fresh implementations that
follow the desktop interaction/visual structure without inheriting its props.
Components
<AdeChat>
Transcript above, composer with the model rail below. No header bar.
| Prop | Type | Default | |
|---|---|---|---|
| client | AdeChatClient | — | required |
| threadKey | string | — | changing it opens a different conversation |
| defaultModelId / modelId | string | — | uncontrolled / controlled selection |
| onModelChange | (model: ModelDescriptor) => void | — | |
| labels | ActivityLabelConfig | — | |
| theme | Partial<AdeChatTheme> | — | usually createTheme(...) |
| disableStyles | boolean | false | skip the injected stylesheet |
| placeholder, sendOnEnter, onRequestAttachment | | | forwarded to <Composer> |
| value / onValueChange | | — | controlled draft, forwarded to <Composer> (0.4) |
| onSend | (input, thread) => AdeChatSendResult \| Promise<…> | — | runs before a new message is sent; false cancels and keeps the draft, "handled" means you sent it yourself (0.4) |
| children | (thread: ThreadState) => ReactNode | — | drawn between transcript and composer (0.4) |
| threadRef | { current: ThreadState \| null } | — | the live thread state for code outside the tree (0.4) |
| onLinkClick | (href, { source, text }) => void | — | handle links yourself; pass it in Electron (0.4) |
| modelRail | ReactNode | built-in picker | replaces the default picker; null = empty rail |
| actions | ReactNode | — | extra composer controls (right rail) |
| attachments / onAttachmentsChange | | — | controlled staging; see the merge rule under <Composer> |
| reasoningEffort / onReasoningEffortChange | | — | the picker's effort control; the host applies it |
| renderToolResult, toolChipActions | | | forwarded to <Transcript> |
| historyPageSize | number | 200 | events per history page, when the thread has historyPage |
| styleNonce | string | — | CSP nonce for the injected <style> |
| hideToolCalls, hideReasoning, renderMarkdown, emptyState | | | forwarded to <Transcript> |
| approvals | { render?, labels? } | — | approval card wording, or a replacement card |
| hideModelPicker | boolean | false | when the host pins a model |
| className | string | — | |
The approval card itself is not opt-in. A provider that asks for permission
parks its turn until someone answers, so a host that drew nothing would show a
conversation that had silently stopped. approvals changes only how it looks.
<Composer>
| Prop | Type | Default | |
|---|---|---|---|
| onSend | (input: SendInput) => void \| Promise<void> | — | required |
| onSteer | same | — | omit to disable steering entirely |
| onInterrupt | () => void \| Promise<void> | — | omit to hide Stop |
| status | "idle" \| "running" \| "error" | "idle" | |
| ready | boolean | true | false while the thread resolves |
| disabled | boolean | false | |
| value / onValueChange | string / (v) => void | — | controlled draft |
| placeholder | string | "Send a message…" | |
| sendOnEnter | boolean | true | false swaps to Cmd/Ctrl+Enter |
| autoFocus | boolean | false | |
| maxRows | number | 12 | autosize ceiling |
| onRequestAttachment | () => Promise<ChatAttachment[] \| null> \| … | — | omit to hide the button |
| attachments / onAttachmentsChange | | — | controlled staging |
| modelRail | ReactNode | — | slot for the model rail |
| actions | ReactNode | — | extra controls |
| error | string \| null | — | |
| className | string | — | |
Controlled attachments: onAttachmentsChange always receives the FULL new
list, so replace your state with it and never append. A picker result is merged
into the latest list (not the list from when the picker opened), duplicates are
removed by id, and a remove click removes one id. Every attachment is sent
with type: "image" for an image/* MIME type or an image extension (bmp,
gif, heic, heif, ico, jpeg, jpg, png, svg, tif, tiff, webp), else "file". This
is the runtime's own rule.
Submitting during a running turn dispatches onSteer, never a second onSend,
and staged attachments go with the steer. Enter sends, Shift+Enter is a newline, IME composition never submits, Escape
interrupts a running turn. A failed send restores the draft rather than losing
it.
<Transcript>
| Prop | Type | Default | |
|---|---|---|---|
| rows | readonly TranscriptRow[] | — | from buildTranscriptRows() or useAdeThread() |
| status | "idle" \| "running" \| "error" | "idle" | drives the live indicator |
| labels | ActivityLabelConfig | — | |
| hideToolCalls | boolean | false | hides chips entirely |
| hideReasoning | boolean | false | |
| expandReasoning | boolean | false | reasoning starts collapsed |
| renderMarkdown | (text: string) => ReactNode | built-in | |
| onLinkClick | (href, { source, text }) => void | — | chat-ui calls preventDefault() and hands you the link. Without it, links open with target="_blank" — in Electron, a chrome-less app window (0.4) |
| onApprove | (itemId, decision) => void \| Promise<void> | — | omit to render approval cards read-only |
| approvals | { render?, labels? } | — | custom approval card and button wording |
| emptyState | ReactNode | "No messages yet." | |
| renderToolResult | (row: ToolChipRow) => ReactNode | — | custom result view under a chip |
| toolChipActions | (row: ToolChipRow) => { label, onSelect }[] | — | buttons on a chip. Prefer row.resourceLinks; when a tool returns only ids, parse row.result yourself |
| hasOlder / loadingOlder / onLoadOlder | | — | "Load older messages" paging |
| windowThreshold | number | 150 | row count above which rows are windowed |
| overscan | number | 8 | rows mounted on each side of the view |
| className | string | — | |
Card set: user text, assistant markdown, collapsed reasoning, tool chips,
approvals, error. Pinned to the bottom and released as soon as the reader
scrolls up. Above windowThreshold rows only the rows in view plus overscan
are mounted (no dependency; every row renders where ResizeObserver is
missing). Tool chip rows carry identity: { server, tool } and, when the
provider passes them, resourceLinks.
Approval cards
An approval_request becomes an approval row and renders inline where the
request happened, not as a modal — the reader needs to see what the agent was
doing when it asked. pending_input_resolved settles the same card in place,
and a turn that ends unanswered marks it expired. The card never disappears and
never takes focus from the composer.
Two rules keep a live card from being drawn dead, which is a hang rather than a cosmetic bug — the buttons go read-only while the runtime still waits:
- A turn ending is
done, or astatusofcompleted/failed/interrupted. Nevererror. Anerrorends no turn on either layer: an OpenCode per-tool failure emits one and keeps streaming, and the Codex planning-approval guard emits one to decline a single request. - A restored row is live by construction.
pendingApprovals()is the engine's authoritative "still blocked right now" list, read after the history window, so no ending in that history expires it. Only an explicit resolution settles it. Restored rows sort in at the instant they were read rather than being appended.
| Decision | Button | Meaning |
|---|---|---|
| accept | Allow once | this call only |
| accept_always | Always allow | stop asking for this in this session |
| reject | Reject | refuse, and let the model hear why |
requestKind of question, structured_question, plan_approval or
model_selection renders read-only: those want prose or a choice this surface
cannot carry, and @ade-dev/sdk's own approve() refuses them with
invalid_option rather than sending a verdict the request cannot use. A thread
with no approve also renders read-only, with a line saying the host cannot
answer.
<ModelPicker>
| Prop | Type | Default | |
|---|---|---|---|
| value | string \| null | — | selected model id |
| onChange | (model: ModelDescriptor) => void | — | required |
| models / providers | arrays | — | supply data directly; omit to read from the client |
| client | AdeChatClient | context | used only when data is not supplied |
| searchable | boolean | true | |
| renderProviderNotice | (status, providerId) => ReactNode | — | drawn under an unusable provider group |
| className | string | — | |
Models group under their provider; rows are disabled when the provider is not
installed/authenticated or the model reports available: false.
<ProviderCard> / <ProviderCards>
Free-floating — the host decides placement.
| Prop | Type | |
|---|---|---|
| status | ProviderStatus | required on ProviderCard |
| renderAction | (command, kind: "install" \| "login") => ReactNode | replace the copy button |
| onCopy | (command: string) => void \| Promise<void> | override the clipboard write |
| showDetail | boolean (default false) | add a line with the version and a truncated binary path |
| className | string | |
<ProviderCards> adds statuses, client, and onlyNeedsAttention (default
true) and renders one card per provider needing action. showDetail is
forwarded to every card.
A missing provider reads "Not installed" only when source is "probed" —
a runtime looked and found nothing. When the status was derived from the model
catalog nobody looked, so the card says "Not detected" instead. Telling someone
to install a CLI they already have is the failure that distinction removes.
Activity labels
labels={{
map: {
"server.tool": { running: "Searching…", done: "Searched", error: "Search failed" },
"server.*": "Talking to your account…", // string = running phase only
"*": "Working…",
},
resolve: (source) => source.tool === "x" ? "Custom" : null, // wins over map
icons: { "server.*": <ServerIcon /> },
thinkingLabel: "Thinking…",
elapsedAfterMs: 3000,
}}Resolution order: resolve() → exact key → longest wildcard prefix → * →
the raw tool name. A bare string labels the running phase only, so a finished
chip never keeps saying "Searching…". Labels apply to the live thinking
indicator, tool chips, and error text. An elapsed suffix appears after 3s of
running and is formatted 45s / 1m 35s / 1h 5m. prefers-reduced-motion
is respected in both CSS and the indicator's animated ellipsis.
An MCP tool may be keyed in any spelling — mcp:versic:search,
mcp__versic__search, versic:search, or the bare search — and one key
matches the tool under every provider. A server-qualified key matches a
tool the provider reported by its bare name only when the event names that
server (ToolChipRow.identity). A bare name with no server on the event
matches only a bare key, and the matcher never guesses a server from the name.
A bare key is compared with the tool's own name, so the bare key to write is the name your providers report. One wildcard covers a whole family when your injected tools carry a prefix:
labels={{
map: {
"mcp:versic:*": "Checking your projects…", // wins when the event names the server
"versic_*": "Checking your projects…", // the tool's own name, e.g. versic_projects
"*": "Working…",
},
}}versic_* matches versic_projects, whether it arrives bare or as
mcp__versic__versic_projects. It does not match a bare search that
belongs to the versic server — that name starts with search — so only * or
an exact search key reaches that one.
Theming
createTheme({ accent, background, foreground, muted, danger, success, radius, fontFamily, monoFontFamily, fontSize, space, scheme })
returns the full token set; hovers, borders and subtle tints are derived. Pass
it to theme, or set the tokens yourself on any ancestor.
--adechat-bg --adechat-accent --adechat-radius
--adechat-bg-subtle --adechat-accent-fg --adechat-radius-sm
--adechat-bg-raised --adechat-accent-subtle --adechat-font
--adechat-fg --adechat-border --adechat-font-mono
--adechat-muted --adechat-border-strong --adechat-font-size
--adechat-danger --adechat-hover --adechat-space
--adechat-danger-subtle --adechat-success--adechat-root-bg is the chat root's background: transparent for
createTheme({ background: "transparent" }), else var(--adechat-bg). Pass
scheme with a transparent background. injectAdeChatStyles(target?, { nonce })
and <AdeChatStyles nonce> set a CSP nonce on the injected <style>.
Light/dark is inferred from the background's luminance (override with
scheme). Non-hex colors (var(--brand), color-mix(...)) pass straight
through; derived tints are only computed for hex inputs.
What adaptSdkClient produces
src/sdkTypes.ts is the view contract this package renders against — a copy,
not an import, so the package is standalone and any client shape can satisfy it.
src/adapters/sdkClient.ts bridges a real @ade-dev/sdk client onto it and does
import that package's types (type-only, and @ade-dev/sdk is an optional peer, so
nothing reaches the bundle).
The list below is the adapter's output contract: what every component here may assume about the client it is handed. Write your own client — a WebSocket proxy, an Electron IPC bridge, a fake — and these are the rules to meet.
One such client is checked here rather than described: test/electronBridge.test.tsx
assigns createAdeIpcClient() from @ade-dev/sdk/electron/renderer to
SdkLikeChatClient with no cast, then drives a full turn through
registerAdeIpc and <AdeChat>. A drift between that bridge and this contract
is a typecheck failure, not a runtime surprise.
client.providers.status()resolvesProviderStatus[]withinstalledandauthenticatedas separate booleans. Both true means selectable.loginCommand/installCommandare copy-pasteable shell strings.sourcesays whetherinstalledwas probed or derived, and the card's wording depends on it.client.providers.onChange(cb)fires with the full status list (not a delta) and returns an unsubscribe function. A status change may also change the model catalog, so the hook re-readsmodels.list()after each one.refresh()is optional and is never called by a poll.client.models.list()resolves the full catalog.providerIdmust match aProviderStatus.id; models whose provider has no status entry still render (grouped under the raw id) but are never selectable.client.threads.open(key, opts)is idempotent per key within a session — re-opening the same key returns a handle onto the same conversation.thread.history()returns envelopes in transcript order. Ordering is envelope-based (sequence, thentimestamp); provider clocks are not trusted. Events emitted whilehistory()is in flight must also reach the"event"subscriber — the hook de-duplicates the overlap onsessionId:sequence:timestamp:typeand then re-sorts, so a live envelope that beat the page still renders in its own place. The canonical definition of that key isenvelopeDedupeKeyin@ade-dev/sdk(src/electron/protocol.ts); this package mirrors it because the SDK is an optional peer.- Streaming text may be sent either as growing snapshots or as deltas; both
collapse correctly. Chunks of one message must share a
messageId, or failing that aturnId+itemId. tool_resultmust reuse itstool_call'sitemId, or carry a matchinglogicalItemIdwhen the provider renumbers items. Otherwise the result renders as a second, orphaned chip.thread.on("status")reportsrunningfor the whole duration of a turn and returns toidlewhen it ends. The composer's send-vs-steer decision is driven entirely by this.steer()does not start a turn. It delivers into the running one and must be safe to call whilestatus.state === "running".- Unknown event types are ignored, not rendered. The SDK may add event kinds without breaking this package, but anything it wants drawn here needs a matching row kind.
approval_requestblocks the turn until it is answered. Anapproval_requestand itspending_input_resolvedmust share anitemId(or a matchinglogicalItemId), and a turn that ends without an answer must emitdone,error, or a terminalstatusso the card can settle.thread.approveandthread.pendingApprovalsare optional. A client that omitsapprovegets a read-only card with a line saying why, never a throw.pendingApprovals()is read once on open, so a reload restores the cards a lost live stream would otherwise have taken with it.
What @ade-dev/sdk actually returns
The adapter exists because the two shapes do not meet. The differences that catch people writing their own proxy:
providers.status()returns aRecord<string, ProviderStatus>keyed by provider id, not an array.- A status record calls the id
provider, notid, and carriesavailable/requiresConfiguration/modelCount/stale— fields this package folds intoinstalledand adetailsentence. - A catalog entry calls its provider
providerand its usabilityisAvailable, where the view contract usesproviderIdandavailable. threads.open(key, opts)takes the SDK's own option names (provider,model), whichadaptSdkClientmaps fromproviderId/modelId.send(text, { attachments })is positional, and an attachment is a{ path }file ref rather than the view'sChatAttachment.on("status")andon("usage")deliver raw envelopes; the adapter maps them to{ state, turnId }and{ inputTokens, … }and drops anything that maps to nothing.
Development
Requires Node 22 (/opt/homebrew/opt/node@22/bin on macOS).
npm install # in packages/chat-ui
npm test # vitest, 225 tests
npm run typecheck # tsc --noEmit
npm run build # tsup → dist/ (ESM + CJS + d.ts)From the repo root: npm run test:chat-ui, npm run build:chat-ui.
License
MIT. See LICENSE. ADE itself remains AGPL-3.0-only; see the ADE Runtime Embedding Exception for shipping the runtime binary in your app.
