# Customizing `<MateChat>`

Five ways to make the embed yours, easiest → deepest. Reach for the first one
that does the job — most apps never go past level 1 or 2.

| # | Lever | Effort | Use when |
|---|-------|--------|----------|
| 1 | **Theme tokens** (CSS variables) | seconds | "make it match my colors/font/radius" |
| 2 | **`classNames`** (slot classes) | minutes | "tweak the spacing/look of one part" |
| 3 | **`components`** (swap a piece) | minutes | "render tool calls / approvals my own way" |
| 4 | **`useMate` headless** | an afternoon | "I want a completely custom layout" |
| 5 | **Own the code** (`shadcn add`) | minutes | "I want the source in my repo to edit freely" |

---

## 1. Theme tokens — it already inherits your app

`<MateChat>` is a **chameleon**: its visual tokens read your app's
shadcn/Tailwind-v4 variables first, and fall back to the m8tes palette when a
variable is absent. **In a shadcn app the structural surfaces match for free** —
it picks up your `--background`, `--foreground`, `--muted`, `--border`,
`--primary` (buttons, user bubble, **and links/selected state**), `--ring`,
`--radius`, and `--font-sans` automatically. The **accent legend** (cyan = agent activity/info, green/amber = tool state) is intentionally fixed and not your brand;
set those explicitly if you want them to match (see the table).

> **Dark mode** works under a `.dark` ancestor (the shadcn convention) — see the
> recipe below. **On Tailwind v3** (HSL channel triplets, not full colors) add a
> token bridge — see "shadcn v3" below.

To override the host tokens (including in a non-shadcn app), set them on
`:root` or a wrapper around the chat:

```css
.my-agent-panel {
  /* the host token is read first; set it and the embed follows */
  --primary: oklch(0.55 0.2 264);      /* your brand color (buttons, user bubble) */
  --ring: oklch(0.55 0.2 264);         /* focus ring */
  --radius: 0.75rem;                   /* roundness */
  --font-sans: "Inter", sans-serif;    /* the embed inherits your font */
}
```

You can also target the embed's own `--m8-*` tokens directly when you want it to
differ from the rest of your app. Set them on `.m8tes-chat` or `.m8tes-widget`
in CSS loaded after the package stylesheet, or on `<MateChat style={{...}}>`.
A `--m8-*` value on an ancestor is overridden by the component's own defaults.
For tokens redefined in dark mode, match `.dark .m8tes-chat` or
`.dark .m8tes-widget` as well.

```css
.m8tes-chat {
  --m8-cyan: oklch(0.6 0.13 195);   /* agent activity and information */
  --m8-radius: 1rem;
}
```

**Full token surface** (each is `var(--your-token, <m8tes fallback>)`):

| `--m8-*` token | Reads host token | Role |
|---|---|---|
| `--m8-bg` / `--m8-fg` | `--background` / `--foreground` | panel surface + text ✅ standard shadcn |
| `--m8-muted` / `--m8-muted-fg` | `--muted` / `--muted-foreground` | assistant bubble, secondary text ✅ standard |
| `--m8-border` | `--border` | hairlines, card borders ✅ standard |
| `--m8-primary` / `--m8-primary-fg` | `--primary` / `--primary-foreground` | buttons, user bubble ✅ standard |
| `--m8-blue` | `--accent-blue` → **`--primary`** | interactive accent (links, focus tint, selected) — **follows your brand by default** |
| `--m8-purple` | `--m8-cyan` | Legacy alias, retained for existing theme overrides |
| `--m8-cyan` | `--accent-cyan` | info accent — legend, set to match |
| `--m8-success` / `--m8-warning` | `--success` / `--warning` | tool-state accents — set to match |
| `--m8-destructive` | `--destructive` | stop button ✅ standard |
| `--m8-ring` | `--ring` | focus ring ✅ standard |
| `--m8-conviction` | `--conviction` | gold presence dot when a human decision is needed |
| `--m8-radius` | `--radius` | corner radius ✅ standard |
| `--m8-msg-max-width` | _(none — set directly)_ | max width of a message body (default `88%`) |
| `--m8-mono` | `--font-mono` | code / tool I/O |
| `--m8-bevel` | `--btn-bevel` | button bevel shadow |

