
# Chat widget

Agentak has two levels of chat surface:

- **`ChatPanel` / `AgentChat`** receives a `ChatSession` and wires every available feature
  to it. Use this for Pi or another live agent.
- **`ChatView` / `Chat`** receives messages, state, and callbacks directly. Use this when
  your application already owns the full chat state.

The framework wrappers render one host element, inject Agentak's design tokens, and mount
the Preact surface inside it. Give the host a height.

## React

```tsx
import { useEffect, useMemo } from "react";
import { ChatPanel } from "agentak/react";
import { createPiSession } from "agentak/pi";

export function Assistant() {
  const session = useMemo(() => createPiSession({ history: true, page: true }), []);

  useEffect(() => () => session.dispose(), [session]);

  return <ChatPanel session={session} style={{ height: "600px" }} />;
}
```

## Vue

```vue
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
import { ChatPanel } from "agentak/vue";
import { createPiSession } from "agentak/pi";

const session = createPiSession();
onBeforeUnmount(() => session.dispose());
</script>

<template>
  <ChatPanel :session="session" class="h-[600px]" />
</template>
```

## Preact

```tsx
import { render } from "preact";
import { ChatPanel } from "agentak/preact";
import { createPiSession } from "agentak/pi";

const session = createPiSession();

render(
  <ChatPanel session={session} style={{ height: "600px" }} />,
  document.querySelector("#app")!,
);

// Call session.dispose() when this app unmounts.
```

## Plain JavaScript

```ts
import { mountChat } from "agentak";
import { createPiSession } from "agentak/pi";

const session = createPiSession();
const chat = mountChat("#chat", { session });

chat.update({ session, generateTitle: true });

// Later:
chat.unmount();
session.dispose();
```

`mountChat(target, props)` accepts an element or a selector. It makes the target a flex
container, injects tokens unless `tokens: false`, and returns:

- `update(props)` to apply new props without losing the live transcript
- `unmount()` to clear the target

Neither method disposes the session.

## `ChatPanel` props

The React, Vue, and Preact wrappers share these behavior props:

| Prop                  | Type              | Description                                                           |
| --------------------- | ----------------- | --------------------------------------------------------------------- |
| `session`             | `ChatSession`     | Runs the conversation. Required.                                      |
| `generateTitle`       | `boolean`         | Uses one extra model request to name the conversation.                |
| `autoCompact`         | `boolean`         | Set to `false` to compact only when a person asks. On by default.     |
| `tokens`              | `boolean`         | Set to `false` when the host already declares Agentak tokens.         |
| `actions`             | `ChatAction[]`    | Adds host controls to the end of the bar under the composer.          |
| `emptyItems`          | `ChatEmptyItem[]` | Adds content below the greeting before the first message.             |
| `prompts`             | `ChatPrompt[]`    | Adds starter messages under the composer of an empty chat.            |
| `linkBase`            | `string`          | Base URL for relative links when the chat discusses another document. |
| `autoFocus`           | `boolean`         | Focuses the composer on mount on non-touch devices.                   |
| `class` / `className` | `string`          | Styles the wrapper element.                                           |
| `style`               | style object      | Styles the wrapper element. Use it to set the size.                   |

Use `class` with Vue and `className` with React or Preact. The chat itself uses Preact in
every integration, but no host ever passes it a Preact node: `actions` and `emptyItems` are
plain objects, so the same code works from Vue, React, Preact and plain JavaScript.

The chat has two rows of chrome. The title bar over the transcript shows the conversation
title first, then the two buttons that change which conversation it is: new conversation
and stored conversations, and last your `actions` at the corner. A settings or history page
replaces those two buttons with a back arrow in front of the title; your `actions` stay
where they are, because a page replaces the controls of the conversation and not the
chrome of the page around it.

The bar under the composer shows what is running, and reads from both ends. At the leading
edge: the model, which also opens the settings page, and then the tool gate — **Ask**
confirms tool calls before they run, **Bypass** lets them run, and one click swaps them.
At the trailing edge: the context meter. The gate button appears only
when the session has tools to gate; the Pi session opens on **Bypass** and keeps the choice
for that session alone. The composer between the two rows is a text field and a send
button: a pill while the message is one line, and the same corner around a box that grows
with the message, with the button at the foot of the field.

