AI UI
The interaction contract for the Phase-12 Ask AI panel in
apps/web(TASK-0121/0122). The API/SSE contract lives in ../api/ai.md (authoritative).
Component map
components/ai/ai-panel.tsx— the panel (desktop right-side dialog, mobile bottom sheet < 768 px): header, history, messages, composer, error banner, delete-confirmcomponents/ai/ai-context-badge.tsx— the "Answering about: …" chip in the header (only rendered when the panel is bound to a context label)hooks/use-ai-stream.ts— the streaming state machine:sendMessage(fetch + SSE reader),stop,retry(re-sends withretryOfwhen a user row id is known),finalize(persists nothing — the server persists), error classification into bannerslib/ai.ts— the fetch + SSE frame parser (delta/tool_call/usage/error/done)lib/ai-context.ts— builds thecontextpayload ({ pageId, blockId, databaseId }) + the human-readable label from the current routecomponents/layout/topbar.tsx— the Commands button (command-palette-button) that opens the command palette- Writing tools (TASK-0123):
components/editor/selection-toolbar.tsx— the floating toolbar above a multi-block selection (selection-ai-menu+selection-clear)components/ai/writing-actions-menu.tsx— the four actions (Summarize / Rewrite / Improve writing / Translate) + the translate language picker (EN/ES/DE/FR/JA/PT/ZH + "Other…" free text), shared by both entry pointscomponents/ai/ask-ai-submenu.tsx— the "Ask AI" context-menu submenu ("Chat with AI" → the panel, + "Writing tools")components/ai/writing-diff.tsx— the suggestion dialog (per-block word-diff, Accept all / Accept one / Discard, rebase prompt, shimmer, error banner)lib/ai.ts—sendWriting(the writing SSE reader) +writingApi.apply+blockPlainText
1. Entry points
| Entry | Where | Context |
|---|---|---|
| Page toolbar "Ask AI" | ask-ai-button on the page toolbar (every page route) |
page (badge = page title) |
| Block selection | "Ask AI" submenu in the block ⋮-menu / context menu — "Chat with AI" opens the panel (wired in TASK-0122); "Writing tools" opens the writing dialog (TASK-0123, §7) | block |
| Multi-block selection (TASK-0123) | ⌘/Ctrl+click (or Shift+click range) selects rows → floating selection toolbar → "AI" menu (§7) | blocks |
| Database header | "Ask AI about this database" | database |
| Command palette | topbar Commands button → type "Ask AI" → Enter | none |
| Search results | "Ask AI about this result" on a hit | the hit's resource |
All entries call setAiPanel(true, context) on the shared useUiStore —
there is exactly one panel instance. ⌘K/Ctrl+K opens search (TASK-0098);
the palette is reached via the topbar button.
2. The panel
Header — "Ask AI" title, the context badge (when bound), "New
conversation" (ai-history-new), history toggle, close (ai-close,
aria-label "Close AI panel"). Escape closes the panel (but first
cancels an open delete-confirm); the backdrop click closes it. Focus is
trapped; on close, focus returns to the trigger.
History — the list (ai-history-item) is open by default, showing
the user's 50 most recent conversations (auto-titled from the first user
message, first 60 chars).
- Single click → loads that conversation (messages re-fetched from the server — nothing is kept in client state).
- Double-click → inline rename (
ai-history-rename, autoFocus; Enter or blur commits, Escape cancels). - Row hover reveals the delete button (
ai-history-delete) → confirm alertdialog (ai-delete-confirm/ai-delete-confirm-yes): "The messages will be soft-deleted (purged after 30 days)."
Messages — user bubbles right (ai-message-user), assistant bubbles left
(ai-message-assistant). While a reply streams, the last assistant bubble is
ai-message-live with a caret; before the first delta it reads
"Thinking…". Tool calls render as a collapsible card (ai-tool-call,
"Called name"). A role="status" sr-only live region announces
"AI is responding" / "AI finished" for screen readers.
Composer — ai-input (textarea; Enter sends, Shift+Enter newline),
ai-send (disabled when empty), ai-stop (replaces send while streaming).
A "Answering about this page — <label>" note (ai-context-note) is shown
under the composer when bound.
3. Error states (the banner contract)
Failures render ai-error-banner (role="alert") above the composer; the
input stays usable. The Retry button (ai-retry) appears only when the
error is retryable and not streaming:
| Condition | Banner text | Retry |
|---|---|---|
AI_BUDGET_EXCEEDED / monthly-exceeded (402) |
"Monthly AI budget reached." + "Ask an admin to raise it in Settings → AI." | none (a budget 402 is not retryable) |
RATE_LIMITED (429) |
"Sending too fast — try again shortly." | yes (plain resend — no user row was persisted) |
| provider 5xx / timeout (mid-stream) | "Something went wrong." | yes — re-sends with retryOf (the persisted user message id), so the row is not duplicated |
The banner clears on the next send or when a new reply completes.
4. Streaming behaviour
useAiStream renders every delta frame immediately (the live bubble
grows incrementally — E2E asserts ≥ 3 distinct intermediate DOM states with
the mock provider's per-word deltas). usage is stored (shown in the
completed message's footer: "· 120→34 tokens · 812 ms"). The terminal done
frame swaps the live bubble for the final ai-message-assistant and, for
reason: "aborted", keeps the partial text (the server persisted it as
truncated).
5. Accessibility & mobile
- Panel is a
role="dialog"witharia-label"Ask AI"; labelled by its title; focus-trapped; Escape semantics per the shared dialog convention. - Composer input has
aria-label"Message"; the send button is labelled "Send message"; the stop button "Stop generating". - The banner is
role="alert"(announced once); streaming state is announced through therole="status"live region, not per-delta. - < 768 px: the panel becomes a bottom sheet (full width, ~75 vh, rounded top); the same testids apply.
6. State across reloads
The panel itself is ephemeral (client state — conversation id, open/close, draft text). Messages and conversations are server state: after a reload, the history list and every message re-render from the API, so a finished answer survives a page reload with its full content and token/latency footer.
7. Writing tools (the suggestion diff, TASK-0123)
The four tools (Summarize / Rewrite / Improve writing / Translate) run against the selected block(s) and return a suggested-edit diff the user Accepts (applies as normal block updates) or Discards (no write). The API/SSE contract is authoritative in ../api/ai.md §7.
Entry points (two, shared menu)
- Selection toolbar — ⌘/Ctrl+click (or Shift+click for a range) selects
one or more blocks; a floating
role="toolbar"appears above the topmost selected row with "N selected", an AI menu (selection-ai-menu), and Clear (selection-clear). Escape / outside click closes it. - Block menus — the block ⋮-menu and the right-click context menu show
an Ask AI submenu: "Chat with AI" (the TASK-0122 panel,
block-ask-ai) and, under a divider, the four Writing tools for that single block.
Both open the same four-action list (writing-actions-*); Translate first
opens the language picker (writing-language-en/es/de/fr/ja/pt/zh +
writing-language-other free text ≤ 64 chars).
The dialog (writing-diff.tsx)
- Loading — a shimmer diff placeholder (
writing-loading) while the SSE streams; the suggestion appears when thegenerateBlockstool call arrives. - Per-block diff (
writing-diff-block) — a word-level highlight (deletions struck through, additions underlined) for rewrite / improve / translate; summarize shows the summary text in place of the span. The old text is the local block's current content (the stream carries only{blockId, newText}— no old text crosses the wire). - Notes — blocks that were skipped (missing / non-text) or dropped (a model-returned id outside the request) are listed as a caption, never silently.
- Errors — reuse the TASK-0122 banner contract (
writing-error-banner): budget402(no Retry), provider/timeout (Retry), rate-limit429(Retry after the window). Pre-stream failures never open a partial dialog. - Accept all (
writing-accept) — applies every listed block in one call; per-block failures are listed, not silently dropped. It is disabled while any block shows a version conflict (409) — the user must rebase first. - Accept one (
writing-accept-one-<blockId>) — a subset apply. - Discard (
writing-discard) — closes with no API call; the persisted suggestion stays for the cost ledger. - Rebase — a 409 conflict row shows a prompt (
writing-rebase-prompt): Rebase & retry the rest (re-reads the block, re-runs the remaining blocks) or Skip it. The concurrently-edited text is never clobbered. - After all blocks apply, the dialog closes; the editor invalidates the page's block query and the second open tab converges over realtime.
Accessibility & mobile
role="dialog"+aria-label, focus-trapped, Escape + focus restore to the trigger; Accept / Discard / Accept-one are real<button>s; the result is announced through anaria-live="polite"summary (e.g. "3 of 3 blocks updated").- < 768 px: the dialog scrolls vertically with the Accept/Discard bar pinned at the bottom (verified by the chromium-mobile E2E project).