> ✅ = a standard shadcn token, so it adapts with zero config. The unmarked
> accent-legend tokens are m8tes-specific; set them if you want them on-brand.

> Import the stylesheet once, anywhere: `import "@m8tes/react/styles.css";`

### Accessibility (motion & transparency)

The embed stylesheet mirrors the platform's fluid-interface accessibility rules:

- **`prefers-reduced-motion: reduce`** — looping animations stop; buttons keep a
  short opacity cross-fade instead of zero feedback.
- **Thread scroll** — `.m8tes-thread` uses edge-fade masks so content does not
  clip harshly under the composer chrome.

Host apps using Motion or custom animations should wrap the chat in
`MotionConfig reducedMotion="user"` in the app root route (`routes/__root.tsx`).

### Dark mode

Works out of the box: under a `.dark` ancestor (the shadcn convention) the
surface/text/border/primary tokens follow your dark theme, and the embed
re-tunes the onyx avatar, button bevel, and shadows so nothing looks broken on
dark. No config needed. If your app toggles dark a different way (a `data-theme`
attribute, a different class), scope the overrides to your selector:

```css
[data-theme="dark"] .m8tes-chat { /* mirror the .dark re-tune from styles.css */ }
```

### shadcn v3 (HSL channel triplets)

Tailwind/shadcn **v4** ships full-color tokens (`--background: oklch(...)`), which
the chameleon uses directly. Older **v3** themes store HSL *channels*
(`--background: 0 0% 100%`) meant to be wrapped `hsl(var(--background))`. There,
the raw token isn't a valid color and surfaces break. Load this bridge after the
package stylesheet; it covers both components and their dark-mode overrides:

```css
.m8tes-chat,
.m8tes-widget,
.dark .m8tes-chat,
.dark .m8tes-widget {
  --m8-bg: hsl(var(--background));
  --m8-fg: hsl(var(--foreground));
  --m8-muted: hsl(var(--muted));
  --m8-muted-fg: hsl(var(--muted-foreground));
  --m8-border: hsl(var(--border));
  --m8-primary: hsl(var(--primary));
  --m8-primary-fg: hsl(var(--primary-foreground));
  --m8-blue: hsl(var(--primary));
  --m8-ring: hsl(var(--ring));
  --m8-destructive: hsl(var(--destructive));
}
```

---

## 2. `classNames` — restyle individual slots

Pass extra classes to specific parts. They **compose** with the defaults (the
`m8tes-*` classes stay), so you add on top rather than replace.

```tsx
<MateChat
  classNames={{
    root: "rounded-2xl border shadow-lg",
    thread: "px-6",
    assistantBubble: "bg-slate-50",
    composer: "border-t-2",
  }}
/>
```

Slots: `root`, `statusBar`, `thread`, `composer`, `message`, `userBubble`,
`assistantBubble`, `files`. (`className` on the component is an alias for `root`.)

You also get `header`, `greeting`, `placeholder`, and `style` props for the easy wins:

```tsx
<MateChat
  greeting={<p>Ask me anything about your account.</p>}
  placeholder="Ask the billing agent…"
  header={<MyOwnHeaderBar />}
  style={{ "--m8-msg-max-width": "70%" } as React.CSSProperties}
/>
```

Two things to know:
- **`header` replaces the status bar** (it doesn't sit above it). If you want a
  title *and* the streaming "Working…/Connecting…" indicator, render your title
  next to the exported `<MateStatusBar status={...} streaming={...} />` inside
  your own `header`.
- **A clickable "suggested prompts" empty state** needs to call `send`, which
  `greeting` (a static node) can't reach — drop to the headless `useMate` (level
  4) and render your own empty state with buttons wired to `send`.