`actions` takes one object per button, drawn at the top right of the title bar — use it for
host chrome such as a collapse button. `label` is the accessible name and the tooltip;
`icon` is either a name from the built-in set or the path data of a glyph Agentak does not
ship; `text` puts words on the button. `variant`, `disabled` and `pressed` are the rest —
`pressed` makes it a switch. The chat sizes and styles the button itself, so host chrome
matches the buttons beside it:

```ts
const actions = [
  { id: "close", label: "Collapse", icon: "panel-right-close", onClick: () => hide() },
  { id: "docs", label: "Open the docs", icon: { paths: ["M4 4h16", "M4 12h16"] }, onClick: open },
];
```

`emptyItems` fills the empty state under the greeting, one object per item:
`{ kind: "text", text }` for a line of prose, `{ kind: "actions", actions }` for a row of the
same buttons, and `{ kind: "element", name, props }` for a renderer you registered by name
with `registerElements()` from `agentak/components`. That registry is the one place a host
renders its own component, and it is also how a transcript `{ kind: "element" }` part is
drawn.

`prompts` puts starter messages at the foot of the empty chat, in one row of buttons under
the recent conversations. A click sends the message, so a reader starts a conversation
without typing. Give a string when the
button and the message are the same words, or `{ label, prompt }` when the button is the
short of a longer message:

```tsx
<ChatPanel
  session={session}
  prompts={[
    "Summarize this page",
    { label: "Compare plans", prompt: "Compare the plans on this page in a table." },
  ]}
/>
```

The buttons show only before the first message and go with the greeting. A click made
before a provider is chosen still counts: the Pi session holds the message, opens the
settings page, and sends it once a model is selected.

`autoFocus` is off by default because an embedded widget must not take focus from the host
page. It is useful when the chat is the whole document, as in the Chrome side panel. It is
ignored on touch devices to avoid opening the keyboard over the surface.

Use `linkBase` when the answer is about a document other than the one that contains the
chat. For example, a side panel passes the active tab URL so `/settings` points at that
site instead of the extension.

A link in an answer that lands on your own site is answered in place: the chat calls
`history.pushState` and raises a `popstate`, which your router already listens on, so the
page changes and the conversation beside it stays. Links to another site open a new tab, a
`#fragment` of the open page scrolls as usual, and a click with Ctrl, Cmd, Shift or Alt is
left to the browser. A host without a client-side router therefore gets a URL change and
no page change.

## Use `AgentChat` without a wrapper

`AgentChat` is the session-driven Preact surface without a host element:

```tsx
import { render } from "preact";
import { AgentChat, injectTokens } from "agentak";
import { createPiSession } from "agentak/pi";

injectTokens();
const session = createPiSession();

render(
  <AgentChat
    session={session}
    style={{ height: "600px" }}
    actions={[{ id: "close", label: "Close", icon: "x", onClick: () => hide() }]}
    emptyItems={[{ kind: "text", text: "Ask me about this page." }]}
  />,
  document.querySelector("#app")!,
);
```

Call `injectTokens()` once, or provide the exported `tokens` text in your own stylesheet.
Agentak uses inline component styles and no shadow root. Host CSS can still affect
inherited styles.

## Control the transcript with `ChatView`

`ChatView` wraps the presentational `Chat` component. It needs no session:

```tsx
import { ChatView } from "agentak/react";

<ChatView
  messages={messages}
  isStreaming={streaming}
  onSend={send}
  onStop={stop}
  onReset={clear}
  onRetry={retry}
  style={{ height: "600px" }}
/>;
```

In Vue, callbacks are props rather than emitted events:

```vue
<ChatView
  :messages="messages"
  :is-streaming="streaming"
  :on-send="send"
  :on-stop="stop"
  :on-reset="clear"
  class="h-[600px]"
/>
```

