Skip to main content
@ade-dev/chat-ui renders an agent conversation for your users. There are no lanes, projects, repos, or worktrees in any prop or any string it can display.
React and React DOM are peers (^18 or ^19). @ade-dev/sdk is an optional peer used for types only.
<AdeChat> is transcript above, composer with the model rail below. No header bar. Changing threadKey opens a different conversation. adaptSdkClient maps a real @ade-dev/sdk client onto the view contract. Any SDK-shaped proxy works — for example a renderer that talks to main over a WebSocket. Every link that chat-ui draws goes through one component, <AdeLink>: links in assistant markdown, and the “Documentation” link on a provider card. Pass onLinkClick to decide what opens and where:
With onLinkClick, chat-ui calls preventDefault() and then calls your handler, so nothing opens unless you open it. source is "markdown" or "provider-docs". href has already passed safeHref: only http:, https:, mailto:, tel:, # and relative paths get through, never javascript: or data:. Without onLinkClick, a link opens with target="_blank" and rel="noreferrer noopener", which is right for a web page.
In Electron, target="_blank" opens a new BrowserWindow with no browser chrome. It looks like part of your app and shows a page at a URL that a model wrote. Pass onLinkClick, and deny new windows in main with setWindowOpenHandler. See Electron: Links in the chat.
<Transcript>, <ProviderCard> and <ProviderCards> take the same onLinkClick prop. A custom renderMarkdown draws its own links, so onLinkClick does not reach them unless your renderer uses <AdeLink>, which is exported.

Host control of the composer

<AdeChat> accepts these props for a host that drives the conversation itself (chat-ui 0.4):
ThreadState carries rows, status, model, send, steer, interrupt, setModel, update, retry, editLast, approve, and the flags ready, canSetModel, canUpdate, canRetry and canApprove. A “Retry” button calls thread.retry(), and an “Edit” flow calls thread.editLast(input). When the runtime reports that the old turn left the history (session_meta_updated with historyInvalidated), useAdeThread re-reads the history, so the message shows once. canRetry is false on an SDK before 0.4. See Retry and edit for the providers and errors.

Reasoning effort

ThreadState.update(patch) changes the title, the reasoning effort or fast mode of the open thread (thread.update on @ade-dev/sdk 0.3 and later). canUpdate is false on a client whose thread has no update. Use it with the built-in effort control:
A reasoning change while a turn runs is refused with the code turn_in_flight (SDK 0.4).

Runtime restarts

When the client reports a successful runtime restart (client.on("restart") with ok: true, from autoRestart), useAdeThread re-opens the thread and re-reads its history. The old rows stay on screen until the new history replaces them. You do not need to remount the chat. adaptSdkClient forwards on when the SDK client (or the Electron renderer client) has it.

Approvals

A provider that asks for permission parks its turn until someone answers. <AdeChat> draws a card for every approval_request and answers it through thread.approve(), so a blocked conversation looks blocked rather than broken. The card renders inline in the transcript, at the position of the request, not as a modal — the reader has to see what the agent was doing when it asked. It shows the description, and for a command request the command itself in a monospace block. Three buttons: Once answered, the card stays on screen in a settled state. A card that disappears is indistinguishable from one that was never answered. A turn that ends with the request unanswered marks it expired. The card is keyboard reachable and never takes focus from the composer.
Pass approvals.render to replace the card entirely; it receives the request and a respond(decision) callback. Two cases render read-only rather than offering buttons:
  • A requestKind of question, structured_question, plan_approval or model_selection. These want prose or a choice this surface cannot carry, and Allow would send an answer that means nothing to the model.
  • A thread with no approve. An older SDK, a proxy, or a fake. The card says the host cannot answer approvals instead of throwing.
After a reload, <AdeChat> calls thread.pendingApprovals() once on open and restores a card for every outstanding request the transcript no longer carries. A restored card sorts into the transcript at the instant it was read, not at the end, so it sits where the conversation actually is. A restored card is live by construction. It came from the engine’s authoritative “still blocked right now” list, read after the history window, so nothing in that history can expire it — an ending recorded before the request was even restored says only that some earlier turn finished. Only an explicit pending_input_resolved settles it. Drawing a genuinely blocked request as expired is a hang: the buttons go read-only while the runtime still waits for the answer. A turn ends a card when done arrives, or a status of completed, failed or interrupted. An error never does. An error does not end a turn on either layer — OpenCode emits one per failing tool and keeps streaming, and Codex emits one to decline a single request — so treating it as an ending would expire a live card and leave the provider blocked.

Composer rail and attachments