---

## 3. `components` — swap any rendered piece

Override the parts that benefit most from product-specific rendering. Anything
you don't pass keeps the default. Each override gets the same props the default
receives, so you can wrap-and-extend instead of starting from scratch.

```tsx
import { MateChat, ToolCall } from "@m8tes/react";

<MateChat
  components={{
    // your brand avatar instead of the onyx default
    Avatar: () => <img src="/agent.png" alt="" className="size-7 rounded-full" />,

    // render tool calls your way (or wrap the default)
    ToolCall: ({ part }) =>
      part.toolName === "WebSearch"
        ? <MySearchCard query={part.input} />
        : <ToolCall part={part} />,

    // a fully custom approval UI — just call onDecision with the verdict
    ApprovalCard: ({ approval, onDecision }) => (
      <MyConfirmDialog
        title={`Run ${approval.toolName}?`}
        payload={approval.toolInput}
        onConfirm={() => onDecision("allow")}
        onCancel={() => onDecision("deny")}
      />
    ),

    // bring your own markdown renderer
    Markdown: ({ children }) => <MyMarkdown source={children} />,
  }}
/>
```

Overridable: `Avatar`, `Markdown`, `ToolCall`, `ApprovalCard`, `QuestionCard`,
`PlanCard`, `LimitCard`, `FileCard`. The defaults are all exported
(`AgentAvatar`, `Markdown`, `ToolCall`, `ApprovalCard`, `QuestionCard`,
`PlanCard`, `LimitCard`, `FileCard`, plus `ToolGroup`, `TodoList`,
`ThinkingSection`, `SelectedAnswer`, `FilesRow`, `RetryBar`) so you can compose
with them.
Tool rows use the same verb language as the m8tes app ("Ran code · ls -la") —
reuse it in your own `ToolCall` via `getToolResultLabel` / `getToolStreamingLabel`
/ `getToolDescription` from the package.

---

## 4. `useMate` — headless, build your own UI

When you want a completely custom layout, skip `<MateChat>` and drive the hook
directly. It returns the full conversation state + actions; you render whatever
you like. `<MateChat>` itself is just a thin renderer over this.

```tsx
import { useMate } from "@m8tes/react";

function MyChat() {
  const { messages, status, pendingApproval, send, approve, stop } = useMate();
  return (
    <>
      {messages.map((m) => <MyBubble key={m.id} message={m} />)}
      {pendingApproval && <MyApproval req={pendingApproval} onAllow={() => approve("allow")} />}
      <MyComposer onSend={send} busy={status === "running"} onStop={stop} />
    </>
  );
}
```

The exported building blocks (`MateThread`, `MateComposer`, `MateStatusBar`,
`ApprovalCard`, `QuestionCard`, `ToolCall`, `AgentAvatar`) are also usable à la
carte — assemble your own panel from the ones you want and hand-roll the rest.

---

## 5. Own the code — `npx shadcn add`

