Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions console/web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
dist-ssr
.DS_Store
*.log
.vite
*.tsbuildinfo
238 changes: 238 additions & 0 deletions console/web/PLAYGROUND.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Playground & Backend Contract

This document is the source of truth for two things:

1. **The streaming contract** every `ChatBackend` honors. The chat surface
only knows about this contract, never about a specific provider — so the
internals can churn freely as long as the contract holds.
2. **The Playground page** that exercises the contract through a catalog of
scenarios (slow streams, errors, multi-tool runs, markdown stress, etc).

If you're swapping the mock for a real backend, this is the file to read
first. If a scenario fails after your refactor, the contract has drifted —
either fix the backend or update both the scenario and this doc together.

## Quickstart

The Playground (and the Examples spec sheet) ship behind a build-time flag.

```bash
# dev: flag is on by default (set in .env.development)
npm run dev
# open #/playground
```

In dev, the header has a `chat / playground / examples` toggle. Pick a
scenario from the left rail, send any message, and watch the right-hand
event log mirror every `StreamEvent` the backend yields.

## The streaming contract

Every backend implements:

```ts
export interface ChatBackend {
readonly id: string
stream(
prompt: string,
mode: Mode,
model: ModelId,
opts?: ChatStreamOptions,
): AsyncGenerator<StreamEvent>
}
```

The generator yields `StreamEvent`s in this taxonomy:

| event | payload | when |
|--------------------|-------------------------------------------|--------------------------------------------|
| `thought-start` | — | a thought block is opening |
| `thought-token` | `{ token: string }` | one chunk of the thought body |
| `thought-end` | `{ durationMs: number }` | the thought block has finished |
| `fcall-start` | `{ functionId, input, pendingApproval? }` | a function call begins (or awaits approval) |
| `fcall-end` | `{ output, durationMs }` | the function call resolved |
| `assistant-token` | `{ token: string }` | one chunk of the assistant body |
| `assistant-end` | — | the assistant body has finished |

### Ordering rules

1. A `thought-start` is always followed by zero or more `thought-token`
events and then exactly one `thought-end`. Backends never interleave a
thought with another phase.
2. An `fcall-start` is always paired with exactly one matching `fcall-end`.
Multiple `fcall-*` pairs may appear back-to-back; the consumer resets its
pointer between pairs (see `multi-tool-agent`).
3. `assistant-token`s may be empty or whitespace-only; the consumer treats
them as opaque appends.
4. `assistant-end` is the terminal event for that turn. After yielding it,
the generator returns.
5. A turn may legally contain *no* thought block, *no* function calls, or
*no* assistant body. The minimum legal turn is a single `assistant-end`
on an empty body.

### Abort semantics

The caller passes an `AbortSignal` via `opts.signal`. Backends MUST:

- Check `signal.aborted` between async waits and stop iterating early.
- Treat the signal as advisory: emitting a partial sequence is fine, the
consumer's `finally` cleans up streaming flags. No special "aborted"
event is required.
- Optionally throw a `DOMException('...', 'AbortError')` to signal that the
backend itself initiated the abort. The chat surface treats AbortError as
benign; any other thrown error is logged.

### Error semantics

There are two distinct shapes for failures:

- **Soft errors** (the call ran but didn't succeed) ride on `fcall-end`'s
`output` field. The convention is `{ error: { kind, message, ... } }`.
The `error-on-fcall` scenario asserts this. Backends should prefer this
shape over thrown exceptions for anything the user can act on.
- **Hard errors** (the stream itself broke) are thrown out of the generator.
The chat surface logs them and returns the surface to "ready" state.

## The seam

```mermaid
graph TD
ChatView["ChatView (UI)"]
Backend["ChatBackend interface"]
Mock["mockBackend (lib/backend/mock.ts)"]
Real["realBackend (lib/backend/real.ts) - stub today"]
Scenarios["scenarioBackend (pages/Playground/scenarios)"]

ChatView -->|consumes| Backend
Backend -.implements.- Mock
Backend -.implements.- Real
Backend -.implements.- Scenarios
```

The seam is `chat-app/src/lib/backend/`:

- [`types.ts`](src/lib/backend/types.ts) — the contract types: `StreamEvent`,
`ChatStreamOptions`, `ChatBackend`.
- [`mock.ts`](src/lib/backend/mock.ts) — three canned bodies, jittered token
delays, abort-aware sleeps. Imported only when `VITE_PLAYGROUND` is on.
- [`real.ts`](src/lib/backend/real.ts) — stub that throws
`'backend not configured'`. Replace its body with your provider; preserve
the `ChatBackend` shape and you're done.
- [`index.ts`](src/lib/backend/index.ts) — `getDefaultBackend()` picks one or
the other based on the build-time flag.

The chat page imports `getDefaultBackend()` once at module load and passes
it to `ChatView` as a prop. Nothing else in the app depends on the choice.

## Scenarios

Each scenario is a `ChatBackend` exported from
[`pages/Playground/scenarios/`](src/pages/Playground/scenarios/). The
registry in [`scenarios/index.ts`](src/pages/Playground/scenarios/index.ts)
groups them and exposes them to the picker.

| id | group | what it asserts |
|---------------------|---------------|------------------------------------------------------------------------------|
| `happy-plan` | happy paths | thought + assistant body, no function calls. |
| `happy-ask` | happy paths | assistant body only, no thought, no function calls. |
| `happy-agent` | happy paths | thought + one function call + assistant body. |
| `multi-tool-agent` | agent | three sequential `fcall-*` pairs — exercises pointer reset in `ChatView`. |
| `pending-approval` | agent | `pendingApproval: true` lifecycle: pending → running → done. |
| `abort-mid-thought` | failure modes | half a thought, then `throw new DOMException('...', 'AbortError')`. |
| `error-on-fcall` | failure modes | `fcall-end.output = { error: { kind: 'rate_limited' } }`. |
| `slow-tokens` | timing | ~200ms between assistant tokens — watch for cursor flicker. |
| `fast-tokens` | timing | ~5ms between assistant tokens — stresses the patch path. |
| `long-markdown` | markdown | ~4kB body: headings, lists, tables, fenced code in 3 langs. |
| `markdown-stress` | markdown | nested lists, footnotes, autolinks, hard breaks, busy GFM tables. |

This list is the regression suite. Wiring a real backend without breaking
any of these scenarios means the chat surface continues to render correctly.

## Flag plumbing

A single env var, `VITE_PLAYGROUND`, controls visibility:

| file | value | effect |
|---------------------|-----------|-----------------------------------------------------|
| `.env.development` | `1` | dev defaults: Playground + Examples + mock shipped. |
| `.env.production` | empty | prod defaults: pages and mock tree-shaken. |

The flag is consumed in three places:

1. [`src/App.tsx`](src/App.tsx) — `lazy()`-wraps the Playground and Examples
pages and only registers the routes when the flag is truthy.
2. [`src/hooks/use-hash-route.ts`](src/hooks/use-hash-route.ts) — `#/playground`
and `#/examples` resolve to `chat` when the flag is off, so old deep links
degrade gracefully.
3. [`src/lib/backend/index.ts`](src/lib/backend/index.ts) — `getDefaultBackend()`
returns the mock when the flag is on, otherwise the real backend stub.

Vite/Rolldown inlines `import.meta.env.VITE_PLAYGROUND` as a literal at
build time. The dead branch (and every transitive import) is then dropped
by tree-shaking.

### Verifying a prod build is clean

```bash
npm run build
# expect: a single index-*.js, no Playground-*.js or Examples-*.js chunks

# none of these strings should appear in dist/assets/*.js:
grep -E '"happy-(plan|ask|agent)"|"abort-mid-thought"|"long-markdown"' dist/assets/*.js && echo "LEAK" || echo "clean"
```

A flag-on build (`VITE_PLAYGROUND=1 npm run build`) emits separate
`Playground-*.js` and `Examples-*.js` chunks — that's the expected dev/staging
layout, not the production layout.

## Adding a new scenario

Three steps. Average size is 30–60 lines.

1. Create `src/pages/Playground/scenarios/<id>.ts`. Use the helpers from
[`scenarios/helpers.ts`](src/pages/Playground/scenarios/helpers.ts):

```ts
import { makeBackend, streamAssistant, streamThought } from './helpers'

export const myScenario = makeBackend(
'my-scenario',
async function* (_prompt, _mode, _model, opts) {
yield* streamThought('reasoning…', { signal: opts?.signal })
yield* streamAssistant('answer…', { signal: opts?.signal })
},
)
```

2. Register it in
[`scenarios/index.ts`](src/pages/Playground/scenarios/index.ts):

```ts
import { myScenario } from './my-scenario'

export const SCENARIOS: PlaygroundScenario[] = [
// ...
{
id: 'my-scenario',
label: 'my scenario',
description: 'one sentence about what this asserts.',
group: 'happy paths',
preferredMode: 'agent',
backend: myScenario,
},
]
```

3. Add a row to the table in [the Scenarios section](#scenarios) of this
doc. The table is the regression contract — keep it in sync.

## Out of scope

- **Implementing the real backend.** [`real.ts`](src/lib/backend/real.ts) is
a stub that throws. Replace its body when you wire your provider; respect
the contract and nothing else changes.
- **Persisting playground conversations.** They're ephemeral by design; the
`localStorage` path in [`lib/storage.ts`](src/lib/storage.ts) is reserved
for the real chat surface.
- **CI assertions on the prod bundle.** Documented above as a manual step;
not enforced automatically.
145 changes: 145 additions & 0 deletions console/web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# chat-app

A base scaffold for a chat surface, built with Vite + React + TypeScript +
Tailwind v4 and styled to the iii Schematic design system
(see [`../DESIGN.md`](../DESIGN.md) for the full spec).

It runs entirely client-side with mocked streaming, so there are no API keys
to configure. The mock — and an interactive Playground that exercises every
streaming edge case (errors, aborts, multi-tool runs, long markdown, …) —
ships behind the `VITE_PLAYGROUND` flag, on by default in dev and off in
prod. Drop a real provider in by replacing one file
(see [Swapping in a real backend](#swapping-in-a-real-backend) below) and
[`PLAYGROUND.md`](./PLAYGROUND.md) is the contract you have to honor.

## Quickstart

```bash
npm install
npm run dev
```

Then open the printed `Local:` URL (Vite picks the first free port from
5173 upwards).

## Scripts

| command | what it does |
| ------------------ | ----------------------------------------- |
| `npm run dev` | Start the Vite dev server with HMR. |
| `npm run build` | Type-check, then build a static bundle. |
| `npm run preview` | Serve the built bundle locally. |
| `npm run typecheck`| Type-check without emitting. |

## What's in the box

- **Composer** powered by [`lexical`](https://lexical.dev/) in plain-text
mode. The plugin layer is intentionally thin so that autocomplete, mention
pickers, or slash menus can be added later without restructuring the
editor.
- **Markdown** rendering via `react-markdown` + `remark-gfm`, with element
renderers that follow the iii Schematic (lowercase headings, monospace
body, bordered code blocks and tables).
- **Backend seam** in [`src/lib/backend/`](src/lib/backend/). `ChatView`
consumes a `ChatBackend` interface that yields a documented stream of
events; the mock (dev) and the stub real backend (prod) both implement it.
Three canned bodies — one per mode — exercise headings, lists, fenced
code, blockquotes, and inline code on the first run.
See [`PLAYGROUND.md`](./PLAYGROUND.md) for the full contract.
- **Playground** at `#/playground` (dev only) — a chat surface driven by a
catalog of scenarios (errors, aborts, multi-tool runs, slow/fast streams,
long markdown) that stress every corner of the streaming contract. Useful
before swapping in a real backend.
- **Model picker** and **mode picker** (`plan` / `ask` / `agent`) wired into
the canned response so you can see the values flow through.
- **File attachments** via a hidden file input. Previewable text/image
files store a data URL; binaries store metadata only. Attachments are
cleared after the next outgoing message.
- **Sidebar** listing conversations, persisted to `localStorage` under
`iii-chat-conversations`. Double-click a row to rename inline; hover to
reveal the delete affordance.
- **Light / dark theme** toggle, persisted under `iii-theme` and applied
pre-paint to avoid a flash.

## Layout

```
src/
main.tsx
App.tsx # routing + flag-guarded lazy() for dev pages
index.css # Tailwind v4 + iii Schematic tokens + utilities
lib/
utils.ts # cn = twMerge(clsx(...))
storage.ts # localStorage CRUD
markdown.tsx # iii-styled react-markdown wrapper
backend/ # ← the seam. ChatBackend interface + impls
types.ts # StreamEvent, ChatBackend, ChatStreamOptions
mock.ts # dev-only mock; tree-shaken in prod
real.ts # ← swap this stub for your provider
index.ts # getDefaultBackend() picks one based on flag
types/chat.ts # Conversation, Message, Mode, ModelId, Attachment
hooks/
use-conversations.ts # state + persistence
use-hash-route.ts # #/ #/playground #/examples
use-theme.ts # theme + persistence
components/
ui/ # iii Schematic primitives
sidebar/ # ConversationSidebar + ConversationRow
chat/ # ChatView, Composer, LexicalShell, Message, etc.
pages/
Chat.tsx # the production chat surface
Examples/ # spec sheet of component variants (dev only)
Playground/ # interactive scenario sandbox (dev only)
scenarios/ # one ChatBackend per file
```

## Swapping in a real backend

Open [`src/lib/backend/real.ts`](src/lib/backend/real.ts) and replace the
stub generator with one that talks to your provider. The shape of the
events you yield is defined in
[`src/lib/backend/types.ts`](src/lib/backend/types.ts) and explained in
[`PLAYGROUND.md`](./PLAYGROUND.md). As long as your generator yields
`StreamEvent`s in the documented order, the chat surface and every
playground scenario keep working — that's the whole point of the seam.

A sketch for OpenAI's chat-completions stream:

```ts
import type { ChatBackend } from './types'

export const realBackend: ChatBackend = {
id: 'openai',
async *stream(prompt, _mode, model, opts) {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
signal: opts?.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
},
body: JSON.stringify({
model,
stream: true,
messages: [{ role: 'user', content: prompt }],
}),
})
// ... read res.body as a ReadableStream, parse SSE chunks,
// yield { kind: 'assistant-token', token } as each delta arrives,
// finish with { kind: 'assistant-end' }.
},
}
```

To verify your implementation against the same edge cases the mock survives,
flip the flag on (`VITE_PLAYGROUND=1 npm run dev`), open `#/playground`, and
walk every scenario in the picker. If they all render correctly, your
backend is contract-clean.

## Design system

Every primitive in [`src/components/ui`](src/components/ui) is ported
verbatim from §10 of [`../DESIGN.md`](../DESIGN.md). The theme tokens in
[`src/index.css`](src/index.css) are from §0 of the same document. If you
change anything visual, mirror the change in `DESIGN.md` — the doc is the
source of truth, not the code.
Loading
Loading