diff --git a/console/web/.gitignore b/console/web/.gitignore new file mode 100644 index 000000000..d1b042674 --- /dev/null +++ b/console/web/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +dist-ssr +.DS_Store +*.log +.vite +*.tsbuildinfo diff --git a/console/web/PLAYGROUND.md b/console/web/PLAYGROUND.md new file mode 100644 index 000000000..f94c15775 --- /dev/null +++ b/console/web/PLAYGROUND.md @@ -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 +} +``` + +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/.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. diff --git a/console/web/README.md b/console/web/README.md new file mode 100644 index 000000000..540ce6531 --- /dev/null +++ b/console/web/README.md @@ -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. diff --git a/console/web/biome.json b/console/web/biome.json new file mode 100644 index 000000000..0ea45222a --- /dev/null +++ b/console/web/biome.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "all" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/console/web/index.html b/console/web/index.html new file mode 100644 index 000000000..6ab761626 --- /dev/null +++ b/console/web/index.html @@ -0,0 +1,27 @@ + + + + + + iii chat + + + + + + +
+ + + diff --git a/console/web/package.json b/console/web/package.json new file mode 100644 index 000000000..3846224f0 --- /dev/null +++ b/console/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "chat-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write ." + }, + "dependencies": { + "@lexical/react": "^0.44.0", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lexical": "^0.44.0", + "prism-react-renderer": "^2.4.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.15", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^25.8.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "vite": "^8.0.13" + } +} diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx new file mode 100644 index 000000000..d79e2784a --- /dev/null +++ b/console/web/src/App.tsx @@ -0,0 +1,104 @@ +import { lazy, Suspense } from 'react' +import { ModeToggle } from '@/components/ui/ModeToggle' +import { Sheet } from '@/components/ui/Sheet' +import { Wordmark } from '@/components/ui/Wordmark' +import { useHashRoute, type View } from '@/hooks/use-hash-route' +import { type Theme, useTheme } from '@/hooks/use-theme' +import { Chat } from '@/pages/Chat' + +const PLAYGROUND_ENABLED = !!import.meta.env.VITE_PLAYGROUND + +/* Lazy + flag-guarded: when VITE_PLAYGROUND is empty at build time, both + constants fold to null and Rolldown drops the dynamic-import targets along + with every transitive module (mock backend, scenarios, examples sections). */ +const Examples = PLAYGROUND_ENABLED + ? lazy(() => + import('@/pages/Examples').then((m) => ({ default: m.Examples })), + ) + : null + +const Playground = PLAYGROUND_ENABLED + ? lazy(() => + import('@/pages/Playground').then((m) => ({ default: m.Playground })), + ) + : null + +const VIEW_OPTIONS: { value: View; label: string }[] = PLAYGROUND_ENABLED + ? [ + { value: 'chat', label: 'chat' }, + { value: 'playground', label: 'playground' }, + { value: 'examples', label: 'examples' }, + ] + : [{ value: 'chat', label: 'chat' }] + +export function App() { + const [theme, setTheme] = useTheme() + const [view, setView] = useHashRoute() + + return ( + +
+ }> + {view === 'examples' && Examples ? ( + + ) : view === 'playground' && Playground ? ( + + ) : ( + + )} + + + ) +} + +interface HeaderProps { + view: View + onViewChange: (next: View) => void + theme: Theme + onThemeChange: (next: Theme) => void +} + +function Header({ view, onViewChange, theme, onThemeChange }: HeaderProps) { + return ( +
+
+ + + chat + +
+
+ {VIEW_OPTIONS.length > 1 ? ( + + value={view} + onChange={onViewChange} + options={VIEW_OPTIONS} + /> + ) : null} + + value={theme} + onChange={onThemeChange} + options={[ + { value: 'light', label: 'light' }, + { value: 'dark', label: 'dark' }, + ]} + /> +
+
+ ) +} + +function RouteFallback() { + return ( +
+
+ loading… +
+
+ ) +} diff --git a/console/web/src/components/chat/AttachmentButton.tsx b/console/web/src/components/chat/AttachmentButton.tsx new file mode 100644 index 000000000..c7889af18 --- /dev/null +++ b/console/web/src/components/chat/AttachmentButton.tsx @@ -0,0 +1,82 @@ +import { useRef } from 'react' +import { Button } from '@/components/ui/Button' +import { uid } from '@/hooks/use-conversations' +import type { Attachment } from '@/types/chat' + +interface AttachmentButtonProps { + onAttach: (attachments: Attachment[]) => void + disabled?: boolean +} + +const MAX_PREVIEW_BYTES = 1_000_000 + +function readPreview(file: File): Promise { + if (file.size > MAX_PREVIEW_BYTES) return Promise.resolve(undefined) + if (!/^(image|text)\//.test(file.type)) return Promise.resolve(undefined) + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => + resolve(typeof reader.result === 'string' ? reader.result : undefined) + reader.onerror = () => resolve(undefined) + reader.readAsDataURL(file) + }) +} + +export function AttachmentButton({ + onAttach, + disabled, +}: AttachmentButtonProps) { + const inputRef = useRef(null) + + const handlePick = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []) + if (files.length === 0) return + const attachments: Attachment[] = await Promise.all( + files.map(async (f) => ({ + id: uid(), + name: f.name, + size: f.size, + type: f.type || 'application/octet-stream', + dataUrl: await readPreview(f), + })), + ) + onAttach(attachments) + /* allow re-picking the same file */ + e.target.value = '' + } + + return ( + <> + + + + ) +} diff --git a/console/web/src/components/chat/AttachmentChip.tsx b/console/web/src/components/chat/AttachmentChip.tsx new file mode 100644 index 000000000..6215710fb --- /dev/null +++ b/console/web/src/components/chat/AttachmentChip.tsx @@ -0,0 +1,68 @@ +import { cn } from '@/lib/utils' +import type { Attachment } from '@/types/chat' + +interface AttachmentChipProps { + attachment: Attachment + onRemove?: (id: string) => void + className?: string +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}b` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}kb` + return `${(bytes / (1024 * 1024)).toFixed(1)}mb` +} + +export function AttachmentChip({ + attachment, + onRemove, + className, +}: AttachmentChipProps) { + const isImage = attachment.type.startsWith('image/') && attachment.dataUrl + return ( +
+ {isImage ? ( + + ) : ( + + )} + {attachment.name} + + {formatSize(attachment.size)} + + {onRemove ? ( + + ) : null} +
+ ) +} diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx new file mode 100644 index 000000000..1dbaca4ce --- /dev/null +++ b/console/web/src/components/chat/ChatView.tsx @@ -0,0 +1,238 @@ +import { useCallback, useRef, useState } from 'react' +import { Prompt } from '@/components/ui/Prompt' +import { StatusDot } from '@/components/ui/StatusDot' +import { uid } from '@/hooks/use-conversations' +import type { ChatBackend } from '@/lib/backend' +import type { + AssistantMessage, + Conversation, + FunctionCallMessage, + Message, + MessagePatch, + Mode, + ModelId, + ThoughtMessage, + UserMessage, +} from '@/types/chat' +import { Composer, type ComposerSubmitPayload } from './Composer' +import { MessageList } from './MessageList' + +function isAbortError(err: unknown): boolean { + return ( + err instanceof DOMException && + (err.name === 'AbortError' || err.code === DOMException.ABORT_ERR) + ) +} + +interface ChatViewProps { + conversation: Conversation + backend: ChatBackend + onUpdateModel: (id: string, model: ModelId) => void + onUpdateMode: (id: string, mode: Mode) => void + onAppendMessage: (id: string, message: Message) => void + onPatchMessage: (id: string, messageId: string, patch: MessagePatch) => void +} + +export function ChatView({ + conversation, + backend, + onUpdateModel, + onUpdateMode, + onAppendMessage, + onPatchMessage, +}: ChatViewProps) { + const [isStreaming, setIsStreaming] = useState(false) + const abortRef = useRef(null) + + const handleSubmit = useCallback( + async (payload: ComposerSubmitPayload) => { + const conversationId = conversation.id + + /* user turn */ + const userMsg: UserMessage = { + id: uid(), + role: 'user', + content: payload.text, + attachments: + payload.attachments.length > 0 ? payload.attachments : undefined, + createdAt: Date.now(), + } + onAppendMessage(conversationId, userMsg) + + const controller = new AbortController() + abortRef.current = controller + setIsStreaming(true) + + /* per-turn pointers so we know which id to patch when events arrive */ + let thoughtId: string | null = null + let thoughtBuffer = '' + let fcallId: string | null = null + let assistantId: string | null = null + let assistantBuffer = '' + + try { + for await (const event of backend.stream( + payload.text || '(attachments only)', + conversation.mode, + conversation.model, + { signal: controller.signal }, + )) { + switch (event.kind) { + case 'thought-start': { + const msg: ThoughtMessage = { + id: uid(), + role: 'thought', + content: '', + durationMs: 0, + streaming: true, + createdAt: Date.now(), + } + thoughtId = msg.id + thoughtBuffer = '' + onAppendMessage(conversationId, msg) + break + } + case 'thought-token': { + if (!thoughtId) break + thoughtBuffer += event.token + onPatchMessage(conversationId, thoughtId, { + content: thoughtBuffer, + }) + break + } + case 'thought-end': { + if (!thoughtId) break + onPatchMessage(conversationId, thoughtId, { + streaming: false, + durationMs: event.durationMs, + }) + break + } + case 'fcall-start': { + const msg: FunctionCallMessage = { + id: uid(), + role: 'function-call', + functionId: event.functionId, + input: event.input, + running: !event.pendingApproval, + pendingApproval: event.pendingApproval, + createdAt: Date.now(), + } + fcallId = msg.id + onAppendMessage(conversationId, msg) + break + } + case 'fcall-end': { + if (!fcallId) break + onPatchMessage(conversationId, fcallId, { + output: event.output, + durationMs: event.durationMs, + running: false, + pendingApproval: false, + }) + /* reset so a subsequent fcall-start gets a fresh slot */ + fcallId = null + break + } + case 'assistant-token': { + if (!assistantId) { + const msg: AssistantMessage = { + id: uid(), + role: 'assistant', + content: '', + model: conversation.model, + mode: conversation.mode, + streaming: true, + createdAt: Date.now(), + } + assistantId = msg.id + assistantBuffer = '' + onAppendMessage(conversationId, msg) + } + assistantBuffer += event.token + onPatchMessage(conversationId, assistantId, { + content: assistantBuffer, + }) + break + } + case 'assistant-end': { + if (!assistantId) break + onPatchMessage(conversationId, assistantId, { streaming: false }) + break + } + } + } + } catch (err) { + /* AbortError (caller aborted, or backend threw one) is a benign + cancellation. Anything else gets surfaced for debugging — backends + are expected to express semantic failures via fcall-end payloads + rather than thrown errors. */ + if (!isAbortError(err)) { + console.warn('[chat] stream errored', err) + } + } finally { + /* defensive cleanup: if we aborted mid-stream, clear streaming flags. */ + if (thoughtId) { + onPatchMessage(conversationId, thoughtId, { streaming: false }) + } + if (fcallId) { + onPatchMessage(conversationId, fcallId, { running: false }) + } + if (assistantId) { + onPatchMessage(conversationId, assistantId, { streaming: false }) + } + setIsStreaming(false) + abortRef.current = null + } + }, + [ + conversation.id, + conversation.mode, + conversation.model, + backend, + onAppendMessage, + onPatchMessage, + ], + ) + + const handleStop = useCallback(() => { + abortRef.current?.abort() + }, []) + + return ( +
+
+
+ {conversation.model} + · + {conversation.mode} +
+
+ + + {isStreaming ? 'streaming' : 'ready'} + +
+
+ + + +
+
+ onUpdateMode(conversation.id, next)} + onModelChange={(next) => onUpdateModel(conversation.id, next)} + onSubmit={handleSubmit} + onStop={handleStop} + isStreaming={isStreaming} + /> +
+
+
+ ) +} diff --git a/console/web/src/components/chat/Composer.tsx b/console/web/src/components/chat/Composer.tsx new file mode 100644 index 000000000..f0db08c35 --- /dev/null +++ b/console/web/src/components/chat/Composer.tsx @@ -0,0 +1,126 @@ +import type { LexicalEditor } from 'lexical' +import { useCallback, useRef, useState } from 'react' +import { Button } from '@/components/ui/Button' +import type { Attachment, Mode, ModelId } from '@/types/chat' +import { AttachmentButton } from './AttachmentButton' +import { AttachmentChip } from './AttachmentChip' +import { LexicalShell } from './LexicalShell' +import { ModelPicker } from './ModelPicker' +import { ModePicker } from './ModePicker' + +export interface ComposerSubmitPayload { + text: string + attachments: Attachment[] +} + +interface ComposerProps { + mode: Mode + model: ModelId + onModeChange: (next: Mode) => void + onModelChange: (next: ModelId) => void + onSubmit: (payload: ComposerSubmitPayload) => void + onStop?: () => void + isStreaming?: boolean + /** Initial editor content (applied once on mount). */ + initialContent?: (editor: LexicalEditor) => void + /** Initial attachment chips (applied once on mount). */ + initialAttachments?: Attachment[] +} + +export function Composer({ + mode, + model, + onModeChange, + onModelChange, + onSubmit, + onStop, + isStreaming, + initialContent, + initialAttachments, +}: ComposerProps) { + const [attachments, setAttachments] = useState( + initialAttachments ?? [], + ) + const [clearToken, setClearToken] = useState(0) + const textRef = useRef('') + + const handleSubmit = useCallback(() => { + if (isStreaming) return + const text = textRef.current.trim() + if (!text && attachments.length === 0) return + onSubmit({ text, attachments }) + textRef.current = '' + setAttachments([]) + setClearToken((t) => t + 1) + }, [isStreaming, attachments, onSubmit]) + + const handleAttach = useCallback((next: Attachment[]) => { + setAttachments((current) => [...current, ...next]) + }, []) + + const handleRemoveAttachment = useCallback((id: string) => { + setAttachments((current) => current.filter((a) => a.id !== id)) + }, []) + + return ( +
+ {attachments.length > 0 ? ( +
+ {attachments.map((a) => ( + + ))} +
+ ) : null} + +
+ { + textRef.current = text + }} + onSubmit={handleSubmit} + clearToken={clearToken} + placeholder={isStreaming ? 'streaming response…' : 'send a message…'} + disabled={isStreaming} + initialContent={initialContent} + /> +
+ +
+ + +
+ + {isStreaming ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx new file mode 100644 index 000000000..20a0ae891 --- /dev/null +++ b/console/web/src/components/chat/FunctionCallMessage.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/Button' +import { StatusDot } from '@/components/ui/StatusDot' +import { JsonHighlight } from '@/lib/syntax' +import { cn } from '@/lib/utils' +import type { FunctionCallMessage as FunctionCallMessageType } from '@/types/chat' + +interface FunctionCallMessageProps { + message: FunctionCallMessageType + defaultOpen?: boolean + onApprove?: () => void + onDeny?: () => void +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +type Primitive = string | number | boolean | null + +function isPrimitive(v: unknown): v is Primitive { + return ( + v === null || + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' + ) +} + +function singlePrimitiveField( + v: unknown, +): { key: string; value: Primitive } | null { + if (!v || typeof v !== 'object' || Array.isArray(v)) return null + const entries = Object.entries(v as Record) + if (entries.length !== 1) return null + const [key, value] = entries[0] + if (!isPrimitive(value)) return null + return { key, value } +} + +function formatPrimitive(v: Primitive): string { + if (v === null) return 'null' + return String(v) +} + +export function FunctionCallMessage({ + message, + defaultOpen, + onApprove, + onDeny, +}: FunctionCallMessageProps) { + const pending = !!message.pendingApproval + const running = !!message.running + const [open, setOpen] = useState(!!defaultOpen || pending) + + useEffect(() => { + if (pending) setOpen(true) + }, [pending]) + + const dotTone: 'accent' | 'warn' | 'ink' = pending + ? 'warn' + : running + ? 'accent' + : 'ink' + + return ( +
+ + + {open ? ( +
+ + {!pending && !running ? ( + + ) : null} +
+ ) : null} + + {pending ? ( +
+ + +
+ ) : null} +
+ ) +} + +interface ValuePaneProps { + label: string + value: unknown + bordered?: boolean +} + +function ValuePane({ label, value, bordered }: ValuePaneProps) { + const primitive = isPrimitive(value) + const single = !primitive ? singlePrimitiveField(value) : null + + return ( +
+
+ {label} + {single ? ( + + {' '} + · {single.key} + + ) : null} +
+ {primitive ? ( +
+          {formatPrimitive(value)}
+        
+ ) : single ? ( +
+          {formatPrimitive(single.value)}
+        
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/LexicalShell.tsx b/console/web/src/components/chat/LexicalShell.tsx new file mode 100644 index 000000000..bea916e5a --- /dev/null +++ b/console/web/src/components/chat/LexicalShell.tsx @@ -0,0 +1,177 @@ +import { ClearEditorPlugin } from '@lexical/react/LexicalClearEditorPlugin' +import { LexicalComposer } from '@lexical/react/LexicalComposer' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { ContentEditable } from '@lexical/react/LexicalContentEditable' +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' +import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin' +import { + $getRoot, + CLEAR_EDITOR_COMMAND, + COMMAND_PRIORITY_LOW, + KEY_ENTER_COMMAND, + type LexicalEditor, +} from 'lexical' +import { useEffect, useMemo, useRef } from 'react' +import { FunctionMentionNode } from './lexical/FunctionMentionNode' +import { FunctionMentionTransformPlugin } from './lexical/FunctionMentionTransformPlugin' +import { MentionsPlugin } from './lexical/MentionsPlugin' + +interface LexicalShellProps { + onChange: (text: string) => void + onSubmit: () => void + placeholder?: string + disabled?: boolean +} + +const baseConfig = { + namespace: 'iii-chat', + /* no theme classes — surface inherits Chivo Mono from */ + theme: {}, + /* Decorator nodes must be registered up-front so importJSON/restore work. */ + nodes: [FunctionMentionNode], + onError(error: Error) { + console.error(error) + }, +} + +/** + * Lifts the editor text out on every change. The text is whatever + * `root.getTextContent()` returns — plain text, no marks. + */ +function ChangePlugin({ onChange }: { onChange: (text: string) => void }) { + return ( + { + state.read(() => { + onChange($getRoot().getTextContent()) + }) + }} + /> + ) +} + +/** + * Enter submits, Shift+Enter inserts a newline (Lexical's default). + * We listen at LOW priority. While a typeahead menu is open we swallow Enter + * here (return true) so it can't fall through to PlainTextPlugin's + * KEY_ENTER_COMMAND at EDITOR priority (which would insert a newline). The + * typeahead runs at NORMAL and gets first shot at consuming Enter for option + * selection; this branch is the safety net for the brief window where the + * menu is open but the typeahead's Enter handler isn't (yet) consuming. + */ +function SubmitOnEnterPlugin({ + onSubmit, + menuOpenRef, +}: { + onSubmit: () => void + menuOpenRef: React.MutableRefObject +}) { + const [editor] = useLexicalComposerContext() + useEffect(() => { + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (menuOpenRef.current) { + event?.preventDefault() + return true + } + if (event && (event.shiftKey || event.metaKey || event.ctrlKey)) { + return false + } + event?.preventDefault() + onSubmit() + return true + }, + COMMAND_PRIORITY_LOW, + ) + }, [editor, onSubmit, menuOpenRef]) + return null +} + +/** + * Imperatively expose a "clear" so the parent can wipe the editor after submit. + * We use Lexical's CLEAR_EDITOR_COMMAND, which the ClearEditorPlugin handles. + */ +function ClearOnDemandPlugin({ token }: { token: number }) { + const [editor] = useLexicalComposerContext() + useEffect(() => { + if (token === 0) return + editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined) + }, [editor, token]) + return null +} + +/** + * Toggle the editor's editable state when `disabled` flips. + */ +function EditablePlugin({ disabled }: { disabled?: boolean }) { + const [editor] = useLexicalComposerContext() + useEffect(() => { + editor.setEditable(!disabled) + }, [editor, disabled]) + return null +} + +export interface LexicalShellHandle { + clear: () => void +} + +interface LexicalShellExtendedProps extends LexicalShellProps { + clearToken: number + /** Optional one-shot initializer that runs once on mount inside the editor. */ + initialContent?: (editor: LexicalEditor) => void +} + +export function LexicalShell({ + onChange, + onSubmit, + placeholder = 'send a message…', + disabled, + clearToken, + initialContent, +}: LexicalShellExtendedProps) { + /* LexicalComposer reads initialConfig once on mount; lock it behind useMemo + so the initializer callback identity doesn't trigger a remount on re-render. */ + // biome-ignore lint/correctness/useExhaustiveDependencies: initialContent is a one-shot mount initializer; capturing later changes would force a remount and lose editor state. + const initialConfig = useMemo( + () => ({ + ...baseConfig, + editorState: initialContent ?? null, + }), + [], + ) + /* Shared between the mentions plugin (the producer) and SubmitOnEnter + (the consumer) so we can suppress submit when the typeahead is up. */ + const menuOpenRef = useRef(false) + return ( + +
+ + {placeholder} +
+ } + className="composer-editor px-3 py-2" + /> + } + ErrorBoundary={LexicalErrorBoundary} + /> +
+ + + + + + + + + + ) +} diff --git a/console/web/src/components/chat/Message.tsx b/console/web/src/components/chat/Message.tsx new file mode 100644 index 000000000..a0ca8a42e --- /dev/null +++ b/console/web/src/components/chat/Message.tsx @@ -0,0 +1,81 @@ +import { Caret } from '@/components/ui/Caret' +import { Prompt } from '@/components/ui/Prompt' +import { Markdown } from '@/lib/markdown' +import { cn } from '@/lib/utils' +import type { + AssistantMessage as AssistantMessageType, + Message as MessageType, + UserMessage as UserMessageType, +} from '@/types/chat' +import { AttachmentChip } from './AttachmentChip' +import { FunctionCallMessage } from './FunctionCallMessage' +import { ThoughtMessage } from './ThoughtMessage' + +interface MessageProps { + message: MessageType +} + +export function Message({ message }: MessageProps) { + switch (message.role) { + case 'user': + return + case 'assistant': + return + case 'thought': + return + case 'function-call': + return + } +} + +function UserMessage({ message }: { message: UserMessageType }) { + return ( +
+
+ you +
+
+ {message.content} +
+ {message.attachments && message.attachments.length > 0 ? ( +
+ {message.attachments.map((a) => ( + + ))} +
+ ) : null} +
+ ) +} + +function AssistantMessage({ message }: { message: AssistantMessageType }) { + const showCaret = !!message.streaming + return ( +
+
+ assistant + {message.model ? ( + · {message.model} + ) : null} + {message.mode ? ( + · {message.mode} + ) : null} +
+
+ {message.content ? ( + {message.content} + ) : ( +
+ thinking… +
+ )} + {showCaret ? : null} +
+
+ ) +} diff --git a/console/web/src/components/chat/MessageList.tsx b/console/web/src/components/chat/MessageList.tsx new file mode 100644 index 000000000..b07e64ef2 --- /dev/null +++ b/console/web/src/components/chat/MessageList.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from 'react' +import { Prompt } from '@/components/ui/Prompt' +import type { Message as MessageType } from '@/types/chat' +import { Message } from './Message' + +interface MessageListProps { + messages: MessageType[] +} + +export function MessageList({ messages }: MessageListProps) { + const bottomRef = useRef(null) + const containerRef = useRef(null) + + /* Auto-scroll only when the user is already near the bottom. The effect body + reads layout off refs but the trigger we care about is "messages changed", + so list it explicitly. */ + // biome-ignore lint/correctness/useExhaustiveDependencies: messages is the trigger, not a value read in the body. + useEffect(() => { + const c = containerRef.current + if (!c) return + const distanceFromBottom = c.scrollHeight - c.scrollTop - c.clientHeight + if (distanceFromBottom < 200) { + bottomRef.current?.scrollIntoView({ block: 'end' }) + } + }, [messages]) + + if (messages.length === 0) { + return + } + + return ( +
+
+ {messages.map((m) => ( + + ))} +
+
+
+ ) +} + +function EmptyState() { + return ( +
+
+
+ new session +
+

+ how can i help. +

+

+ pick a mode and a model, attach files if you need to, then send a + message. responses are mocked locally — swap{' '} + + lib/backend/real.ts + {' '} + for a real provider when you're ready. +

+
    +
  • + · plan — outline an approach + before doing. +
  • +
  • + · ask — answer a question with + context. +
  • +
  • + · agent — take action and report + back. +
  • +
+
+
+ ) +} diff --git a/console/web/src/components/chat/ModePicker.tsx b/console/web/src/components/chat/ModePicker.tsx new file mode 100644 index 000000000..7565581dc --- /dev/null +++ b/console/web/src/components/chat/ModePicker.tsx @@ -0,0 +1,19 @@ +import { ModeToggle } from '@/components/ui/ModeToggle' +import { MODES, type Mode } from '@/types/chat' + +interface ModePickerProps { + value: Mode + onChange: (next: Mode) => void + className?: string +} + +export function ModePicker({ value, onChange, className }: ModePickerProps) { + return ( + + value={value} + onChange={onChange} + options={MODES.map((m) => ({ value: m.id, label: m.label }))} + className={className} + /> + ) +} diff --git a/console/web/src/components/chat/ModelPicker.tsx b/console/web/src/components/chat/ModelPicker.tsx new file mode 100644 index 000000000..b7bb249f4 --- /dev/null +++ b/console/web/src/components/chat/ModelPicker.tsx @@ -0,0 +1,27 @@ +import { Select } from '@/components/ui/Select' +import { MODELS, type ModelId } from '@/types/chat' + +interface ModelPickerProps { + value: ModelId + onChange: (next: ModelId) => void + disabled?: boolean + className?: string +} + +export function ModelPicker({ + value, + onChange, + disabled, + className, +}: ModelPickerProps) { + return ( + + value={value} + options={MODELS.map((m) => ({ value: m.id, label: m.label }))} + onChange={onChange} + disabled={disabled} + aria-label="model" + className={className} + /> + ) +} diff --git a/console/web/src/components/chat/ThoughtMessage.tsx b/console/web/src/components/chat/ThoughtMessage.tsx new file mode 100644 index 000000000..c73cb5546 --- /dev/null +++ b/console/web/src/components/chat/ThoughtMessage.tsx @@ -0,0 +1,57 @@ +import { Caret } from '@/components/ui/Caret' +import { cn } from '@/lib/utils' +import type { ThoughtMessage as ThoughtMessageType } from '@/types/chat' + +interface ThoughtMessageProps { + message: ThoughtMessageType + /** Force the panel open. Used by the examples showcase. */ + defaultOpen?: boolean +} + +function thoughtLabel(durationMs: number): string { + if (durationMs < 1500) return 'briefly' + return `for ${(durationMs / 1000).toFixed(1)}s` +} + +export function ThoughtMessage({ message, defaultOpen }: ThoughtMessageProps) { + const streaming = !!message.streaming + return ( +
+ + + ▸ + + {streaming ? ( + + thought… + + + ) : ( + + thought{' '} + + {thoughtLabel(message.durationMs)} + + + )} + +
+ {message.content || ( + no content yet… + )} +
+
+ ) +} diff --git a/console/web/src/components/chat/lexical/FunctionMentionNode.tsx b/console/web/src/components/chat/lexical/FunctionMentionNode.tsx new file mode 100644 index 000000000..9b3d9a3af --- /dev/null +++ b/console/web/src/components/chat/lexical/FunctionMentionNode.tsx @@ -0,0 +1,274 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' +import { + $getNodeByKey, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + DecoratorNode, + type DOMConversion, + type DOMConversionMap, + type DOMConversionOutput, + type DOMExportOutput, + type EditorConfig, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + type LexicalNode, + mergeRegister, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical' +import { type JSX, type RefObject, useEffect, useRef } from 'react' +import { cn } from '@/lib/utils' + +export type SerializedFunctionMentionNode = Spread< + { functionId: string }, + SerializedLexicalNode +> + +/** + * An inline pill representing an `@fn()` mention. Rendered through + * Lexical's `decorate()` so React owns the visuals (ƒ glyph + name + panel + * background), while `getTextContent()` returns the plain-text `@fn()` + * form so the existing OnChange lift in LexicalShell keeps working. The + * markdown renderer detects the same `@fn()` token and reuses the + * presentational pill (`FunctionMentionPill`) below. + */ +export class FunctionMentionNode extends DecoratorNode { + __functionId: string + + static getType(): string { + return 'function-mention' + } + + static clone(node: FunctionMentionNode): FunctionMentionNode { + return new FunctionMentionNode(node.__functionId, node.__key) + } + + static importJSON( + serialized: SerializedFunctionMentionNode, + ): FunctionMentionNode { + return $createFunctionMentionNode(serialized.functionId) + } + + /* Recreate the pill on HTML paste (cross-editor or external apps). + `exportDOM` already stamps the `data-lexical-function-mention` flag and + a `data-function-id` attribute, so the round-trip is symmetrical. */ + static importDOM(): DOMConversionMap | null { + return { + span: (el: HTMLElement): DOMConversion | null => { + if (el.getAttribute('data-lexical-function-mention') !== 'true') { + return null + } + return { + conversion: convertFunctionMentionElement, + priority: 1, + } + }, + } + } + + constructor(functionId: string, key?: NodeKey) { + super(key) + this.__functionId = functionId + } + + exportJSON(): SerializedFunctionMentionNode { + return { + type: FunctionMentionNode.getType(), + version: 1, + functionId: this.__functionId, + } + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('span') + element.setAttribute('data-lexical-function-mention', 'true') + element.setAttribute('data-function-id', this.__functionId) + element.textContent = this.getTextContent() + return { element } + } + + createDOM(_config: EditorConfig): HTMLElement { + /* Lexical needs a host DOM node; React's decorate() output mounts inside. */ + const span = document.createElement('span') + span.style.display = 'inline-block' + span.style.verticalAlign = 'middle' + return span + } + + updateDOM(): false { + return false + } + + isInline(): true { + return true + } + + isKeyboardSelectable(): true { + return true + } + + getTextContent(): string { + return `@fn(${this.__functionId})` + } + + getFunctionId(): string { + return this.__functionId + } + + decorate(): JSX.Element { + return ( + + ) + } +} + +function convertFunctionMentionElement(el: HTMLElement): DOMConversionOutput { + const functionId = el.getAttribute('data-function-id') ?? '' + if (!functionId) return { node: null } + return { node: $createFunctionMentionNode(functionId) } +} + +interface PillProps { + functionId: string + /** Visible-selected state. Defaults to false; only the Lexical decorator + wrapper passes a real value. Markdown renders never set this. */ + selected?: boolean + /** Click-target ref; only the Lexical wrapper supplies one (so its + `CLICK_COMMAND` handler can scope hit-tests to the pill). Markdown + renders leave this unset and the pill behaves as pure decoration. */ + pillRef?: RefObject +} + +/** + * The inserted-token visual. ƒ icon in accent, function id in ink, on a + * panel background. Rectilinear; no rounded corners; monospace; tight + * inline-block sizing so it flows with text. When `selected` is true the + * border swaps to accent and the surface lifts one step to `paper-2` — same + * 1px footprint, no layout shift. No DOM-level click handler lives here; + * the Lexical wrapper drives selection via `CLICK_COMMAND` so the pill + * stays a static, accessible inline element in both editor and markdown. + */ +export function FunctionMentionPill({ + functionId, + selected, + pillRef, +}: PillProps) { + return ( + + + ƒ + + {functionId} + + ) +} + +interface EditablePillProps { + functionId: string + nodeKey: NodeKey +} + +/** + * Lexical-decorator wrapper: tracks selection via `useLexicalNodeSelection` + * and listens for `CLICK_COMMAND` / `KEY_BACKSPACE_COMMAND` / + * `KEY_DELETE_COMMAND` so the user can select the pill, then cut/copy/paste + * or delete it. Click-with-shift toggles the selection; plain click replaces + * the current selection. + */ +function EditableFunctionMentionPill({ + functionId, + nodeKey, +}: EditablePillProps) { + const [editor] = useLexicalComposerContext() + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey) + const pillRef = useRef(null) + + useEffect(() => { + const removeIfSelected = (event: KeyboardEvent): boolean => { + if (!isSelected) return false + event.preventDefault() + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if (node) node.remove() + }) + return true + } + return mergeRegister( + editor.registerCommand( + CLICK_COMMAND, + (event: MouseEvent) => { + const target = event.target as Node | null + if ( + !pillRef.current || + !target || + !pillRef.current.contains(target) + ) { + return false + } + /* preventDefault keeps the caret from landing inside the decorator + host; Lexical would otherwise place selection just before/after + the pill and fight our node selection. */ + event.preventDefault() + if (event.shiftKey) { + setSelected(!isSelected) + } else { + clearSelection() + setSelected(true) + } + return true + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + removeIfSelected, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + removeIfSelected, + COMMAND_PRIORITY_LOW, + ), + ) + }, [editor, nodeKey, isSelected, setSelected, clearSelection]) + + return ( + + ) +} + +export function $createFunctionMentionNode( + functionId: string, +): FunctionMentionNode { + return new FunctionMentionNode(functionId) +} + +export function $isFunctionMentionNode( + node: LexicalNode | null | undefined, +): node is FunctionMentionNode { + return node instanceof FunctionMentionNode +} diff --git a/console/web/src/components/chat/lexical/FunctionMentionTransformPlugin.tsx b/console/web/src/components/chat/lexical/FunctionMentionTransformPlugin.tsx new file mode 100644 index 000000000..d8d603dd0 --- /dev/null +++ b/console/web/src/components/chat/lexical/FunctionMentionTransformPlugin.tsx @@ -0,0 +1,56 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { TextNode } from 'lexical' +import { useEffect } from 'react' +import { $createFunctionMentionNode } from './FunctionMentionNode' + +/* Matches a single `@fn()` token where `` contains no whitespace and + no closing paren. Intentionally non-global so the transform processes one + match per pass — Lexical will re-run it on the resulting nodes until the + text stops matching, which keeps the conversion incremental and safe. */ +const FN_PATTERN = /@fn\(([^)\s]+)\)/ + +/** + * Auto-converts plain `@fn()` text into the decorator pill. Fires whenever + * a `TextNode` is mutated — paste, drop, typing, or programmatic insertion — + * so the user can drop a literal mention into the composer and see it render + * exactly the same as one inserted through the typeahead menu. + * + * The transform splits the matched substring out as its own TextNode (using + * `splitText`) and replaces it in place with a `FunctionMentionNode`. Leading + * and trailing text around the match is preserved. + */ +export function FunctionMentionTransformPlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + return editor.registerNodeTransform(TextNode, (node) => { + if (!node.isSimpleText()) return + const text = node.getTextContent() + const match = text.match(FN_PATTERN) + if (!match || match.index === undefined) return + + const start = match.index + const end = start + match[0].length + const functionId = match[1] + + /* `splitText` returns the chunks in order; the original node is mutated + to hold the first chunk and any tail chunks are returned as new nodes. + We pick whichever chunk is the matched substring as `target`. */ + let target: TextNode + if (start === 0 && end === text.length) { + target = node + } else if (start === 0) { + target = node.splitText(end)[0] + } else if (end === text.length) { + target = node.splitText(start)[1] + } else { + target = node.splitText(start, end)[1] + } + + const mention = $createFunctionMentionNode(functionId) + target.replace(mention) + }) + }, [editor]) + + return null +} diff --git a/console/web/src/components/chat/lexical/MentionsPlugin.tsx b/console/web/src/components/chat/lexical/MentionsPlugin.tsx new file mode 100644 index 000000000..4e204914e --- /dev/null +++ b/console/web/src/components/chat/lexical/MentionsPlugin.tsx @@ -0,0 +1,213 @@ +import { + LexicalTypeaheadMenuPlugin, + MenuOption, + useBasicTypeaheadTriggerMatch, +} from '@lexical/react/LexicalTypeaheadMenuPlugin' +import { + $createTextNode, + COMMAND_PRIORITY_NORMAL, + type TextNode, +} from 'lexical' +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { type FunctionEntry, fuzzyFilter } from '@/lib/functions' +import { cn } from '@/lib/utils' +import { $createFunctionMentionNode } from './FunctionMentionNode' + +class FunctionMentionOption extends MenuOption { + entry: FunctionEntry + constructor(entry: FunctionEntry) { + super(entry.id) + this.entry = entry + } +} + +interface MentionsPluginProps { + /** When set, this ref is flipped to true while the typeahead is visible + so a sibling SubmitOnEnter plugin can skip its Enter handler. */ + menuOpenRef?: React.MutableRefObject +} + +interface FlipMenuProps { + anchorEl: HTMLElement + options: FunctionMentionOption[] + selectedIndex: number | null + selectOptionAndCleanUp: (option: FunctionMentionOption) => void + setHighlightedIndex: (index: number) => void +} + +/* Lexical positions the anchor div just below the caret. When the composer sits + near the viewport bottom the menu would hang off the page (and grow body + height, scrolling the page) because Lexical's flip-up branch only triggers + when there's room above WITHIN the editor's root element — which our short + composer never satisfies. We layer a `transform: translateY(...)` on top of + Lexical's `style.top` so Lexical's repositioning keeps working while we + override the placement when needed. */ +function FlipMenu({ + anchorEl, + options, + selectedIndex, + selectOptionAndCleanUp, + setHighlightedIndex, +}: FlipMenuProps) { + const menuRef = useRef(null) + + /* Re-run on options.length so the menu repositions when the option count + changes (the menu's height shifts and the flip-up threshold may cross). */ + // biome-ignore lint/correctness/useExhaustiveDependencies: options.length is a trigger, not read in the effect body. + useLayoutEffect(() => { + const adjust = () => { + const menu = menuRef.current + if (!menu) return + /* IMPORTANT: read layout (offsetTop/offsetHeight), not visual + (getBoundingClientRect). The anchor's bounding rect reflects the + transform we apply here, so using it would make the overflow check + flip true/false on every MutationObserver fire and lock the UI. */ + const viewportTop = anchorEl.offsetTop - window.scrollY + const menuHeight = menu.getBoundingClientRect().height + const anchorHeight = anchorEl.offsetHeight + const SAFE = 12 + const next = + viewportTop + menuHeight > window.innerHeight - SAFE + ? `translateY(-${menuHeight + anchorHeight + 8}px)` + : '' + /* Guard against the MutationObserver → setter → MutationObserver loop. */ + if (anchorEl.style.transform !== next) anchorEl.style.transform = next + } + adjust() + const mo = new MutationObserver(adjust) + mo.observe(anchorEl, { attributes: true, attributeFilter: ['style'] }) + const ro = new ResizeObserver(adjust) + if (menuRef.current) ro.observe(menuRef.current) + window.addEventListener('resize', adjust) + document.addEventListener('scroll', adjust, true) + return () => { + mo.disconnect() + ro.disconnect() + window.removeEventListener('resize', adjust) + document.removeEventListener('scroll', adjust, true) + anchorEl.style.transform = '' + } + }, [anchorEl, options.length]) + + return ( +
+
+ functions +
+ {/* div+role over ul/li because keyboard navigation is driven by Lexical's + typeahead controller (arrow keys, enter, escape), not by Tab. The + listbox/option roles preserve the screen-reader semantics without + dragging in semantic
    /
  • structure that Biome's a11y rules + (correctly) treat as non-interactive. */} +
    + {options.map((opt, i) => { + const active = i === selectedIndex + return ( +
    opt.setRefElement(el)} + onMouseEnter={() => setHighlightedIndex(i)} + onMouseDown={(e) => { + e.preventDefault() + selectOptionAndCleanUp(opt) + }} + className={cn( + 'flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors', + active + ? 'bg-panel border-l-2 border-l-accent pl-[10px]' + : 'border-l-2 border-l-transparent hover:bg-paper-2', + )} + > + +
    + + {opt.entry.id} + + + {opt.entry.description} + +
    +
    + ) + })} +
    +
+ ) +} + +export function MentionsPlugin({ menuOpenRef }: MentionsPluginProps = {}) { + const [query, setQuery] = useState(null) + + const triggerFn = useBasicTypeaheadTriggerMatch('@', { minLength: 0 }) + + const options = useMemo( + () => + fuzzyFilter(query ?? '').map((entry) => new FunctionMentionOption(entry)), + [query], + ) + + /* The typeahead plugin wraps this callback in editor.update() and passes us + the TextNode currently holding "@" (since shouldSplitNodeWithQuery + is true inside the plugin). We just replace it with our mention node and + append a trailing space so the caret lands cleanly after the pill. */ + const onSelectOption = useCallback( + ( + option: FunctionMentionOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + ) => { + if (textNodeContainingQuery) { + const mention = $createFunctionMentionNode(option.entry.id) + const trailing = $createTextNode(' ') + textNodeContainingQuery.replace(mention) + mention.insertAfter(trailing) + trailing.selectEnd() + } + closeMenu() + }, + [], + ) + + return ( + + options={options} + onQueryChange={setQuery} + onSelectOption={onSelectOption} + onOpen={() => { + if (menuOpenRef) menuOpenRef.current = true + }} + onClose={() => { + if (menuOpenRef) menuOpenRef.current = false + }} + triggerFn={triggerFn} + /* Run the typeahead's KEY_ENTER_COMMAND (and arrows/tab/escape) at NORMAL + so it consumes Enter before our SubmitOnEnter handler at LOW. */ + commandPriority={COMMAND_PRIORITY_NORMAL} + menuRenderFn={(anchorElementRef, props) => { + if (!anchorElementRef.current || options.length === 0) return null + return createPortal( + , + anchorElementRef.current, + ) + }} + /> + ) +} diff --git a/console/web/src/components/sidebar/ConversationRow.tsx b/console/web/src/components/sidebar/ConversationRow.tsx new file mode 100644 index 000000000..e0fed3623 --- /dev/null +++ b/console/web/src/components/sidebar/ConversationRow.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' +import type { Conversation } from '@/types/chat' + +interface ConversationRowProps { + conversation: Conversation + active: boolean + onSelect: () => void + onRename: (title: string) => void + onRemove: () => void +} + +function formatRelative(ts: number): string { + const delta = Date.now() - ts + if (delta < 60_000) return 'now' + if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m` + if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h` + return `${Math.floor(delta / 86_400_000)}d` +} + +export function ConversationRow({ + conversation, + active, + onSelect, + onRename, + onRemove, +}: ConversationRowProps) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(conversation.title) + const inputRef = useRef(null) + + useEffect(() => { + if (editing) { + inputRef.current?.focus() + inputRef.current?.select() + } + }, [editing]) + + useEffect(() => { + if (!editing) setDraft(conversation.title) + }, [conversation.title, editing]) + + const commit = () => { + setEditing(false) + const next = draft.trim() + if (next && next !== conversation.title) onRename(next) + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: row hosts a nested delete +
+ ) +} diff --git a/console/web/src/components/sidebar/ConversationSidebar.tsx b/console/web/src/components/sidebar/ConversationSidebar.tsx new file mode 100644 index 000000000..986a5abb7 --- /dev/null +++ b/console/web/src/components/sidebar/ConversationSidebar.tsx @@ -0,0 +1,65 @@ +import { Button } from '@/components/ui/Button' +import type { Conversation } from '@/types/chat' +import { ConversationRow } from './ConversationRow' + +interface ConversationSidebarProps { + conversations: Conversation[] + activeId: string | null + onCreate: () => void + onSelect: (id: string) => void + onRename: (id: string, title: string) => void + onRemove: (id: string) => void +} + +export function ConversationSidebar({ + conversations, + activeId, + onCreate, + onSelect, + onRename, + onRemove, +}: ConversationSidebarProps) { + return ( + + ) +} diff --git a/console/web/src/components/ui/Button.tsx b/console/web/src/components/ui/Button.tsx new file mode 100644 index 000000000..b5e8ff7af --- /dev/null +++ b/console/web/src/components/ui/Button.tsx @@ -0,0 +1,56 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-x-2 whitespace-nowrap font-mono lowercase rounded-none transition-[background-color,color,border-color] duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-40 select-none', + { + variants: { + variant: { + primary: 'bg-ink text-bg border border-ink hover:bg-bg hover:text-ink', + ghost: + 'bg-transparent text-ink border border-transparent hover:bg-ink hover:text-bg', + pill: 'bg-bg text-ink border border-ink hover:bg-ink hover:text-bg', + icon: 'bg-bg text-ink-faint border border-rule hover:text-ink', + terminal: 'bg-bg text-ink border border-rule justify-start', + wiggle: + 'wiggle bg-ink text-bg border border-ink hover:bg-bg hover:text-ink relative', + }, + size: { + sm: 'h-8 px-3 text-[13px]', + md: 'h-9 px-5 text-[13px]', + lg: 'h-11 px-5 text-[14px]', + icon: 'size-[30px] p-0', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +export const Button = React.forwardRef( + ({ className, variant, size, asChild, children, ...props }, ref) => { + const Comp: React.ElementType = asChild ? Slot : 'button' + return ( + + {children} + + ) + }, +) +Button.displayName = 'Button' + +export { buttonVariants } diff --git a/console/web/src/components/ui/Caret.tsx b/console/web/src/components/ui/Caret.tsx new file mode 100644 index 000000000..4c67d2c94 --- /dev/null +++ b/console/web/src/components/ui/Caret.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/lib/utils' + +interface CaretProps { + className?: string +} + +export function Caret({ className }: CaretProps) { + return ( + + ) +} diff --git a/console/web/src/components/ui/ModeToggle.tsx b/console/web/src/components/ui/ModeToggle.tsx new file mode 100644 index 000000000..49dcefc87 --- /dev/null +++ b/console/web/src/components/ui/ModeToggle.tsx @@ -0,0 +1,49 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils' + +interface ModeToggleOption { + value: T + label: React.ReactNode +} + +interface ModeToggleProps { + value: T + onChange: (next: T) => void + options: ModeToggleOption[] + className?: string +} + +export function ModeToggle({ + value, + onChange, + options, + className, +}: ModeToggleProps) { + return ( +
+ {options.map((opt) => { + const active = opt.value === value + return ( + + ) + })} +
+ ) +} diff --git a/console/web/src/components/ui/Prompt.tsx b/console/web/src/components/ui/Prompt.tsx new file mode 100644 index 000000000..93e8eee5c --- /dev/null +++ b/console/web/src/components/ui/Prompt.tsx @@ -0,0 +1,19 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils' + +interface PromptProps { + symbol?: string + className?: string + children?: React.ReactNode +} + +export function Prompt({ symbol = '$', className, children }: PromptProps) { + return ( + + {symbol} + {children !== undefined ? ( + {children} + ) : null} + + ) +} diff --git a/console/web/src/components/ui/Select.tsx b/console/web/src/components/ui/Select.tsx new file mode 100644 index 000000000..58b3c6b24 --- /dev/null +++ b/console/web/src/components/ui/Select.tsx @@ -0,0 +1,67 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils' + +interface SelectOption { + value: T + label: string +} + +interface SelectProps + extends Omit< + React.SelectHTMLAttributes, + 'onChange' | 'value' | 'defaultValue' + > { + value: T + options: SelectOption[] + onChange: (next: T) => void +} + +export function Select({ + value, + options, + onChange, + className, + disabled, + ...rest +}: SelectProps) { + return ( + + ) +} diff --git a/console/web/src/components/ui/Sheet.tsx b/console/web/src/components/ui/Sheet.tsx new file mode 100644 index 000000000..8c1cfc162 --- /dev/null +++ b/console/web/src/components/ui/Sheet.tsx @@ -0,0 +1,20 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils' + +interface SheetProps { + children: React.ReactNode + className?: string +} + +export function Sheet({ children, className }: SheetProps) { + return ( +
+ {children} +
+ ) +} diff --git a/console/web/src/components/ui/StatusDot.tsx b/console/web/src/components/ui/StatusDot.tsx new file mode 100644 index 000000000..f48a359e2 --- /dev/null +++ b/console/web/src/components/ui/StatusDot.tsx @@ -0,0 +1,36 @@ +import type * as React from 'react' +import { cn } from '@/lib/utils' + +type DotTone = 'accent' | 'alert' | 'warn' | 'ink' + +const dotTone: Record = { + accent: 'bg-accent', + alert: 'bg-alert', + warn: 'bg-warn', + ink: 'bg-ink', +} + +interface StatusDotProps extends React.HTMLAttributes { + tone?: DotTone + pulse?: boolean +} + +export function StatusDot({ + tone = 'accent', + pulse, + className, + ...props +}: StatusDotProps) { + return ( + + ) +} diff --git a/console/web/src/components/ui/Wordmark.tsx b/console/web/src/components/ui/Wordmark.tsx new file mode 100644 index 000000000..fe1ad6de5 --- /dev/null +++ b/console/web/src/components/ui/Wordmark.tsx @@ -0,0 +1,38 @@ +import { cn } from '@/lib/utils' + +interface WordmarkProps { + className?: string +} + +/** + * The "iii" wordmark — three lowercase "i"s as identical square units. Per §6 + * of DESIGN.md the mark is six rectangles (stem + tittle per "i"), all in ink, + * sharing the same unit. No curves, no color, deliberate negative space. + */ +export function Wordmark({ className }: WordmarkProps) { + return ( + + + + + + ) +} + +function Glyph() { + return ( + + {/* tittle */} + + {/* stem */} + + + ) +} diff --git a/console/web/src/hooks/use-conversations.ts b/console/web/src/hooks/use-conversations.ts new file mode 100644 index 000000000..8c2964cb9 --- /dev/null +++ b/console/web/src/hooks/use-conversations.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + loadActiveId, + loadConversations, + saveActiveId, + saveConversations, +} from '@/lib/storage' +import { + type Conversation, + DEFAULT_MODE, + DEFAULT_MODEL, + type Message, + type MessagePatch, + type Mode, + type ModelId, +} from '@/types/chat' + +function uid(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36) +} + +function deriveTitle(text: string): string { + const clean = text.replace(/\s+/g, ' ').trim().toLowerCase() + if (!clean) return 'new chat' + return clean.length > 32 ? `${clean.slice(0, 32)}…` : clean +} + +function emptyConversation(): Conversation { + const now = Date.now() + return { + id: uid(), + title: 'new chat', + model: DEFAULT_MODEL, + mode: DEFAULT_MODE, + messages: [], + createdAt: now, + updatedAt: now, + } +} + +export interface ConversationsApi { + conversations: Conversation[] + activeId: string | null + active: Conversation | null + createNew: () => string + select: (id: string) => void + rename: (id: string, title: string) => void + remove: (id: string) => void + setModel: (id: string, model: ModelId) => void + setMode: (id: string, mode: Mode) => void + appendMessage: (id: string, message: Message) => void + updateMessage: (id: string, messageId: string, patch: MessagePatch) => void +} + +export function useConversations(): ConversationsApi { + const [conversations, setConversations] = useState(() => { + const loaded = loadConversations() + /* Always boot with at least one empty conversation so the chat surface + has something to render. Done in the initializer so StrictMode's + double-invoke can't create two. */ + return loaded.length > 0 ? loaded : [emptyConversation()] + }) + const [activeId, setActiveId] = useState(() => { + const stored = loadActiveId() + return stored + }) + + /* Persist on every mutation. Debounced via microtask to avoid thrashing. */ + const persistRef = useRef(null) + useEffect(() => { + if (persistRef.current) cancelAnimationFrame(persistRef.current) + persistRef.current = requestAnimationFrame(() => + saveConversations(conversations), + ) + return () => { + if (persistRef.current) cancelAnimationFrame(persistRef.current) + } + }, [conversations]) + + useEffect(() => { + saveActiveId(activeId) + }, [activeId]) + + /* Ensure there's always a sensible "active" pointer at the start. */ + useEffect(() => { + if (conversations.length === 0) return + if (!activeId || !conversations.some((c) => c.id === activeId)) { + setActiveId(conversations[0].id) + } + }, [conversations, activeId]) + + const active = useMemo( + () => conversations.find((c) => c.id === activeId) ?? null, + [conversations, activeId], + ) + + const patchConversation = useCallback( + (id: string, patch: (c: Conversation) => Conversation) => { + setConversations((list) => list.map((c) => (c.id === id ? patch(c) : c))) + }, + [], + ) + + const createNew = useCallback(() => { + const next = emptyConversation() + setConversations((list) => [next, ...list]) + setActiveId(next.id) + return next.id + }, []) + + const select = useCallback((id: string) => setActiveId(id), []) + + const rename = useCallback( + (id: string, title: string) => + patchConversation(id, (c) => ({ + ...c, + title: title.trim() || c.title, + titleManual: true, + updatedAt: Date.now(), + })), + [patchConversation], + ) + + const remove = useCallback((id: string) => { + setConversations((list) => list.filter((c) => c.id !== id)) + setActiveId((current) => (current === id ? null : current)) + }, []) + + const setModel = useCallback( + (id: string, model: ModelId) => + patchConversation(id, (c) => ({ ...c, model, updatedAt: Date.now() })), + [patchConversation], + ) + + const setMode = useCallback( + (id: string, mode: Mode) => + patchConversation(id, (c) => ({ ...c, mode, updatedAt: Date.now() })), + [patchConversation], + ) + + const appendMessage = useCallback( + (id: string, message: Message) => + patchConversation(id, (c) => { + const messages = [...c.messages, message] + const next: Conversation = { + ...c, + messages, + updatedAt: Date.now(), + } + if ( + !c.titleManual && + message.role === 'user' && + c.messages.every((m) => m.role !== 'user') + ) { + next.title = deriveTitle(message.content) + } + return next + }), + [patchConversation], + ) + + const updateMessage = useCallback( + (id: string, messageId: string, patch: MessagePatch) => + patchConversation(id, (c) => ({ + ...c, + messages: c.messages.map((m) => + m.id === messageId ? ({ ...m, ...patch } as Message) : m, + ), + updatedAt: Date.now(), + })), + [patchConversation], + ) + + return { + conversations, + activeId, + active, + createNew, + select, + rename, + remove, + setModel, + setMode, + appendMessage, + updateMessage, + } +} + +export { uid } diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts new file mode 100644 index 000000000..163b6f33d --- /dev/null +++ b/console/web/src/hooks/use-hash-route.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export type View = 'chat' | 'examples' | 'playground' + +const PLAYGROUND_ENABLED = !!import.meta.env.VITE_PLAYGROUND + +/** + * Resolve a hash to a route view, OR null if it isn't a route hash at all + * (e.g. anchor links like `#message-variants`). When it's not a route, the + * caller should keep the current view rather than reset to chat. + * + * When the playground flag is off, dev-only routes resolve to 'chat' so old + * deep links don't strand a user on a 404. + */ +function routeFromHash(hash: string): View | null { + if (hash === '' || hash === '#' || hash === '#/' || hash === '#/chat') { + return 'chat' + } + if (hash === '#/examples') return PLAYGROUND_ENABLED ? 'examples' : 'chat' + if (hash === '#/playground') return PLAYGROUND_ENABLED ? 'playground' : 'chat' + return null +} + +export function useHashRoute(): [View, (next: View) => void] { + /* Initial: read window.location on mount. */ + const [view, setView] = useState(() => { + if (typeof window === 'undefined') return 'chat' + return routeFromHash(window.location.hash) ?? 'chat' + }) + const viewRef = useRef(view) + viewRef.current = view + + useEffect(() => { + const handle = () => { + const next = routeFromHash(window.location.hash) + /* Only react to actual route hashes; anchor hashes keep the current view. */ + if (next !== null && next !== viewRef.current) setView(next) + } + window.addEventListener('hashchange', handle) + return () => window.removeEventListener('hashchange', handle) + }, []) + + const navigate = useCallback((next: View) => { + const targetHash = + next === 'chat' + ? '#/' + : next === 'examples' + ? '#/examples' + : '#/playground' + if (window.location.hash !== targetHash) { + window.location.hash = targetHash + } else { + setView(next) + } + }, []) + + return [view, navigate] +} diff --git a/console/web/src/hooks/use-theme.ts b/console/web/src/hooks/use-theme.ts new file mode 100644 index 000000000..71ba7ad07 --- /dev/null +++ b/console/web/src/hooks/use-theme.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from 'react' + +export type Theme = 'light' | 'dark' + +const KEY = 'iii-theme' + +function readTheme(): Theme { + if (typeof document === 'undefined') return 'light' + const attr = document.documentElement.dataset.theme + return attr === 'dark' ? 'dark' : 'light' +} + +export function useTheme(): [Theme, (next: Theme) => void] { + const [theme, setThemeState] = useState(() => readTheme()) + + useEffect(() => { + document.documentElement.dataset.theme = theme + try { + localStorage.setItem(KEY, theme) + } catch { + /* best-effort */ + } + }, [theme]) + + const setTheme = useCallback((next: Theme) => setThemeState(next), []) + return [theme, setTheme] +} diff --git a/console/web/src/index.css b/console/web/src/index.css new file mode 100644 index 000000000..41631632c --- /dev/null +++ b/console/web/src/index.css @@ -0,0 +1,204 @@ +@import "tailwindcss"; + +@theme { + --font-sans: + "Chivo Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + --font-mono: + "Chivo Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + + --color-bg: #f2f0ed; + --color-panel: #e9e6e2; + --color-paper-2: #ebe8e3; + + --color-ink: #0a0a0a; + --color-ink-faint: #6b6865; + --color-ink-ghost: #a3a09c; + + --color-rule: #d8d5d0; + --color-rule-2: #e6e3df; + + --color-accent: #ff5a1f; + --color-accent-fg: #f2f0ed; + + --color-alert: #c43e1c; + --color-warn: #a87a00; + + --radius-none: 0px; + --radius-full: 9999px; + + --spacing-gutter: 24px; + --spacing-section-x: 36px; + --spacing-section-y: 80px; + --spacing-sheet-max: 1200px; + --spacing-content-max: 1216px; +} + +[data-theme="dark"] { + --color-bg: #111110; + --color-panel: #1a1916; + --color-paper-2: #1f1e1c; + --color-ink: #f2f0ed; + --color-ink-faint: #9c9893; + --color-ink-ghost: #5d5a55; + --color-rule: #2a2926; + --color-rule-2: #1f1e1c; + --color-accent: #3ea8ff; + --color-accent-fg: #111110; +} + +@layer base { + html, + body, + #root { + height: 100%; + } + + html, + body { + scrollbar-gutter: stable; + } + + body { + background-color: var(--color-bg); + color: var(--color-ink); + font-feature-settings: + "liga" 0, + "clig" 0, + "calt" 0, + "dlig" 0; + } + + ::selection { + background-color: var(--color-accent); + color: var(--color-accent-fg); + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: var(--color-rule); + } + ::-webkit-scrollbar-thumb:hover { + background: var(--color-ink-ghost); + } +} + +@keyframes pulse-dot { + 0% { + box-shadow: 0 0 0 0 var(--color-accent); + } + 100% { + box-shadow: 0 0 0 8px transparent; + } +} + +@utility pulse-dot { + animation: pulse-dot 1.6s ease-out infinite; +} + +@keyframes blink { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +@utility blink { + animation: blink 1s steps(1) infinite; +} + +@keyframes wiggle { + 0%, + 90%, + 100% { + transform: rotate(0deg); + } + 93% { + transform: rotate(-3deg); + } + 96% { + transform: rotate(3deg); + } +} + +@utility wiggle { + animation: wiggle 3s ease-in-out infinite; +} + +/* sanctioned exception to the no-gradients rule: a soft sweep across the */ +/* text mask used to telegraph "thinking" / "streaming" model states. */ +@keyframes thinking-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@utility thinking-shimmer { + background-image: linear-gradient( + 90deg, + var(--color-ink-ghost) 0%, + var(--color-ink) 50%, + var(--color-ink-ghost) 100% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: thinking-shimmer 2.4s linear infinite; +} + +@utility deal-shadow { + box-shadow: + -2px 0 0 var(--color-rule), + -16px 4px 36px -10px rgba(0, 0, 0, 0.22); +} + +/* lexical editor surface — keep it boring so it inherits the rest of the system */ +.composer-editor { + outline: none; + min-height: 56px; + max-height: 240px; + overflow-y: auto; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.7; + color: var(--color-ink); +} + +.composer-placeholder { + pointer-events: none; + position: absolute; + inset: 0; + color: var(--color-ink-ghost); + user-select: none; +} + +/* iii-styled
rows: hide the native marker, rotate our own chevron */ +.iii-details > summary { + list-style: none; + cursor: pointer; +} +.iii-details > summary::-webkit-details-marker { + display: none; +} +.iii-details > summary .iii-chev { + display: inline-block; + transition: transform 150ms ease-out; +} +.iii-details[open] > summary .iii-chev { + transform: rotate(90deg); +} diff --git a/console/web/src/lib/backend/index.ts b/console/web/src/lib/backend/index.ts new file mode 100644 index 000000000..c68474461 --- /dev/null +++ b/console/web/src/lib/backend/index.ts @@ -0,0 +1,17 @@ +import { mockBackend } from './mock' +import { realBackend } from './real' +import type { ChatBackend } from './types' + +/** + * Build-time flag: when truthy, ship the mock as the default backend (and + * the playground page). When falsy, only the real backend is reachable, so + * Rolldown tree-shakes `mockBackend` and every scenario module out of the + * production chunk. + */ +const PLAYGROUND_ENABLED = !!import.meta.env.VITE_PLAYGROUND + +export function getDefaultBackend(): ChatBackend { + return PLAYGROUND_ENABLED ? mockBackend : realBackend +} + +export type { ChatBackend, ChatStreamOptions, StreamEvent } from './types' diff --git a/console/web/src/lib/backend/mock.ts b/console/web/src/lib/backend/mock.ts new file mode 100644 index 000000000..604cf6243 --- /dev/null +++ b/console/web/src/lib/backend/mock.ts @@ -0,0 +1,134 @@ +import type { Mode, ModelId } from '@/types/chat' +import type { ChatBackend, ChatStreamOptions, StreamEvent } from './types' + +const PLAN_BODY = `## plan + +i'd start by laying out the work as a sequence of small, reversible steps — +this keeps the surface area auditable and lets you bail out if the shape of +the problem changes. + +1. read the request carefully and restate it in one sentence. +2. enumerate the constraints (latency, footprint, dependencies). +3. sketch the data flow on paper before writing any code. +4. pick the smallest slice that proves the design, then iterate. + +\`\`\`text +problem -> constraints -> sketch -> slice -> ship +\`\`\` + +a one-liner you can keep in your back pocket: *plan the work, then work the +plan*. nothing fancy, but it survives contact with reality.` + +const ASK_BODY = `## what i can tell you + +short answer: it depends on whether you're optimizing for **read** speed or +**write** speed. a few things worth keeping in mind: + +- a flat \`Map\` gives you \`O(1)\` lookups but is awkward for ordered iteration. +- a sorted list trades insert cost for cheap range scans. +- if you need both, a btree-backed index is usually the right tool. + +| structure | lookup | ordered range | +|-----------|--------|---------------| +| map | o(1) | no | +| sorted | o(log) | yes | +| btree | o(log) | yes | + +pick the one whose worst case matches your hottest path. you can always swap +later — the interface is the thing that matters.` + +const AGENT_BODY = `## echoed + +i ran \`engine::echo\` against your prompt and got the expected mirror back. +nothing else to do here — the worker is alive and responsive. + +\`\`\`json +{ "ok": true } +\`\`\` + +ready for the next instruction.` + +const PLAN_THOUGHT = `restating the request in one line, then enumerating +constraints. the user mentioned shape and direction but not scale, so i'll +plan for the smaller end of the range and flag the bigger case as a follow-up.` + +const AGENT_THOUGHT = `straightforward dispatch: this is an echo through +the engine, so i'll call \`engine::echo\` with the user's text verbatim and +report what comes back.` + +function bodyFor(mode: Mode): string { + if (mode === 'plan') return PLAN_BODY + if (mode === 'ask') return ASK_BODY + return AGENT_BODY +} + +async function* mockStream( + prompt: string, + mode: Mode, + _model: ModelId, + options: ChatStreamOptions = {}, +): AsyncGenerator { + const { signal, meanDelayMs = 25 } = options + const trimmedPrompt = prompt.replace(/\s+/g, ' ').trim().slice(0, 200) + + /* phase 1: thought (plan + agent) */ + if (mode === 'plan' || mode === 'agent') { + const thoughtBody = mode === 'plan' ? PLAN_THOUGHT : AGENT_THOUGHT + const targetDuration = mode === 'plan' ? 1500 : 1000 + const startedAt = Date.now() + yield { kind: 'thought-start' } + const thoughtTokens = thoughtBody.split(/(\s+)/) + const perToken = Math.max( + 8, + targetDuration / Math.max(1, thoughtTokens.length), + ) + for (const token of thoughtTokens) { + if (signal?.aborted) return + if (token) yield { kind: 'thought-token', token } + await sleep(perToken * (0.6 + Math.random() * 0.8), signal) + } + yield { kind: 'thought-end', durationMs: Date.now() - startedAt } + } + + /* phase 2: function call (agent only) */ + if (mode === 'agent') { + if (signal?.aborted) return + const fcallStart = Date.now() + const input = { text: trimmedPrompt || '(empty)' } + yield { kind: 'fcall-start', functionId: 'engine::echo', input } + await sleep(500 + Math.random() * 400, signal) + if (signal?.aborted) return + const output = { text: trimmedPrompt || '(empty)' } + yield { kind: 'fcall-end', output, durationMs: Date.now() - fcallStart } + } + + /* phase 3: assistant body */ + const body = bodyFor(mode) + const tokens = body.split(/(\s+)/) + for (const token of tokens) { + if (signal?.aborted) return + if (token) yield { kind: 'assistant-token', token } + await sleep(meanDelayMs * (0.5 + Math.random()), signal) + } + yield { kind: 'assistant-end' } +} + +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve() + const t = setTimeout(resolve, ms) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(t) + resolve() + }, + { once: true }, + ) + }) +} + +export const mockBackend: ChatBackend = { + id: 'mock', + stream: mockStream, +} diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts new file mode 100644 index 000000000..dbc15f767 --- /dev/null +++ b/console/web/src/lib/backend/real.ts @@ -0,0 +1,24 @@ +import type { Mode, ModelId } from '@/types/chat' +import type { ChatBackend, ChatStreamOptions, StreamEvent } from './types' + +/** + * Placeholder for the eventual real backend. Wire your provider here while + * preserving the StreamEvent contract documented in PLAYGROUND.md, and the + * playground will exercise the new implementation without changes. + */ +// biome-ignore lint/correctness/useYield: stub backend; throws before yielding. +async function* realStream( + _prompt: string, + _mode: Mode, + _model: ModelId, + _opts?: ChatStreamOptions, +): AsyncGenerator { + throw new Error( + 'backend not configured — see chat-app/PLAYGROUND.md for the streaming contract', + ) +} + +export const realBackend: ChatBackend = { + id: 'real', + stream: realStream, +} diff --git a/console/web/src/lib/backend/types.ts b/console/web/src/lib/backend/types.ts new file mode 100644 index 000000000..0e2db228d --- /dev/null +++ b/console/web/src/lib/backend/types.ts @@ -0,0 +1,45 @@ +import type { Mode, ModelId } from '@/types/chat' + +/** + * The streaming contract every ChatBackend honors. The order is: + * (thought-start ... thought-token* ... thought-end)? + * (fcall-start ... fcall-end)* + * (assistant-token+ ... assistant-end)? + * + * Aborts may interrupt at any token boundary; the consumer's `finally` is + * responsible for closing out streaming flags. Errors that aren't aborts + * surface either as a thrown exception or as an `fcall-end` payload whose + * `output` carries the error shape (caller-defined). + * + * See PLAYGROUND.md for the full contract. + */ +export type StreamEvent = + | { kind: 'thought-start' } + | { kind: 'thought-token'; token: string } + | { kind: 'thought-end'; durationMs: number } + | { + kind: 'fcall-start' + functionId: string + input: unknown + pendingApproval?: boolean + } + | { kind: 'fcall-end'; output: unknown; durationMs: number } + | { kind: 'assistant-token'; token: string } + | { kind: 'assistant-end' } + +export interface ChatStreamOptions { + signal?: AbortSignal + /** mean delay between assistant tokens, in ms */ + meanDelayMs?: number +} + +export interface ChatBackend { + /** stable identifier used by the playground for telemetry / labels */ + readonly id: string + stream( + prompt: string, + mode: Mode, + model: ModelId, + opts?: ChatStreamOptions, + ): AsyncGenerator +} diff --git a/console/web/src/lib/functions.ts b/console/web/src/lib/functions.ts new file mode 100644 index 000000000..31236c2f7 --- /dev/null +++ b/console/web/src/lib/functions.ts @@ -0,0 +1,28 @@ +export interface FunctionEntry { + id: string + description: string +} + +export const FUNCTIONS: FunctionEntry[] = [ + { id: 'engine::echo', description: 'echo a string back' }, + { id: 'engine::list', description: 'list workers' }, + { id: 'engine::info', description: 'inspect a worker' }, + { id: 'functions::list', description: 'list every function' }, + { id: 'functions::info', description: 'inspect a function' }, + { id: 'trigger::run', description: 'fire a trigger' }, + { id: 'directory::index', description: 'index a worker skill bundle' }, + { id: 'directory::resolve', description: 'resolve an iii:// link' }, +] + +export function fuzzyFilter(query: string, limit = 8): FunctionEntry[] { + const q = query.trim().toLowerCase() + if (!q) return FUNCTIONS.slice(0, limit) + return FUNCTIONS.filter( + (f) => + f.id.toLowerCase().includes(q) || f.description.toLowerCase().includes(q), + ).slice(0, limit) +} + +export function getFunctionEntry(id: string): FunctionEntry | undefined { + return FUNCTIONS.find((f) => f.id === id) +} diff --git a/console/web/src/lib/markdown.tsx b/console/web/src/lib/markdown.tsx new file mode 100644 index 000000000..2d0a6bbb2 --- /dev/null +++ b/console/web/src/lib/markdown.tsx @@ -0,0 +1,304 @@ +import type { Element, Root, RootContent, Text } from 'hast' +import ReactMarkdown, { type Components } from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { FunctionMentionPill } from '@/components/chat/lexical/FunctionMentionNode' +import { JsonHighlight } from '@/lib/syntax' +import { cn } from '@/lib/utils' + +interface MarkdownProps { + children: string + className?: string +} + +/* Matches `@fn()` where `` excludes whitespace and `)`. Tolerates + trailing punctuation outside the parens. The pattern is intentionally + defensive: we never accept arbitrary content inside the parens, so the + pill can't be smuggled into otherwise-safe markdown. */ +const FN_MENTION_RE = /@fn\(([^)\s]+)\)/g + +/* Inline rehype plugin: walks the hast tree and splits any `text` node that + contains `@fn()` into a mix of leftover text + a marker `span` element + carrying the function id. The actual pill rendering happens in the `span` + component override below. Code / pre subtrees are skipped so literal + mentions inside fenced blocks stay verbatim. */ +function rehypeFnMention() { + return (tree: Root) => walk(tree) +} + +function walk(node: Root | Element): void { + if ( + node.type === 'element' && + (node.tagName === 'code' || node.tagName === 'pre') + ) { + return + } + const children = node.children as RootContent[] + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (child.type === 'text') { + const parts = splitMention(child.value) + if (parts.length > 0) { + children.splice(i, 1, ...parts) + i += parts.length - 1 + } + } else if (child.type === 'element') { + walk(child) + } + } +} + +/* hast's `className` lands as either `string[]` or a space-joined `string`, + depending on how it was parsed. Normalize both shapes to a single check. */ +function hasLanguageJson(node: Element): boolean { + const cls = node.properties?.className + if (Array.isArray(cls)) return cls.includes('language-json') + if (typeof cls === 'string') return cls.split(/\s+/).includes('language-json') + return false +} + +function splitMention(value: string): Array { + if (!value.includes('@fn(')) return [] + const out: Array = [] + let last = 0 + /* matchAll iterates with stateless semantics on a /g regex, so we don't + have to babysit FN_MENTION_RE.lastIndex between calls. */ + for (const m of value.matchAll(FN_MENTION_RE)) { + const index = m.index ?? 0 + if (index > last) { + out.push({ type: 'text', value: value.slice(last, index) }) + } + out.push({ + type: 'element', + tagName: 'span', + properties: { className: ['fn-mention'] }, + children: [{ type: 'text', value: m[1] }], + }) + last = index + m[0].length + } + if (out.length === 0) return [] + if (last < value.length) { + out.push({ type: 'text', value: value.slice(last) }) + } + return out +} + +const components: Components = { + h1: ({ className, ...rest }) => ( +

+ ), + h2: ({ className, ...rest }) => ( +

+ ), + h3: ({ className, ...rest }) => ( +

+ ), + h4: ({ className, ...rest }) => ( +

+ ), + p: ({ className, ...rest }) => ( +

+ ), + ul: ({ className, ...rest }) => ( +