`ChatView` accepts all `Chat` state and callbacks, plus wrapper `tokens`, `class`, and
`style`. Optional state controls optional features: omit usage to hide the context meter,
omit providers and models for a fixed-model chat, or omit history for no history page.

## Included interaction details

The assembled chat handles all of these without host code:

- streaming answers with separate reasoning, Markdown, syntax-highlighted code, images,
  and tool calls
- approval and denial controls for gated tools, including a denial reason sent back to
  the model, and a switch in the bar between confirming every call and bypassing the gate
- messages sent during a turn as a visible queue, with removal before they run
- stop, retry, dismissible errors, and provider failures that open settings when the
  provider, account, key, or model needs attention
- provider, model, API key, and thinking-level settings in a full-height page
- stored conversation history in a second page, without unmounting the chat, and the three
  newest conversations at the foot of an empty chat
- host-declared starter prompts at the foot of an empty chat, each sent with one click
- context tokens and accumulated cost details, with an amber near-limit warning, and a
  **Compact** button in the same panel where the session can summarize the conversation
- copy and read-aloud actions under finished text answers
- retry and fork actions under each user message, which rewind the conversation
- relative-link safety through `linkBase`
- `/model` and `/new` commands with keyboard selection and completion, and the agent's
  tools in the same list — picking one runs it, and the model answers from what it returned
- a growing composer that lifts above mobile virtual keyboards

Copy and speech use only the text parts of the answer. Thinking and tool calls are left
out. Read aloud converts Markdown to spoken text, selects a suitable browser voice, and
splits long answers into short utterances.

Retry and fork both rewind to the message you clicked. Retry stays in the conversation on
screen: it sends the message again and replaces the answer it got, together with
everything said after it. Fork starts a new conversation from the turns before that
message and puts the message back in the composer, so you can change a word and send it
again; the conversation you forked from is stored, not edited, exactly as the
new-conversation button stores it.

Each button appears only where the session supports it — `retryFrom` for retry, `fork` for
fork. Neither is the retry button in the error row, which runs a failed request again.

## Component library and custom transcript elements

`agentak/components` exports the chat parts, shadcn-style primitives, and Preact ports of
AI SDK Elements as named exports. Some exported components are building blocks that the
Pi transcript does not emit by default.

A custom transcript can carry `{ kind: "element", name, props }`. Register its renderer
once:

```tsx
import { registerElements } from "agentak/components";

registerElements({
  weather: ({ temperature }) => <strong>{String(temperature)}°</strong>,
});
```

Agentak registers `image`, `checkpoint` and `progress` itself because the Pi transcript
can emit them. An unknown element name renders nothing instead of breaking the transcript.

## Progress in a turn

A stream carries words, and a progress bar is not words. A `::progress{…}` line in the
text or thinking of a turn is read back out as a bar:

```
::progress{id="model-load" value="42" label="Loading Granite 4.1 3B, 2.0 GB"}
```

The line is the whole marker: `id`, `value`, `max` (100 by default) and `label`, quoted or
bare, with the closing brace ending it. A marker owns its line, so prose that mentions one
stays prose. Any other key is dropped. Without a `value` the bar is indeterminate and
sweeps.

A stream only appends, so a marker written again under the same `id` is the same bar,
updated in place: the fields it names change and the rest — usually the label, written
once — stay as they were. A download is therefore one marker per tick and one bar on
screen, not a trail of percentages. Half a marker at the end of a growing block is hidden
until its closing brace arrives.

A full bar has nothing left to report: it rests a second, fades, and leaves the turn. A
bar that is already full when the conversation is read back out of storage never appears
at all, so a finished download leaves no mark on the transcript.

Agentak's own local models use this for their one-time download. The words around the
markers are kept, so a turn can carry a bar and an answer. The `Progress` component behind
it is exported from `agentak/components` for a host that wants a bar of its own; there the
leaving is the `hideWhenDone` prop, and off by default.

## Session ownership

A session holds one live conversation at a time. It can replace that conversation in
place through history or `restore()`, so the widget stays mounted. A host can also replace
the session prop when it wants completely separate runtimes.

Whoever creates a session owns its cleanup. No wrapper calls `dispose()`.
