interrupted by user, not an error
pressing stop ended the turn with "Claude Code returned an error" — the agent sdk reports interrupt() as an ordinary failed result, indistinguishable from a real fault downstream. the sidecar now flags the session it interrupted and rewrites that event to the existing durable 'stopped', which opencode already emitted. escape stops the turn (bound to the chat subtree, not the document), and the prompt comes back to the composer verbatim unless you've started typing something else. history parity: claude files [Request interrupted by user] as a user message, so the transcript reader maps those exact strings to the same role instead of replaying them as something you typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -411,14 +411,14 @@ every navigation, so clicking anything at all wiped it even without a refresh.
|
||||
deliberately not allowed to know which tab they are in, or that other tabs exist — it's the same
|
||||
boundary that stops a page enumerating your windows. So there is no id to key the name on.
|
||||
|
||||
There doesn't need to be: **`sessionStorage` *is* the per-tab store.** It's separate per tab, it
|
||||
There doesn't need to be: **`sessionStorage` _is_ the per-tab store.** It's separate per tab, it
|
||||
survives a refresh and in-place navigation, and it's discarded when the tab closes. That is exactly the
|
||||
lifetime a tab name wants. (`localStorage` would be wrong in the obvious way — every tab would share
|
||||
one name, which is the problem, not the fix.)
|
||||
|
||||
So the name is read from `sessionStorage` at module load and mirrored into a `useGlobal` entry; the
|
||||
React Query copy is what re-renders the header, and the storage copy is what survives the reload. The
|
||||
route title is no longer *assigned* to the state, it's **derived** — `label ?? titleForPath(pathname)`.
|
||||
route title is no longer _assigned_ to the state, it's **derived** — `label ?? titleForPath(pathname)`.
|
||||
Navigation therefore retitles the tab by itself when you haven't named it, and leaves it alone when you
|
||||
have. Clearing the field is the one way back to the route name, and there's no third state to get stuck
|
||||
in.
|
||||
@@ -438,9 +438,56 @@ keystroke before, which was fine while the title was a plain string; now that an
|
||||
the route name", deleting the last character would have snapped the input to "Chat" under the cursor.
|
||||
Escape discards the draft.
|
||||
|
||||
**Not verified:** the duplicate-tab navigation type, in an actual browser. The rest — that the name
|
||||
survives a refresh and a navigation — follows from sessionStorage's specified behaviour, but the clone
|
||||
heuristic is the part I could only reason about.
|
||||
**Verified in the browser.** The clone heuristic was the one part I could only reason about; you tested
|
||||
it and it behaves as designed — a duplicated tab keeps the _page_ (React Query cache and all) but not
|
||||
the _name_, and the two tabs then diverge.
|
||||
|
||||
---
|
||||
|
||||
## 17. Stop means stop, not "Claude Code returned an error"
|
||||
|
||||
Pressing stop mid-turn ended with a red destructive bubble reading **"Claude Code returned an error"**.
|
||||
Nothing had gone wrong; you had told it to stop. Three changes, one per part of the problem.
|
||||
|
||||
**Where the lie came from.** The Agent SDK reports an `interrupt()` as an ordinary failed `result` —
|
||||
`is_error` set, no text. Downstream that is indistinguishable from the harness genuinely falling over,
|
||||
and `stream-parser.ts` correctly turned it into an error event. The only process that can tell the two
|
||||
apart is the one that called `interrupt()`, so it now says so: `PersistentSession` carries an
|
||||
`interrupted` flag, set in `interruptClaudeSession` **before** the `await` (the failed `result` can land
|
||||
while `interrupt()` is still resolving), and the consumer loop rewrites an `error` event to `stopped`
|
||||
while it is set. Any turn ending clears it, so a later real error can't wear it.
|
||||
|
||||
`{type:'stopped'}` was already in the wire protocol, already marked durable in `turn-stream.ts`, and
|
||||
already emitted by OpenCode's runner — so this is the two harnesses converging on one behaviour rather
|
||||
than a new message. The client had simply been settling to idle on it in silence, which looked the same
|
||||
as the turn just ending. It now commits whatever the agent had said (it happened; it stays) and appends
|
||||
a divider line: **INTERRUPTED BY USER**, muted, not red. `stream-parser.ts` keeps its error string —
|
||||
that message is still right for an actual failure.
|
||||
|
||||
**Escape stops the turn**, as it does in Claude Code. The handler is bound to the chat's own subtree,
|
||||
not the document: two chat panels can be generating at once and a document listener in each would make
|
||||
one Escape stop both, quite apart from colliding with dialog dismissal. The composer handles its own
|
||||
Escape and stops it bubbling, so a standalone `InputArea` still works and one keypress never fires two
|
||||
stops.
|
||||
|
||||
**Your prompt comes back.** Interrupting almost always means "not like that" — you want to say it
|
||||
differently — and retyping it out of the transcript is busywork. The composer is refilled with the text
|
||||
exactly as typed, newlines and all (the raw input, not the trimmed-and-prefixed prompt that went to the
|
||||
model), and refocused. It never overwrites: if you started composing something else while it ran, that
|
||||
wins and the old prompt is dropped. Losing what you just typed to a stop you pressed would be the worse
|
||||
failure. The stop button gets this too — it's the same function.
|
||||
|
||||
**History matches.** Claude records an interruption by writing `[Request interrupted by user]` (or
|
||||
`…for tool use`) as the _user's_ next message; that is how the model is told on the next turn that it
|
||||
was cut off. Replayed literally, your transcript showed a message you never typed. The server now maps
|
||||
those two exact strings to the same `interrupted` role, so a reloaded session looks like a live one.
|
||||
Matched whole-string only — that text appears _inside_ real messages too (this very conversation being
|
||||
one), and those are genuinely yours. Nothing is written back into Claude's store; this is read-side
|
||||
reinterpretation only.
|
||||
|
||||
**Not verified:** the browser. The reasoning above is from the code and from grepping your real
|
||||
transcript store (54 plain markers, 11 tool-use ones); the flag's race behaviour in particular is
|
||||
reasoned, not observed. Typecheck and the sidecar tests are clean.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user