Workstream A — Chat & composer (2026-09-11)
Everything in section A of docs/UPGRADE-PLAN.md is implemented except the items listed under Coming soon / limits.
pnpm typecheck, pnpm lint and pnpm test are green for every file below (remaining failures at the time of writing
belong to other workstreams: arena-view.tsx, app/app/onboarding/page.tsx, tests/unit/public-models.test.ts).
Files
Server (lib/chat/*, app/api/chat/*)
| File | Change |
|---|---|
src/lib/chat/schemas.ts |
chatRequestSchema + ephemeral, history, projectId; new chatAdoptSchema (ephemeralHistorySchema exported). |
src/lib/chat/service.ts |
Ephemeral turns (no conversation/message rows; usage record with conversationId: null; temp attachments deleted after the turn), projectId on new conversations, richer stored error (StoredMessageError: provider, status, retryAfterMs, providerCode, detail), settings exposed on PublicMessage, new adoptMessage() ("Continue with this model"), EPHEMERAL_CONVERSATION_ID. |
src/app/api/chat/route.ts |
meta event now carries ephemeral and requestId. |
src/app/api/chat/adopt/route.ts |
New POST /api/chat/adopt. |
src/lib/chat/humanize-error.ts |
New pure error humanizer (humanizeChatError, retryPhrase, secondsLeft). |
src/lib/chat/markdown-blocks.ts |
New streaming Markdown splitter (splitStreamingMarkdown), normalizeMath, extensionForLanguage. |
src/lib/chat/deprecation.ts |
New deprecationNotice, suggestReplacement, largerContextModel. |
src/lib/client/types.ts |
Appended ChatMetaExtras, StoredMessageError re-export, ChatAdoptResponse. |
tests/unit/chat-client.test.ts |
New 18 unit tests (humanizer, splitter, math normaliser, deprecation, schemas, stored error). |
Client (components/chat/*, components/markdown/*)
| File | Change |
|---|---|
chat-view.tsx |
Rewritten orchestrator: mobile header (48 px, hamburger, "more" ActionSheet), Smart Router AUTO flow, cost confirm, temporary chat, project instructions, compare inline, message actions (retry with model, quote, save as prompt, export…), summarize context, deprecated banner, ConfirmDialog for delete (no window.confirm). |
composer.tsx |
Redesign [+] Ask anything… [mic] [send]; + = ActionSheet (phone) / dropdown (desktop) with Upload image / Upload document / Camera (capture="environment", coarse pointers) / Paste content / From library (FileLibraryPicker) + extraActions; auto-grow 1→6 lines + collapse chevron; drag-and-drop, paste files, multiple chips; voice dictation (hidden when unsupported); forwardRef handle { focus, openFilePicker, insert }; topSlot/bottomSlot. Backwards compatible with the Arena's usage. |
composer-sheets.tsx |
New StructuredOutputSheet (JSON / JSON schema editor), SystemPromptSheet, ToolsSheet (calculator/clock/random). |
context-indicator.tsx |
New 43K / 200K meter + cost line; banner at 80 / 95 / >100 % with New chat / Summarize context / Switch to larger-context model. |
router-card.tsx |
New Smart Router recommendation sheet (Recommended · Why · Estimated cost · alternatives · mode · Use / Choose another / Always auto-route). |
cost-confirm.tsx |
New confirm sheet above COST_CONFIRM_THRESHOLD_USD with up to 3 cheaper alternatives (one tap = switch + send). |
model-launcher.tsx |
New ModelPickerLauncher — opens ModelSelector programmatically using only its public props (hidden trigger + .click()). |
message.tsx |
Toolbar (desktop hover via .hover-reveal, phone long-press → ActionSheet), "more" dropdown, expandable Details metadata (model, provider, tokens in/out/reasoning/cached, cost, TTFT, tok/s, reasoning effort, finish reason), deprecated / retiring badge + "Switch to …" strip, humanized errors. |
message-error.tsx |
New error card: title/description per code, Retry with live countdown (retryAfterMs), Switch model, Providers link, View details sheet (code, provider code, HTTP status, request id, provider message, copy). |
compare-inline.tsx |
New inline Compare with… (2–4 models via ModelSelector multiple; runs POST /api/arena + /api/arena/stream; desktop grid, phone .snap-row + sticky tabs; "Continue with " → /api/chat/adopt). |
empty-state.tsx |
"What do you want to work on?" + Write / Code / Research / Analyze a file / Compare models; temporary & project variants; keeps <Onboarding />. |
use-speech.ts |
New useSpeechDictation (Web Speech API, interim + final). |
markdown/markdown.tsx |
Memoized blocks while streaming (completed blocks never re-parse), LaTeX via remark-math + rehype-katex (+ \(…\) / \[…\] normalisation), katex CSS imported here, skipHtml kept. |
markdown/code-block.tsx |
Copy / Download / Wrap / Expand (collapses > 28 lines), debounced highlighting while streaming, 44 px targets. |
New dependencies: remark-math, rehype-katex, katex (allowed by the plan; katex CSS ≈ 23 KB gz + fonts loaded lazily by the browser).
API changes
POST /api/chat (SSE) — request additions
jsonc
{
"modelKey": "openai/gpt-5.5",
"message": { "text": "…", "attachmentIds": ["att_…"] },
"ephemeral": true, // optional — temporary chat
"history": [ // optional, ephemeral only (no server history)
{ "role": "user", "content": "…" }, { "role": "assistant", "content": "…" }
],
"projectId": "prj_…" // optional — set on the new conversation only
}ephemeral: truerequiresaction: "send"and noconversationId. Nothing is written toconversations/messages; ausage_recordsrow is still inserted (conversation_idandmessage_idNULL,kind: "chat"). Attachments uploaded for the temporary turn are deleted after the answer.metaevent:{ type: "meta", conversationId, isNewConversation, userMessage, assistantMessageId, modelKey, clientId, ephemeral: boolean, requestId: string }— for ephemeral turnsconversationIdis the sentinel"ephemeral".done.message.error(when failed) is now{ code, message, provider?, status?, retryable?, retryAfterMs?, providerCode?, detail? }.PublicMessagegainssettings(generation settings used for that assistant turn).
POST /api/chat/adopt — new
Body:
jsonc
{
"conversationId": "cnv_…", // optional; omitted → creates the conversation
"modelKey": "anthropic/claude-sonnet-5",
"arenaResponseId": "arr_…", // preferred: content + metrics copied server-side (ownership checked)
"content": "…", // fallback when no arenaResponseId
"userText": "…", // required when creating the conversation
"systemPrompt": "…", "settings": { … }, "projectId": "prj_…" // new conversation only
}→ 201 { conversation: PublicConversation, userMessage: PublicMessage | null, message: PublicMessage, isNewConversation }.
Switches the conversation's model, updates aggregates (messageCount, totals). No provider call — usage was recorded by the Arena.
Consumed (built by other workstreams — degrade gracefully)
GET /api/projects/:id→{ project: { id, name, instructions, preferredModelKeys } }(D). 404 / network error → no instructions.POST /api/prompts{ name, content, source: "chat" }(D). 404/405 → toast "Prompt library not available yet".GET /api/prompts/:id→{ prompt: { content } }for?promptId=(D). Ignored on 404.window.dispatchEvent(new CustomEvent("polyllm:prompt-insert", { detail: { text } }))inserts text at the composer caret (D / G).localStorage["polyllm:router-mode"](RouterMode) andlocalStorage["polyllm:router-always"](boolean) — B's AUTO entry may read/write the mode.
Behaviour notes
- AUTO:
selectedModelKey === AUTO_MODEL_KEY→ pill shows "Auto ▾"; on send the Router card appears (Recommended · Why · Estimated cost · alternatives · mode). "Always auto-route" skips the card but always toasts Auto-routed to X · why and the message header shows the model. The conversation then keeps the concrete model; the global selection stays AUTO. - Cost confirm: estimate >
COST_CONFIRM_THRESHOLD_USD→ sheet with ≤ 3 cheaper models fromrouteModels(…, "cheapest"). Cancelling any sheet restores the draft and attachments. - Temporary chat:
?temporary=1or+ → Temporary chat. Header badge "Temporary chat — not stored in history". Retry / edit are emulated client-side (history replayed); Continue and Branch are hidden. Refreshing the page loses the messages (by design). - Summarize context: real — an ephemeral turn with the current model produces the summary, then
POST /api/conversationscreates " (continued)" with the summary appended to the system prompt and navigates to it.</li> <li><strong>Project instructions</strong>: for a new chat with <code>activeProjectId</code>, <code>project.instructions</code> are prepended to the system prompt on the wire (the conversation stores the merged prompt from then on) and <code>preferredModelKeys[0]</code> becomes the default model unless the user already picked one. <code>projectId</code> is sent with the first turn.</li> <li><strong>Errors</strong>: <code>MessageError</code> maps every <code>PolyErrorCode</code> (+ <code>NO_PROVIDER_KEY</code>) to copy such as "OpenAI rate limit reached. Retry in 18 seconds." with a live countdown; details sheet shows code / provider code / HTTP status / request id.</li> <li><strong>Streaming</strong>: 40 ms batching kept; <code>Markdown streaming</code> renders completed blocks through memoized <code><Block></code>s (cuts only on blank lines outside fences, never splitting loose lists / quotes / tables), the tail re-parses live; code highlighting is debounced 120 ms.</li> </ul> <h2 id="coming-soon--limits" tabindex="-1"><a class="heading-anchor" href="#coming-soon--limits" aria-hidden="true"><span class="anchor-icon" aria-hidden="true">#</span></a> Coming soon / limits</h2> <ul> <li>Nothing is labelled "Coming soon" in the UI. Known limits: <ul> <li>Temporary chats: attachments from earlier turns are not replayed on retry/edit (text history only).</li> <li><code>⌘/</code> is a global listener inside <code>ModelSelector</code>; while the inline Compare panel (second selector) or the Router card is open the shortcut toggles both instances. <code>TODO(integration: B)</code>: expose <code>open</code>/<code>onOpenChange</code> on <code>ModelSelector</code> so <code>model-launcher.tsx</code> can drop the hidden-trigger <code>.click()</code> workaround.</li> <li><code>ModelConfig</code> is opened from the mobile "more" sheet by clicking its trigger (<code>#chat-model-config</code>).</li> <li>Model labels (<code>store.labels</code>) are not shown on the pill — left to B's <code>ModelSelector</code>.</li> </ul> </li> </ul> <h2 id="visual-qa-checklist-integration-phase-375--390--393--430--1440" tabindex="-1"><a class="heading-anchor" href="#visual-qa-checklist-integration-phase-375--390--393--430--1440" aria-hidden="true"><span class="anchor-icon" aria-hidden="true">#</span></a> Visual QA checklist (integration phase, 375 / 390 / 393 / 430 / 1440)</h2> <ol> <li>New chat: header 48 px with hamburger; empty state "What do you want to work on?" with 5 quick actions in 2 columns (5 on lg); no horizontal overflow.</li> <li>Composer: single row <code>[+] Ask anything… [mic?] [send]</code>; textarea grows to 6 lines then scrolls; chevron collapses/expands; 16 px font on phones (no iOS zoom); keyboard open → composer stays above it (<code>.h-app</code>), no page scroll.</li> <li><code>+</code> sheet on phone (ActionSheet) / dropdown on desktop; Camera item only on touch devices with a vision model; Tools / Web search / Structured output only when the model supports them; Temporary chat check mark.</li> <li>Attachments: drag-and-drop highlight, paste image, chips with remove, upload indicator; From library opens <code>FileLibraryPicker</code>.</li> <li>Model pill above the composer opens the picker; AUTO shows "Auto ▾"; Router card on send (sheet on phone) with alternatives and mode segmented; Choose another opens the picker; Always auto-route persists.</li> <li>Context indicator: <code>43K / 200K</code> + bar; simulate 80 % / 95 % (long paste) → warning / critical banner with New chat, Summarize context, Switch to larger model.</li> <li>Cost confirm sheet when estimate > $1 (paste a very long text on an expensive model); one-tap alternative switches and sends.</li> <li>Temporary chat via <code>?temporary=1</code>: badge in header, no URL change after send, no entry in the sidebar; usage still appears in Usage.</li> <li>Messages: hover toolbar on desktop, long-press ActionSheet on phone (copy, retry, retry with another model, compare, quote, save as prompt, export, delete); Details disclosure shows the metadata grid; deprecated model → badge + "Switch to …".</li> <li>Error card: force a bad key → "… rejected your API key." with Providers link; force 429 → countdown on Retry; View details sheet.</li> <li>Streaming: long Markdown answer with tables, code blocks and LaTeX (<code>$$\int_0^1 x\,dx$$</code>) — no flicker of completed blocks; code block Copy / Download / Wrap / Expand.</li> <li>Compare with…: from a response (prompt pre-filled) and from the empty state (editable prompt); desktop grid 2–4 columns, phone swipeable panels with sticky tabs; "Continue with <model>" appends the answer and switches the pill; new-chat case navigates to the created conversation.</li> <li>Header "more" sheet on phone: Model settings, Compare, Pin, Share, New temporary chat, Open Arena, Delete (ConfirmDialog).</li> <li>Scroll-to-bottom pill appears when scrolled up; Enter / ⇧Enter / ⌘Enter / Esc / ⌘/ shortcuts still work.</li> </ol>