fix(chat): show Claude Code's interrupt marker as a stopped line, not a user message
Links
- Before/after captures (org only): https://app.superset.sh/page/interrupt-marker-in-chat-ihq3aa
Summary
[Request interrupted by user]and[Request interrupted by user for tool use]no longer show as user bubbles in reopened, resumed, or CLI-to-chat forked chats. They render as a quiet stopped line where the stop happened.deriveTimelineturns a replayed marker into auser_stoptimeline entry. Prompt recall (↑), the prompt rail, message edit, and the handoff transcript stop treating it as a prompt. This holds on desktop and mobile, for new replays and for chats already stored in a journal.
Why / Context
When you stop Claude Code, the CLI writes a user message whose whole text is the marker. On session/load and session/resume, claude-agent-acp replays that message as a user_message_chunk. It strips command and system-reminder tags, but not this marker. The ACP adapter then records it as a user message. Live chats never showed it: the adapter drops live user echoes, and a live stop arrives as turn status interrupted.
Which action writes which marker, as seen in the session JSONL:
- Stop while the model replies or thinks:
[Request interrupted by user]. - Esc at the Claude Code CLI permission prompt, or Esc while an approved tool is running: the same refusal
tool_result(toolUseResult: "User rejected tool use"), then[Request interrupted by user for tool use]. The marker does not say whether the tool ran. A probe that appended a line each second wrote 19 of 30 lines before the stop. - No on the chat's own approval card: no marker. The turn continues.
How It Works
packages/chat/src/core/userStop:userStopMarker(text)anduserStopOf(item, harness)recognise both markers, allowing whitespace around them. Only Claude sessions count (claudeBookkeeping.harnesses:claude-acp,claude-code). A user message is never read as a stop if it has aclientId(typed in this chat), was forwarded by a parent agent (from: "parent"), or has more than one part.collapseWorkLog/deriveTimeline: adds theTimelineEntrykind{ kind: "user_stop", item, stop }, placed where the marker was.DeriveTimelineOptions.harnessscopes it, anduseTimelinepassesSessionState.harness.- Desktop
transcriptRows: auser_stopentry becomes an interruptedturn_statusrow that carriesuserStop. The interrupted state ofTurnStatusRownow rendersStoppedDivider: a small stop square, mutedtext-xscopy, and a hairline. The copy is "You stopped this response", "You stopped this response at a tool call", or "Stopped" when the cause is unknown. - Mobile
chatRows: auser_stopentry becomes the existing "Stopped" status line. chat-runtimetranscriptBefore(fork and rewind) keeps the marker item, so a forked chat keeps its stops.
Manual QA Checklist
Run on this worktree's dev app over CDP with real Claude sessions. Each "before" was captured on the base commit, and each "after" on this branch, on the same session.
- Stop during thinking, then reopen the chat (a host restart replays it). Base: a
[Request interrupted by user]bubble. Branch: "You stopped this response". - Fork into CLI, press Esc at the permission prompt, then Fork into chat. Base: a
[… for tool use]bubble, and the prompt rail counts 6. Branch: the tool-use divider (captured with the earlier wording "…before a tool ran", since changed to "…at a tool call"), and the rail counts the 4 real prompts. - ↑ in the empty composer recalls the last real prompt, not the marker.
- A live stop renders the divider with "Stopped".
- The divider has no edit or resend affordances.
- Mobile was not run on a simulator. It is covered by unit tests only.
Testing
bun test:packages/chat117 pass,packages/chat-runtime451 pass (2 skip),apps/mobile257 pass.bun testinapps/desktop: 9300 pass, 49 fail. 38 are git, worktree, shell-env, and native-messaging tests that fail only when run from a worktree path. 11 are LexicalHISTORY_MERGE_TAGinit errors that the base commit also shows. None are in touched code.bun run typecheck: desktop,packages/chat, andpackages/chat-runtimeare clean.apps/mobilereports 187 errors, all in files this PR does not touch.bun run check:i18npasses. Both new strings are translated in all 17 locales.biome checkon the changed files is clean.- New tests: the recogniser (including harness, parent and
clientIdcases),deriveTimeline, the stored-journal-to-rows path intranscriptRows,TurnStatusRowcopy, and mobilechatRows.
Design Decisions
- Why
deriveTimeline, not the ACP adapter. An adapter fix leaves every chat already stored in a journal showing the bubble. Also,liveSession.fail()sets turn statusinterruptedwhen the harness dies, so "the user stopped it" has to come from the marker itself, or from a new protocol field. A single client data layer covers desktop, mobile, every consumer of user messages, and both old and new data. - Why a new entry kind, not a rewrite of turns. Replay folds the next prompt into the marker's turn (
[marker, P2, reply]). A prompt stopped before any reply keeps the marker in its own turn ([P, marker]). An entry in place keeps both positions right without splitting turns. - Live stops use the same line. Every interrupted turn now renders
StoppedDivider. A live stop keeps the word "Stopped", because the cause is not known.
Known Limitations
- Mobile shows "Stopped", not the two new sentences.
- Not exercised end to end: resume from the history picker, resume after an update, and
superset sessionsresume. They use the same replay path, per the code.
Summary by cubic
Fixes the chat transcript so Claude Code's interrupt markers render as a quiet stopped line instead of a user message bubble in reopened, resumed, and forked chats.
Previously, Claude Code records a stop as a user message whose text is [Request interrupted by user] (or ... for tool use]), and replaying session history surfaced it as a prompt in the transcript, prompt recall, the prompt rail, and message edit. The timeline now recognizes these markers as a user_stop entry at the position where the stop happened.
- Desktop shows "You stopped this response" or "You stopped this response at a tool call"; the stop is also shown live, and mobile keeps the existing "Stopped" line.
- Markers are read only in Claude sessions; the same text in a Codex or OpenCode session, or one a parent agent forwarded, stays a prompt.
- Live interrupts without a marker still show "Stopped", since the cause isn't known from the protocol alone.
- The fix lives in the shared client data layer, so it also corrects chats already stored in a journal.
- Markers typed by the user in the chat (carrying a
clientId) are never mistaken for stops, and forked chats keep their stops.
Summary by CodeRabbit
- New Features
- Chat transcripts show when you stopped a response, with a distinct notice when it stopped at a tool call.
- Stop notices appear in their original position in conversation history on desktop and mobile.
- Stop notices are available in supported languages.