fix(chat): show Claude Code's interrupt marker as a stopped line, not a user message

AV
28m ago
+463-21

Links

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.
  • deriveTimeline turns a replayed marker into a user_stop timeline 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) and userStopOf(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 a clientId (typed in this chat), was forwarded by a parent agent (from: "parent"), or has more than one part.
  • collapseWorkLog / deriveTimeline: adds the TimelineEntry kind { kind: "user_stop", item, stop }, placed where the marker was. DeriveTimelineOptions.harness scopes it, and useTimeline passes SessionState.harness.
  • Desktop transcriptRows: a user_stop entry becomes an interrupted turn_status row that carries userStop. The interrupted state of TurnStatusRow now renders StoppedDivider: a small stop square, muted text-xs copy, 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: a user_stop entry becomes the existing "Stopped" status line.
  • chat-runtime transcriptBefore (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/chat 117 pass, packages/chat-runtime 451 pass (2 skip), apps/mobile 257 pass.
  • bun test in apps/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 Lexical HISTORY_MERGE_TAG init errors that the base commit also shows. None are in touched code.
  • bun run typecheck: desktop, packages/chat, and packages/chat-runtime are clean. apps/mobile reports 187 errors, all in files this PR does not touch.
  • bun run check:i18n passes. Both new strings are translated in all 17 locales.
  • biome check on the changed files is clean.
  • New tests: the recogniser (including harness, parent and clientId cases), deriveTimeline, the stored-journal-to-rows path in transcriptRows, TurnStatusRow copy, and mobile chatRows.

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 status interrupted when 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 sessions resume. 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.