<AdeChat> passes four props through to its <Composer>: The merge rule for controlled attachments. onAttachmentsChange always receives the full new list. Replace your state with it. Do not append it to your state, or each file stages twice in your own state. When the file picker from onRequestAttachment returns, the composer merges its result with the latest list, not with the list from when the picker opened. So a file that the host staged while the picker was open (a drop, for example) is kept. The composer removes duplicates by id, and a remove click removes one id. Every attachment is sent with a type: "image" when the MIME type is image/* or the extension is an image extension (bmp, gif, heic, heif, ico, jpeg, jpg, png, svg, tif, tiff, webp), and "file" for everything else. This is the same rule the runtime uses. An image then reaches the model as an image when the provider accepts that format; see Attachments for the limits. Set hydrate: false on an attachment to send only its path, so that the runtime never reads the bytes. See Attachments. Submitting during a running turn is a steer, and staged attachments go with the steer. A steer with only attachments and no text is allowed.

Long transcripts

<AdeChat> loads history one page at a time when the thread supports historyPage (SDK 0.3 and later). It loads the newest page on open and older pages when the reader scrolls to the top or clicks Load older messages. historyPageSize sets the page size (default 200 events). Rows are built incrementally: a streamed event changes only the rows it touches. Above windowThreshold rows (default 150) the transcript mounts only the rows in view plus overscan rows on each side (default 8). With no ResizeObserver (jsdom, server rendering) every row renders. A row that scrolls out of the window unmounts. An expanded reasoning block or tool chip is collapsed again when it scrolls back in.

Tool results

Tool chips carry identity: { server, tool }, parsed from the event or from the tool name. Every spelling of an MCP tool (mcp__versic__search, versic:search, mcp:versic:search) gives the same identity. parseToolIdentity(name) is exported if you need the same parse. Two slots let a host act on a result:
row.resourceLinks holds the MCP resource_link items of the result, when the provider passes them through. See MCP for which providers do. Prefer a resource_link first. It is the one source that carries a URL your own tool produced, so use it when the tool returns one:
When the tool returns only ids, build the action yourself. Most host MCP tools return { projectId, trackId } rather than a link, and emit no resource_link. Parse your own result shape and construct the action from the id. Never let the model write the link: a model can invent a URL for the wrong environment, and it has.
Write one toolChipActions that tries row.resourceLinks first and falls back to your parse, so a tool that starts returning links keeps working. row.result is the tool’s result as the provider reported it. Check row.status first: a running chip has no result.

Activity labels

Tool names stay in the agent transcript. Your users should see a sentence.
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…”. A key can use the same tool names as a permission policy. mcp:versic:*, mcp:versic:search, versic:search, mcp__versic__search and search all match the tool search of the server versic. You list each tool once. Some providers report an MCP tool by its bare name (search). A server-qualified key (mcp:versic:*) matches that tool when the event names its MCP server, which the runtime does for Claude and Codex (ToolChipRow.identity). A bare name with no MCP server on the event matches only a bare key. matchLabelKey(keys, name, identity?) takes the identity as its third argument for your own lookups. The matcher never invents a server from the name, because a bare name is not evidence of who owns it. 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 family when your injected tools carry a prefix:
versic_* matches versic_projects, bare or as mcp__versic__versic_projects. It does not match a bare search belonging to the versic server — that name starts with search — so only * or an exact search key reaches that one.

Theming

CSS custom properties only. No Tailwind, no class overrides. createTheme({ accent, background, foreground, muted, danger, success, radius, fontFamily, monoFontFamily, fontSize, space, scheme }) returns the full token set. Light/dark is inferred from background luminance unless you set scheme. createTheme({ background: "transparent" }) lets the host window show through. Pass scheme with it, because a transparent background has no luminance to read. Pass disableStyles if you inject the stylesheet yourself. Under a strict CSP with no 'unsafe-inline' in style-src, pass a nonce: <AdeChat styleNonce={nonce} />, or injectAdeChatStyles(document, { nonce }).

Standalone components

Every piece is importable without <AdeChat>: Composer rule: submitting during a running turn dispatches onSteer, never a second onSend. A failed send restores the draft.

Provider cards

<ProviderCard> reads ProviderStatus.source before it says anything about the machine.
  • source: "probed" — a runtime looked at the filesystem. installed: false renders “Not installed”, and binaryPath and version are real.
  • source: "derived" — nobody looked; installed is “ADE knows models for this provider”. The card renders “Not detected” instead, because telling someone to install a CLI they already have is worse than saying you are not sure.
<ProviderCards> shows only providers that need attention by default. Pass onlyNeedsAttention={false} to list every provider, for example in a settings page: a signed-in provider renders as one line (status dot, name, “Ready”) with no command. showDetail adds a line with the probed version and a truncated binary path. It is off by default, and the basename is never cut — ”…/claude” identifies the binary where a head-first truncation would not. commandHints is now an override, not the source. A probing runtime already supplies installCommand, loginCommand and docsUrl; use hints to change ADE’s wording, not to supply it. A host that set them in 0.1.x sees exactly the copy it had.

Next

Runtime

What the sidecar actually is, and what doctor() checks.