> ## Documentation Index
> Fetch the complete documentation index at: https://ade-app.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Chat UI

> Embeddable React chat for @ade-dev/sdk: AdeChat, Composer, Transcript, ModelPicker, link handling, CSS-token theming, and activity labels.

`@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.

```bash theme={null}
npm install @ade-dev/chat-ui
```

React and React DOM are peers (`^18` or `^19`). `@ade-dev/sdk` is an optional peer used for types only.

```tsx theme={null}
import { AdeChat, createTheme, adaptSdkClient } from "@ade-dev/chat-ui";

<AdeChat
  client={adaptSdkClient(ade)}
  threadKey="support-42"
  labels={{ map: { "server.*": "Looking that up…" } }}
  theme={createTheme({ accent: "#7c5cff", background: "#0e0f13" })}
/>;
```

`<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.

## Links

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:

```tsx theme={null}
<AdeChat
  client={client}
  threadKey="main"
  onLinkClick={(href, { source, text }) => openInBrowser(href)}
/>
```

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.

> [!WARNING]
>
> 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](https://ade-app.dev/docs/sdk/electron.md#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):

| Prop | Effect |
| - | - |
| `value` / `onValueChange` | Controlled draft. Fill the composer from outside, for example from a prompt card. |
| `onSend(input, thread)` | Runs when the person sends a new message, before it reaches the thread. Return `false` to cancel: nothing is sent, and the composer keeps the draft and the staged files (a confirmation or a validation step). Return `"handled"` when you sent the message yourself (with `thread.send`): nothing more is sent, and the composer stays cleared. Any other result sends as usual. A throw shows as a failed send and restores the draft. A steer during a running turn does not call it. |
| `children(thread)` | Render prop drawn between the transcript and the composer, with the live `ThreadState`. Return `null` to draw nothing. |
| `threadRef` | An object whose `current` receives the live `ThreadState` on every render, for code outside the tree (a toolbar button). |

```tsx theme={null}
const threadRef = useRef<ThreadState | null>(null);

<AdeChat
  client={client}
  threadKey={key}
  value={draft}
  onValueChange={setDraft}
  threadRef={threadRef}
  onSend={async (input, thread) => {
    if (thread.rows.length === 0) await thread.update({ title: titleFrom(input.text) });
  }}
>
  {(thread) => (thread.rows.length === 0 ? <PromptCards onPick={setDraft} /> : null)}
</AdeChat>;
```

`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](https://ade-app.dev/docs/sdk/threads.md#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:

```tsx theme={null}
<AdeChat
  client={client}
  threadKey={key}
  threadRef={threadRef}
  reasoningEffort={effort}
  onReasoningEffortChange={async (next) => {
    await threadRef.current?.update({ reasoningEffort: next });   // throws turn_in_flight mid-turn
    setEffort(next);                                               // show it only once the thread took it
  }}
/>;
```

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:

| Button | Decision | Meaning |
| - | - | - |
| Allow once | `accept` | this call only |
| Always allow | `accept_always` | stop asking for this for the rest of the session |
| Reject | `reject` | refuse, and let the model hear why |

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.

```tsx theme={null}
<AdeChat
  client={adaptSdkClient(ade)}
  threadKey="assistant"
  approvals={{
    labels: {
      title: (request) => `Let the assistant ${request.description.toLowerCase()}?`,
      accept: "Just this once",
      acceptAlways: "Always",
      reject: "No",
    },
  }}
/>;
```

Pass `approvals.render` to replace the card entirely; it receives the request and a `respond(decision)` callback.

An approval is read-only when the thread does not expose `approve`; the card explains that the host cannot answer it instead of showing buttons that throw. Questions, structured questions and plan approvals use a separate input card and `thread.respond(requestId, response)`, where `response` has the runtime's `answers`, `decision` and `responseText` fields. It stays read-only when the client has no `respond` method. Model-selection requests remain read-only here.

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.

## Questions and plan review

Question cards render each question, its choices and any allowed freeform answer. Multi-select questions return a `string[]`; answers are keyed by question id. Secret questions use a password field. Submitting keeps the card in place, and a failed response leaves it available to retry. Plan review sends `decision: "accept"` to approve or `decision: "decline"` to keep planning; it does not infer decisions from option labels. Unstructured freeform responses use `responseText`.

```ts theme={null}
await thread.respond(requestId, { answers: { project_scope: ["UI", "tests"], timeline: "One week" } });
```

## Queued messages and usage limits

When a thread exposes `queue.list()`, `<AdeChat>` shows its queued messages with the operations the client supports: edit, reorder, cancel, send now and cancel a dispatched send. Failed reads or actions show an error with a retry where the operation can be repeated. Clients without a queue stay usable and do not show an empty placeholder.

Usage-limit events are scoped by `threadKey`; `<AdeChat>` ignores events for another thread. When the thread has resume controls, the notice offers **Resume now** and **Continue on** the alternate account it reports. Failed actions remain visible on the notice.

## Accounts components

The standalone account components render non-secret account summaries and usage only. `<SignInCard>` supports browser links, device codes and pasted codes from an optional `SignInHandle`; `<ApiKeyForm>` clears the entered key after save and never prints it in an error. Account and credential operations are optional. A missing operation hides its control, and account-list load failures show a retry instead of a false empty state.

## Composer rail and attachments

`<AdeChat>` passes four props through to its `<Composer>`:

| Prop | Effect |
| - | - |
| `modelRail` | Replaces the built-in model picker in the left rail. `null` leaves the rail empty. |
| `actions` | Extra controls in the right rail, for example a status pill. |
| `attachments` / `onAttachmentsChange` | Controlled staging, so the host can add files from drag and drop or paste. |

**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](https://ade-app.dev/docs/sdk/threads.md#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](https://ade-app.dev/docs/sdk/threads.md#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:

```tsx theme={null}
<AdeChat
  client={client}
  threadKey="main"
  toolChipActions={(row) =>
    row.identity?.server === "versic"
      ? (row.resourceLinks ?? []).map((link) => ({
          label: link.title ?? "Open in Versic",
          onSelect: () => openInVersic(link.uri),
        }))
      : []
  }
  renderToolResult={(row) => null /* or your own result view */}
/>;
```

`row.resourceLinks` holds the MCP `resource_link` items of the result, when the provider passes them through. See [MCP](https://ade-app.dev/docs/sdk/mcp.md#resource-links) 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:

```tsx theme={null}
toolChipActions={(row) => {
  if (row.status !== "completed") return [];
  const links = row.resourceLinks ?? [];
  if (links.length > 0) {
    return links.map((link) => ({
      label: link.title ?? "Open",
      onSelect: () => open(link.uri),
    }));
  }
  // Fall through to your own parse of the result, below.
  return [];
}}
```

**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.

```tsx theme={null}
toolChipActions={(row) => {
  if (row.identity?.server !== "versic" || row.status !== "completed") return [];
  const result = parseToolResult(row.result);        // your own parse of the tool's JSON
  if (!result?.projectId) return [];
  return [{ label: "Open project", onSelect: () => openProject(result.projectId) }];
}}
```

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.

```ts theme={null}
labels={{
  map: {
    "server.tool": { running: "Searching…", done: "Searched", error: "Search failed" },
    "server.*": "Talking to your account…",
    "*": "Working…",
  },
  resolve: (source) => source.tool === "x" ? "Custom" : null,
  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…".

A key can use the same tool names as a [permission policy](https://ade-app.dev/docs/sdk/permissions.md). `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:

```ts theme={null}
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`, 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`.

| Token | Role |
| - | - |
| `--adechat-bg` / `--adechat-fg` | Surface and text |
| `--adechat-accent` | Send button, selection, links |
| `--adechat-muted` | Secondary text |
| `--adechat-danger` / `--adechat-success` | Errors and completed chips |
| `--adechat-radius` / `--adechat-font` | Shape and type |
| `--adechat-root-bg` | The background of the chat root. `transparent` when you pass `background: "transparent"`, else `var(--adechat-bg)` |

`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>`:

| Component | Role |
| - | - |
| `<Composer>` | Draft, send / steer, stop, attachments. Enter sends by default (Shift+Enter is a newline). `sendOnEnter={false}` inverts that: Enter is a newline and Cmd/Ctrl+Enter submits. |
| `<Transcript>` | Rows, tool chips, reasoning, empty state. |
| `<ModelPicker>` | Grouped catalog; disabled when the provider is not authenticated. Shows a reasoning-effort control when you pass `onReasoningEffortChange` and the model lists `reasoningEfforts`. The host applies the effort (for example with `thread.update`). |
| `<ProviderCard>` / `<ProviderCards>` | Install / login copy for providers that need attention. With `onlyNeedsAttention={false}`, also a one-line "Ready" card for each signed-in provider. |
| `<ApprovalCard>` | One inline approval, with Allow once / Always allow / Reject. |
| `<PendingInputCard>` | A question, structured question or plan review with `thread.respond`. |
| `<QueueStrip>` | A thread's queued messages and supported queue actions. |
| `<UsageLimitNotice>` | Resume and alternate-account controls for a usage limit. |
| `<AccountList>` / `<AccountSwitcher>` | Account summaries and a per-thread account switcher. |
| `<SignInCard>` / `<ApiKeyForm>` | Browser/device sign-in and API-key entry. |
| `<UsageMeter>` | A non-secret usage summary and progress bar. |

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.

```tsx theme={null}
<ProviderCards
  client={client}
  showDetail
  onlyNeedsAttention={false}
/>;
```

## Next

> [card] [Runtime](https://ade-app.dev/docs/sdk/runtime.md)
>
> What the sidecar actually is, and what `doctor()` checks.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.