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:
2026-08-07 01:18:57 +00:00
co-authored by Claude Opus 5
parent f3a3ae3b64
commit 29fc9722c1
9 changed files with 154 additions and 13 deletions
+52 -5
View File
@@ -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.
---
+16 -3
View File
@@ -344,7 +344,17 @@ export type ClaudeChatMessage =
// virtualised list can key on. It has to be visible, not smoothed over: the parts read as one
// conversation to you, but the agent's context was emptied at this line and nothing above it is in
// its memory.
| { role: 'divider'; sessionId: string };
| { role: 'divider'; sessionId: string }
/** A turn you stopped. See `INTERRUPTION_MARKERS`. */
| { role: 'interrupted' };
/**
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
* model is told, on the next turn, that it was cut off. Replayed literally it reads as something you
* typed, so the transcript showed a message you never sent. Matched whole-string only: this text appears
* inside real messages too (this file's own conversation being one), and those are genuinely yours.
*/
const INTERRUPTION_MARKERS = new Set(['[Request interrupted by user]', '[Request interrupted by user for tool use]']);
type ContentBlock =
| { type: 'text'; text?: string }
@@ -366,6 +376,9 @@ function blockText(content: unknown): string {
return '';
}
const userOrInterruption = (text: string): ClaudeChatMessage =>
INTERRUPTION_MARKERS.has(text.trim()) ? { role: 'interrupted' } : { role: 'user', text };
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
@@ -397,13 +410,13 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
if (entry.type === 'user' && !entry.isMeta) {
if (typeof content === 'string') {
if (content.trim()) messages.push({ role: 'user', text: content });
if (content.trim()) messages.push(userOrInterruption(content));
continue;
}
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text?.trim()) {
messages.push({ role: 'user', text: block.text });
messages.push(userOrInterruption(block.text));
} else if (block.type === 'tool_result') {
const tool = toolById.get(block.tool_use_id);
if (tool) {
+23 -2
View File
@@ -163,6 +163,13 @@ type PersistentSession = {
emit: (event: ChatEvent) => void;
isGenerating: boolean;
pendingTasks: Set<string>; // background tasks started but not yet notified; suppress idle-GC while non-empty
/**
* The user pressed stop and we are waiting for the turn to fall over. The SDK reports an interrupt as
* an ordinary failed `result` — `is_error` with no text — which is indistinguishable downstream from
* the harness actually breaking, and reached the user as "Claude Code returned an error". Only the
* side that called `interrupt()` knows better, so it says so here.
*/
interrupted: boolean;
idleTimer?: ReturnType<typeof setTimeout>;
};
@@ -227,6 +234,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
emit: onEvent,
isGenerating: false,
pendingTasks: new Set<string>(),
interrupted: false,
};
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
@@ -259,7 +267,12 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
session.pushTurn = (prompt: string) => {
if (session.idleTimer) clearTimeout(session.idleTimer);
session.isGenerating = true;
input.push({ type: 'user', message: { role: 'user', content: prompt }, parent_tool_use_id: null, session_id: sessionKey });
input.push({
type: 'user',
message: { role: 'user', content: prompt },
parent_tool_use_id: null,
session_id: sessionKey,
});
};
sessions.set(sessionKey, session);
@@ -268,7 +281,9 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
// and (re)arm the idle timer; the session process stays alive so later task_notifications still flow.
void (async () => {
const state = createParseState();
const emit = (event: ChatEvent) => {
const emit = (raw: ChatEvent) => {
// A turn we interrupted ends in a failed `result`. That is the stop landing, not a fault.
const event: ChatEvent = raw.type === 'error' && session.interrupted ? { type: 'stopped' } : raw;
if (event.type === 'task:started') {
// Work is running — hold off idle-GC until it finishes.
session.pendingTasks.add(event.taskId);
@@ -281,6 +296,8 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
if (!session.isGenerating && session.pendingTasks.size === 0) armIdle(session);
} else if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') {
session.isGenerating = false;
// Whatever ended the turn, the interrupt is spent — a later genuine error must not wear it.
session.interrupted = false;
if (session.pendingTasks.size === 0) armIdle(session);
}
session.emit(event);
@@ -323,11 +340,15 @@ export async function spawnClaudeStreaming(
export async function interruptClaudeSession(sessionKey: string): Promise<boolean> {
const session = sessions.get(sessionKey);
if (!session) return false;
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
// consumer loop reads this flag to tell a stop from a fault.
session.interrupted = true;
try {
await session.query.interrupt();
session.isGenerating = false;
return true;
} catch {
session.interrupted = false;
return false;
}
}
@@ -27,8 +27,11 @@ type EmbeddableChatProps = {
export const EmbeddableChat = ({ className, onMessageComplete, ...params }: EmbeddableChatProps) => {
const manager = useEmbeddableChat(params, onMessageComplete);
// Escape stops the turn from anywhere in the chat, not only the composer — you might have clicked into
// the transcript to read what it was doing when you decided to stop it. Keydown bubbles, so one handler
// on the container covers the whole subtree without a document-level listener.
return (
<div className={`flex flex-col ${className ?? ''}`}>
<div className={`flex flex-col ${className ?? ''}`} onKeyDown={manager.handleEscape}>
<MessageList manager={manager} />
<InputArea manager={manager} />
</div>
@@ -47,7 +47,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
setSelectedModel,
setThinkingLevel,
sendPrompt,
stopGeneration,
stopGeneration: stopTurn,
loadOlder,
hasMoreOlder,
isLoadingOlder,
@@ -76,6 +76,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const bottomRef = useRef<HTMLDivElement | null>(null);
const userScrolledRef = useRef(false);
const didInitialScrollRef = useRef(false);
// The prompt currently in flight, kept verbatim so a stop can put it back in the composer.
const lastSentRef = useRef('');
// Latest values for the (once-attached) scroll listener, without re-subscribing on every page load.
const loadOlderRef = useRef(loadOlder);
@@ -119,12 +121,46 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
);
attachmentManager.clearAttachments();
lastSentRef.current = input; // verbatim, not the trimmed prompt — this is what comes back on a stop
setInput('');
userScrolledRef.current = false;
if (textareaRef.current) textareaRef.current.style.height = 'auto';
};
/**
* Stop, and hand the prompt back. Interrupting almost always means "not like that" — you want to say
* it differently — and retyping it from the transcript is busywork. The text returns exactly as typed,
* newlines and all.
*
* It never overwrites: if you've 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.
*/
const stopGeneration = () => {
stopTurn();
const sent = lastSentRef.current;
lastSentRef.current = '';
if (!sent || input.trim()) return;
setInput(sent);
requestAnimationFrame(() => textareaRef.current?.focus());
};
// Escape is the stop button, as it is in Claude Code itself. Bound to the chat's own subtree rather
// than the document: two chat panels can be generating at once, and a document listener in each would
// make one Escape stop both.
const handleEscape = (ev: KeyboardEvent<HTMLElement>) => {
if (ev.key !== 'Escape' || !isGenerating) return;
ev.preventDefault();
stopGeneration();
};
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
if (ev.key === 'Escape') {
// Handled here so a standalone InputArea still stops on Escape; stopped from bubbling so the
// container's copy of the same handler doesn't fire a second stop for one keypress.
ev.stopPropagation();
handleEscape(ev);
return;
}
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleSend();
@@ -254,6 +290,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
setInput,
handleSend,
handleKeyDown,
handleEscape,
appendToInput,
attachments: attachmentManager.attachments,
attachWebpage: attachmentManager.attachWebpage,
@@ -208,6 +208,18 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
case 'task':
return <TaskActivity message={message} />;
case 'interrupted':
return (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<CircleSlash className="h-3 w-3" />
Interrupted by user
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
case 'error':
return (
<div className="flex justify-start group">
@@ -38,6 +38,9 @@ export type ChatMessage =
}
| { role: 'result'; cost: MessageCost }
| { role: 'error'; text: string }
// You pressed stop. Deliberately not an `error` — the turn did what you told it to, and dressing that
// in red destructive chrome (which is what it looked like) reads as "something went wrong".
| { role: 'interrupted' }
// Where a `/clear` fell inside a resumed conversation. The server splices the parts of a chain into
// one transcript (see `loadChainTranscript`), and this is the seam — deliberately visible, because
// your history runs straight through it and the agent's context does not. `sessionId` is the part
@@ -331,7 +331,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
case 'stopped':
// Whatever the agent had said by then stays — it happened — with a line under it saying where
// you cut it off. Silently settling to idle was indistinguishable from the turn just ending.
commitStreaming();
setMessages((prev) => [...prev, { role: 'interrupted' }]);
setIsGenerating(false);
break;
@@ -44,7 +44,9 @@ export type ClaudeSessionMessage =
isError?: boolean;
}
/** The seam between two parts of a merged `/clear` chain. `sessionId` is the part that ends there. */
| { role: 'divider'; sessionId: string };
| { role: 'divider'; sessionId: string }
/** A turn that was stopped. Claude files its own marker as a user message; the server unpicks it. */
| { role: 'interrupted' };
export type ClaudeSessionDetail = {
id: string;