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:
@@ -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