The deepest customization: copy the component **source** into your repo and edit
it like any other file you own (the assistant-ui / shadcn model). Keep the
[server proxy](https://www.m8tes.ai/docs/embed-a-ui) and provider configured as usual.

```bash
npx shadcn@latest add https://www.m8tes.ai/r/mate-chat.json
```

This drops `mate-chat.tsx` + `styles.css` into your `components/` and adds
`@m8tes/react` (the headless client + hook) as a dependency. From there it's
your code — change anything.

---

## API reference

### `<MateChat>` props

```ts
interface MateChatProps {
  agentId?: string | number; // omit to use/auto-provision a default agent
  teammateId?: string | number; // deprecated alias for agentId
  runId?: string | number; // controlled: rejoin a specific run (cross-refresh)
  onRunIdChange?: (runId: number) => void; // save the conversation id
  initialMessage?: string; // send once when ready; waits for a controlled join
  greeting?: ReactNode;  // defaults to "What can I help you with?"; false hides it
  placeholder?: string;  // composer placeholder
  header?: ReactNode;    // REPLACES the status bar
  className?: string;    // alias for classNames.root
  style?: CSSProperties; // inline styles on the root (handy for --m8-* vars)
  classNames?: { root?, statusBar?, thread?, composer?, message?, userBubble?, assistantBubble?, files? };
  components?: { Avatar?, Markdown?, ToolCall?, ApprovalCard?, QuestionCard?, PlanCard?, LimitCard?, FileCard? };
}
```

### `useMate({ agentId?, runId? })` returns

```ts
{
  // conversation state
  messages: MateMessage[];          // ordered user/assistant turns (parts: text | tool | reasoning)
  status: "idle" | "connecting" | "running" | "awaiting_approval"
        | "awaiting_input" | "complete" | "incomplete" | "error" | "cancelled";
  isStreaming: boolean;
  isStopping: boolean;             // waiting for cancellation acknowledgement
  stopError: string | null;        // cancellation failed; run may still be active
  connectionError: string | null; // disconnected, run may still be active
  files: RunFile[];
  agentName: string | null;
  runId: number | null;
  runUuid: string | null;
  pendingApproval: ApprovalRequestEvent | null;  // set when a tool needs approval
  pendingQuestion: QuestionEvent | null;          // set on an AskUserQuestion
  error: { message: string; code: string | null } | null;
  // actions
  send: (message: string) => void;                // start a run, or reply once idle
  approve: (decision: "allow" | "deny", opts?: { remember?: boolean }) => Promise<ApproveResult | undefined>;
  answer: (answers: Record<string, string>) => Promise<void>; // rejects if not applied
  stop: () => void;                                // cancel the active run
  resume: () => void;                              // rejoin without repeating work
  fileDownloadUrl: (filename: string) => string | null;
  retry: () => void;                               // re-send the last message after a failed/stopped run
  canRetry: boolean;                               // whether retry() would do anything (gate your retry UI on this)
}
```

Must be rendered inside `<M8tesProvider>`. Two instances are independent.

### Stopping a run

`stop()` requests cancellation. Disable further actions while `isStopping` is true.
The hook reports `cancelled` only after the server acknowledges the request. If
cancellation fails, `stopError` explains the failure and the hook rejoins the run
so you can see what is still happening. Render that error and let the user call
`stop()` again. Clear your stopping UI when `isStopping` becomes false; a run
that finishes while cancellation is pending can report its terminal result instead.
A stop requested before metadata waits for the run id to arrive.

### Retrying a failed or stopped run

A failed, incomplete, or cancelled run ends the turn. `<MateChat>` shows a quiet
retry row (the exported `RetryBar`) at that point; `retry()` re-sends the last
user message — a reply on the same run, so the agent keeps the context of what it
already did, without duplicating the message in the thread. Build your own
affordance by gating on **`canRetry`**, not on `status` alone: `retry()` replays
the message the browser optimistically added when you called `send()`, so after a
cross-refresh rejoin (`useMate({ runId })`) there is nothing to re-send and
`canRetry` is `false`.

```tsx
const { status, retry, canRetry, isStreaming } = useMate();
{canRetry && (
  <button onClick={retry} disabled={isStreaming}>
    {isStreaming ? "Retrying…" : "Retry"}
  </button>
)}
```

### Recovering a disconnected stream

A connection error does not mean the server stopped working. Show a reconnect
button using `connectionError` and `resume()`, rather than retrying the message:

```tsx
const { connectionError, resume, isStreaming } = useMate();
{connectionError && (
  <div role="status">
    {connectionError}
    <button onClick={resume} disabled={isStreaming}>Reconnect</button>
  </div>
)}
```

`MateWidget` accepts the same agent/run IDs, `onRunIdChange`, component overrides,
class slots, placeholder, and greeting. It preserves its chat while closed or on
its home view. Reset it with a new React `key` when starting fresh or switching
signed-in users. Starter prompts are offered before the conversation is opened.
