AI assistant: descriptive error messages and live progress#162
Open
josephschorr wants to merge 2 commits into
Open
AI assistant: descriptive error messages and live progress#162josephschorr wants to merge 2 commits into
josephschorr wants to merge 2 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
A non-200 from /api/ai previously surfaced verbatim in the chat as terse,
unactionable text ("Failed — Invalid request body", "Failed — Rate limit
exceeded"), which told the user neither what went wrong nor what to try.
Centralize all user-facing AI error copy in api/_lib/aiErrors.ts, which also
absorbs the existing describeTurnError so mid-stream and pre-stream wording
live side by side. Each pre-stream condition now maps to a code plus a
complete, actionable sentence:
503 disabled the assistant is turned off, check back later
500 server_config temporarily unavailable, try again later
400 bad_request start a new chat, then resend
429 rate_limit sending too quickly, wait a moment
405 method_not_allowed POST only
The 500 copy deliberately leaks no internal detail; the operator-facing cause
(a missing OPENROUTER_API_KEY) stays in a server-side log. retryAfter stays
structural rather than baked into the sentence, so the client's existing
"(retry in Xs)" suffix doesn't render the number twice.
The browser adds a fallback only for responses that never reached the route
at all -- CDN/proxy 5xx, gateway timeouts -- which carry no server-composed
body. When the server did send copy, that copy wins.
Also drops the literal "Failed — " prefix from the chat bubble: the
destructive styling and alert icon already signal failure, and the new copy
reads as a complete sentence.
The assistant showed a static "Thinking…" for an entire turn, and tool activity chips only appeared after each tool had already finished. Work that ran server-side was invisible entirely: when a round trip calls only server tools, no handoff is emitted, so the browser received nothing at all until the turn completed. Surface what the assistant is actually doing, on two coordinated surfaces: - A live status line that names the current step -- "Editing document", "Running check", "Reading design references". - Tool chips that appear with a spinner the moment a tool starts and then resolve to their summary, instead of only materializing once done. Mechanically: - New `status` SSE event. The server announces each server tool before running it, via a `progressLabel` on the tool definition. It is a transient display signal only -- it never enters the conversation sent to the model, and server steps deliberately leave no permanent chip. Older cached clients ignore the unknown event, so the API can deploy ahead of the browser. - The turn controller now brackets every client tool with onToolStart / onToolEnd (correlated by tool call id) rather than reporting once, after the fact. Start is emitted before argument validation so every displayed call is guaranteed a matching end and no chip can spin forever. Clearing the status label runs in a finally around the stream loop, so an abort or a mid-stream failure cannot leave a label stuck. - The store gains a transient `activity` label (never persisted) and ToolActivity.status of running/ok/error, replacing the ok boolean. The persist version intentionally stays at 3: bumping it would run migrate and wipe the user's conversation for what is a cosmetic field change. Chips persisted under the old shape have no status, so only an explicit "error" renders as a failure. - Every client tool is synchronous, so the controller yields a macrotask between starting a tool and executing it. Without that, both store writes land in one task, React batches them, and the in-progress state never paints. All activity writes reuse the existing generation guard, so a reset landing mid-turn cannot resurrect a stale turn's label into a fresh conversation.
josephschorr
force-pushed
the
ai-assistant-errors-and-progress
branch
from
July 24, 2026 00:53
18ee33b to
6d8ed49
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two improvements to the in-app AI assistant.
1. Descriptive, actionable error messages
A non-200 from
/api/aipreviously surfaced verbatim in the chat as terse, unactionable text —Failed — Invalid request body,Failed — Rate limit exceeded— telling the user neither what went wrong nor what to try.All user-facing AI error copy now lives in one module,
api/_lib/aiErrors.ts, which also absorbs the existingdescribeTurnErrorso mid-stream and pre-stream wording sit side by side:disabledserver_configbad_requestrate_limitmethod_not_allowedOPENROUTER_API_KEY) stays in a server-side log, and a test asserts it never appears in the response.retryAfterstays structural rather than baked into the sentence, so the client's existing(retry in Xs)suffix doesn't render the number twice.Failed —prefix is gone: the destructive styling and alert icon already signal failure, and the new copy reads as a complete sentence.2. Live progress
The assistant showed a static
Thinking…for an entire turn, and tool chips only appeared after each tool finished. Server-side work was invisible entirely — when a round trip calls only server tools no handoff is emitted, so the browser got nothing until the turn ended.Two coordinated surfaces now show what's happening:
Thinking…/Working…when nothing specific is running.How it works
statusSSE event. The server announces each server tool before running it, via aprogressLabelon the tool definition. It's a transient display signal only: it never enters the conversation sent to the model, and server steps deliberately leave no permanent chip. Older cached clients ignore the unknown event, so the API can deploy ahead of the browser.onToolStart/onToolEnd(correlated by tool call id) instead of reporting once after the fact. Start fires before argument validation, so every displayed call is guaranteed a matching end and no chip can spin forever. Clearing the status label runs in afinallyaround the stream loop, so an abort or mid-stream failure can't leave a label stuck.activitylabel (never persisted) andToolActivity.statusofrunning/ok/error, replacing theokboolean.Notes for review
versionintentionally stays at 3. Bumping it runsmigrate, which wipes the user's actual conversation for what is a cosmetic field change. The consequence is that chips persisted under the old{ ok: boolean }shape have nostatus, so the chip treats only an explicit"error"as a failure and renders anything else in the muted "done" style. A legacy failed chip loses its red until it ages out of the persisted window — the tradeoff was preferred over discarding chat history.generationguard, so areset()landing mid-turn can't resurrect a stale turn's label into a fresh conversation.Testing
tsc --noEmit,vite build,oxlinton all touched files: cleanNot yet exercised against a live model — worth one manual pass in-app to confirm the status line and spinners read well at real latencies.