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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user