From f6735bfc3d978bfb83f705b16f699e52c80f3982 Mon Sep 17 00:00:00 2001 From: Destin Moss Date: Sat, 23 May 2026 15:38:22 -0700 Subject: [PATCH] fix(chat): kill fake user bubble + stuck thinking state after /compact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reported bugs collapse to one parser bug. After /compact, CC writes two consecutive JSONL lines: (1) an isCompactSummary line carrying the actual summary, and (2) a user-type line whose content is [2mCompacted (ctrl+o to see full summary)[22m — CC's dimmed status echo of the local /compact command. The parser's stripSystemTags was UNWRAPPING that tag (keeping inner text), so the second line became user-typed text "Compacted (ctrl+o to see full summary)" that the reducer's TRANSCRIPT_USER_MESSAGE handler dropped into the "no pending match" branch — appending a fake user bubble AND setting isThinking:true with no transcript turn to ever clear it. Fix: move and from the unwrap regex to the strip-entirely regex in both the TS and Kotlin parsers. Locked down by three new regression tests against the exact CC v2.1.119 JSONL fixture line. UX add (requested in the same report): the compact-summary transcript event now carries the summary text from the isCompactSummary line, the reducer attaches it to the SystemMarker, and SystemMarker.tsx offers a chevron + "view summary" toggle that reveals the full CC-produced summary inline. Replaces CC's terminal "ctrl+o to see full summary" affordance, which never worked inside YouCoded. Documented as a CC-coupling in docs/cc-dependencies.md so the review-cc-changes release agent can map future CC changes to this code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/bridge/TranscriptSerializer.kt | 12 +++- .../youcoded/app/parser/TranscriptEvent.kt | 6 +- .../youcoded/app/parser/TranscriptWatcher.kt | 35 ++++++--- .../youcoded/app/runtime/ManagedSession.kt | 2 +- desktop/src/main/transcript-watcher.test.ts | 71 +++++++++++++++++++ desktop/src/main/transcript-watcher.ts | 25 +++++-- desktop/src/renderer/App.tsx | 4 ++ .../src/renderer/components/SystemMarker.tsx | 65 ++++++++++++++--- .../renderer/components/buddy/BubbleFeed.tsx | 2 + desktop/src/renderer/state/chat-reducer.ts | 4 ++ desktop/src/renderer/state/chat-types.ts | 4 ++ desktop/src/shared/types.ts | 7 ++ docs/cc-dependencies.md | 5 ++ 13 files changed, 215 insertions(+), 27 deletions(-) diff --git a/app/src/main/kotlin/com/youcoded/app/bridge/TranscriptSerializer.kt b/app/src/main/kotlin/com/youcoded/app/bridge/TranscriptSerializer.kt index e07d9b2f..2f3fbf91 100644 --- a/app/src/main/kotlin/com/youcoded/app/bridge/TranscriptSerializer.kt +++ b/app/src/main/kotlin/com/youcoded/app/bridge/TranscriptSerializer.kt @@ -103,8 +103,16 @@ object TranscriptSerializer { }) } - fun compactSummary(sessionId: String, uuid: String, timestamp: Long): JSONObject { - return build("compact-summary", sessionId, uuid, timestamp, JSONObject()) + fun compactSummary( + sessionId: String, + uuid: String, + timestamp: Long, + summary: String, + ): JSONObject { + val data = JSONObject().apply { + if (summary.isNotBlank()) put("summary", summary) + } + return build("compact-summary", sessionId, uuid, timestamp, data) } /** streamingText is a custom event — not part of the desktop protocol. Keep flat. */ diff --git a/app/src/main/kotlin/com/youcoded/app/parser/TranscriptEvent.kt b/app/src/main/kotlin/com/youcoded/app/parser/TranscriptEvent.kt index 213cc2a9..8b83cf52 100644 --- a/app/src/main/kotlin/com/youcoded/app/parser/TranscriptEvent.kt +++ b/app/src/main/kotlin/com/youcoded/app/parser/TranscriptEvent.kt @@ -97,10 +97,14 @@ sealed class TranscriptEvent { /** Canonical compaction-complete signal: emitted when Claude Code writes * a {type:"user", isCompactSummary:true} entry. Covers both in-session * /compact (appends to same JSONL, so shrink cannot fire) and - * resume-from-summary (first entry of a new JSONL). */ + * resume-from-summary (first entry of a new JSONL). + * + * `summary` is the full text CC wrote, pre-stripped of system tags. Used + * by the renderer to offer click-to-expand on the otherwise-thin marker. */ data class CompactSummary( override val sessionId: String, override val uuid: String, override val timestamp: Long, + val summary: String = "", ) : TranscriptEvent() } diff --git a/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt b/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt index df3ea67c..198af850 100644 --- a/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt +++ b/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt @@ -33,20 +33,19 @@ class TranscriptWatcher( /** Strip internal XML tags and ANSI escapes that should never appear in rendered content. * - system-reminder: injected system context - * - command-name/message/args: slash-command metadata (strip entirely) - * - local-command-stdout/stderr: command output (keep inner text) + * - command-name/message/args: slash-command metadata + * - local-command-stdout/stderr: dimmed CC echoes of slash-command output + * (unwrapping them surfaced "Compacted (ctrl+o to see full summary)" + * as a fake user bubble after every /compact AND set isThinking:true + * with nothing to clear it — both stripped now to match desktop parity). */ private val STRIP_ENTIRELY_REGEX = Regex( - """<(system-reminder|command-name|command-message|command-args)>[\s\S]*?""", - ) - private val UNWRAP_REGEX = Regex( - """<(local-command-stdout|local-command-stderr)>([\s\S]*?)""", + """<(system-reminder|command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?""", ) private val ANSI_REGEX = Regex("""\u001b\[[0-9;]*[a-zA-Z]""") fun stripSystemTags(text: String): String { var result = STRIP_ENTIRELY_REGEX.replace(text, "") - result = UNWRAP_REGEX.replace(result) { it.groupValues[2] } result = ANSI_REGEX.replace(result, "") return result.trim() } @@ -240,9 +239,27 @@ class TranscriptWatcher( // Compact-summary entry — canonical "compaction finished" signal. // Written after /compact (appended to same JSONL) or resume-from-summary // (first entry of new JSONL). isVisibleInTranscriptOnly=true: suppress - // from chat timeline and emit the dedicated signal instead. + // from chat timeline and emit the dedicated signal instead. Also + // forward the summary text so the chat-side marker can click-to-expand. if (obj.optBoolean("isCompactSummary", false)) { - _events.tryEmit(TranscriptEvent.CompactSummary(sessionId, uuid, timestamp)) + val msg = obj.optJSONObject("message") + val rawContent = when (val c = msg?.opt("content")) { + is String -> c + is JSONArray -> { + val sb = StringBuilder() + for (i in 0 until c.length()) { + val block = c.optJSONObject(i) ?: continue + if (block.optString("type") == "text") { + if (sb.isNotEmpty()) sb.append('\n') + sb.append(block.optString("text", "")) + } + } + sb.toString() + } + else -> "" + } + val summary = stripSystemTags(rawContent) + _events.tryEmit(TranscriptEvent.CompactSummary(sessionId, uuid, timestamp, summary)) return } diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt b/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt index f3290b32..84850716 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/ManagedSession.kt @@ -308,7 +308,7 @@ class ManagedSession( anthropicRequestId = event.anthropicRequestId, ) is TranscriptEvent.StreamingText -> TranscriptSerializer.streamingText(event.sessionId, event.text) - is TranscriptEvent.CompactSummary -> TranscriptSerializer.compactSummary(event.sessionId, event.uuid, event.timestamp) + is TranscriptEvent.CompactSummary -> TranscriptSerializer.compactSummary(event.sessionId, event.uuid, event.timestamp, event.summary) } server.broadcast(JSONObject().apply { put("type", "transcript:event") diff --git a/desktop/src/main/transcript-watcher.test.ts b/desktop/src/main/transcript-watcher.test.ts index ef9043a2..822e414d 100644 --- a/desktop/src/main/transcript-watcher.test.ts +++ b/desktop/src/main/transcript-watcher.test.ts @@ -74,6 +74,77 @@ describe('transcript-watcher interrupt detection', () => { }); }); +// Regression tests for the post-/compact bugs: +// (1) a "Compacted (ctrl+o to see full summary)" user bubble appearing +// after every /compact, and +// (2) chat view permanently stuck in "thinking" state after every /compact. +// Both stemmed from the parser UNWRAPPING , which let CC's +// dimmed stdout echo of /compact reach the reducer's TRANSCRIPT_USER_MESSAGE +// "no pending match" path — appending a fake user bubble AND setting +// isThinking:true with no transcript turn to ever clear it. +describe('transcript-watcher local-command-stdout suppression', () => { + it('emits NO events for the dimmed "Compacted (...)" stdout echo after /compact', () => { + const line = JSON.stringify({ + type: 'user', + promptId: 'pid-1', + uuid: 'u-compact-stdout', + timestamp: '2026-04-29T05:29:38.779Z', + message: { + role: 'user', + // Exact shape CC v2.1.119 writes — ANSI [2m dim wrap around the inner text. + content: 'Compacted (ctrl+o to see full summary)', + }, + }); + const events = parseTranscriptLine(line, 'sess-1'); + expect(events).toEqual([]); + }); + + it('strips the same way', () => { + const line = JSON.stringify({ + type: 'user', + promptId: 'pid-1', + uuid: 'u-stderr', + message: { role: 'user', content: 'boom' }, + }); + expect(parseTranscriptLine(line, 'sess-1')).toEqual([]); + }); +}); + +describe('transcript-watcher compact-summary forwarding', () => { + it('emits compact-summary with the summary text from an isCompactSummary line', () => { + const summary = 'Summary:\n1. Primary Request: do the thing\n2. Files: foo.ts'; + const line = JSON.stringify({ + type: 'user', + isCompactSummary: true, + isVisibleInTranscriptOnly: true, + uuid: 'u-compact', + timestamp: '2026-04-29T05:29:38.615Z', + message: { role: 'user', content: summary }, + }); + const events = parseTranscriptLine(line, 'sess-1'); + expect(events).toEqual([ + expect.objectContaining({ + type: 'compact-summary', + sessionId: 'sess-1', + data: { summary }, + }), + ]); + }); + + it('omits summary field on an isCompactSummary line with empty content', () => { + const line = JSON.stringify({ + type: 'user', + isCompactSummary: true, + uuid: 'u-empty', + message: { role: 'user', content: '' }, + }); + const events = parseTranscriptLine(line, 'sess-1'); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('compact-summary'); + expect(events[0].data).toEqual({}); + }); +}); + describe('cwdToProjectSlug', () => { it('encodes a Windows path without spaces', () => { expect(cwdToProjectSlug('C:\\Users\\alice\\repo')).toBe('C--Users-alice-repo'); diff --git a/desktop/src/main/transcript-watcher.ts b/desktop/src/main/transcript-watcher.ts index 1410e0f3..654760b0 100644 --- a/desktop/src/main/transcript-watcher.ts +++ b/desktop/src/main/transcript-watcher.ts @@ -67,13 +67,18 @@ export function parseTranscriptLine(line: string, sessionId: string): Transcript // isVisibleInTranscriptOnly=true means it's meant to stay hidden from UI — // we suppress the user-message event and emit a dedicated signal that // App.tsx uses to clear compactionPending and finalize the marker. + // Also forward the summary text so the marker can be expanded inline. if (parsed.isCompactSummary) { + const summaryRaw = typeof content === 'string' + ? content + : extractTextFromBlocks(content); + const summary = stripSystemTags(summaryRaw); events.push({ type: 'compact-summary', sessionId, uuid, timestamp, - data: {}, + data: summary ? { summary } : {}, }); return events; } @@ -275,17 +280,25 @@ function extractTextFromBlocks(content: any): string { * the chat timeline. These are injected by Claude Code's harness and * aren't part of the assistant's actual response. * - Tags stripped entirely: system-reminder, task-notification, antml_thinking, - * command-name, command-message, command-args - * - Tags unwrapped (inner text kept): local-command-stdout, local-command-stderr + * command-name, command-message, command-args, + * local-command-stdout, local-command-stderr + * + * Why local-command-stdout/stderr are stripped (not unwrapped): CC writes + * these as dimmed (ANSI [2m) echoes of slash-command output, e.g. + * "Compacted (ctrl+o to see full summary)" after /compact. Unwrapping them + * surfaced the dim text as a user-typed bubble AND tripped the + * TRANSCRIPT_USER_MESSAGE "no pending match" path, which set isThinking: true + * with no transcript turn to ever clear it — leaving chat permanently stuck + * in "thinking" after every compaction. The chat is the canonical + * conversation; the terminal pane already shows raw CC output for users who + * want to see slash-command stdout. */ -const STRIP_ENTIRELY_RE = /<(task-notification|system-reminder|antml_thinking|command-name|command-message|command-args)>[\s\S]*?<\/\1>/g; -const UNWRAP_RE = /<(local-command-stdout|local-command-stderr)>([\s\S]*?)<\/\1>/g; +const STRIP_ENTIRELY_RE = /<(task-notification|system-reminder|antml_thinking|command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g; const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; function stripSystemTags(text: string): string { return text .replace(STRIP_ENTIRELY_RE, '') - .replace(UNWRAP_RE, (_match, _tag, inner) => inner) .replace(ANSI_RE, '') .trim(); } diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index 36c6f6f7..04902702 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -814,6 +814,10 @@ function AppInner() { sessionId: event.sessionId, markerId: `compact-done-${Date.now()}`, afterContextTokens: contextTokens, + // Forward CC's summary text so the SystemMarker can offer + // click-to-expand (replaces the dead "ctrl+o to see full summary" + // affordance from CC's TUI, which never worked inside YouCoded). + ...(event.data.summary ? { summary: event.data.summary } : {}), }); } break; diff --git a/desktop/src/renderer/components/SystemMarker.tsx b/desktop/src/renderer/components/SystemMarker.tsx index da60ef52..f3643981 100644 --- a/desktop/src/renderer/components/SystemMarker.tsx +++ b/desktop/src/renderer/components/SystemMarker.tsx @@ -1,24 +1,73 @@ -import React from 'react'; +import React, { useState } from 'react'; import type { SystemMarker as SystemMarkerData } from '../state/chat-types'; // Thin horizontal divider used for /clear and /compact markers. Permanent — // user sees "these messages end here" when scrolling back. Deliberately quiet // styling so it doesn't compete with actual conversation content. +// +// When a /compact marker carries a `summary` (CC-produced conversation summary +// from the isCompactSummary line), clicking the label toggles an inline panel +// that reveals the full summary. Replaces CC's terminal "ctrl+o" affordance, +// which never worked inside YouCoded because the chat view consumes that key. interface Props { marker: SystemMarkerData; } export default function SystemMarker({ marker }: Props) { + const [expanded, setExpanded] = useState(false); const time = new Date(marker.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + const expandable = !!marker.summary; + return ( -
-
- - {marker.label} - · {time} - -
+
+
+
+ {expandable ? ( + // Render as a button so screen readers + keyboard navigation get + // the expand affordance for free. + + ) : ( + + {marker.label} + · {time} + + )} +
+
+ {expandable && expanded && ( + // Inline panel — quiet styling, scrolls independently for long + // summaries. Pre-wrap preserves CC's numbered-list formatting; + // break-words so long paths/URLs wrap rather than overflow. +
+
+ Compaction summary +
+
+            {marker.summary}
+          
+
+ )}
); } diff --git a/desktop/src/renderer/components/buddy/BubbleFeed.tsx b/desktop/src/renderer/components/buddy/BubbleFeed.tsx index e89e562d..fde50e84 100644 --- a/desktop/src/renderer/components/buddy/BubbleFeed.tsx +++ b/desktop/src/renderer/components/buddy/BubbleFeed.tsx @@ -191,6 +191,8 @@ export function BubbleFeed({ sessionId }: Props) { sessionId: event.sessionId, markerId: `compact-done-${Date.now()}`, afterContextTokens: null, + // Forward summary so buddy's marker matches main window's expandable behavior. + ...(event.data.summary ? { summary: event.data.summary } : {}), }); } break; diff --git a/desktop/src/renderer/state/chat-reducer.ts b/desktop/src/renderer/state/chat-reducer.ts index 72f0f1ca..b43f70b1 100644 --- a/desktop/src/renderer/state/chat-reducer.ts +++ b/desktop/src/renderer/state/chat-reducer.ts @@ -995,6 +995,10 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { timestamp: Date.now(), label, variant: 'compact', + // Attaches the CC-produced summary text so the otherwise-thin + // marker can click-to-expand inline. Absent on aborted/watchdog + // completions (no summary available). + ...(action.summary ? { summary: action.summary } : {}), }, }, ], diff --git a/desktop/src/renderer/state/chat-types.ts b/desktop/src/renderer/state/chat-types.ts index 890aef17..38e7eb64 100644 --- a/desktop/src/renderer/state/chat-types.ts +++ b/desktop/src/renderer/state/chat-types.ts @@ -73,6 +73,9 @@ export interface SystemMarker { timestamp: number; label: string; // e.g. "Conversation cleared" variant?: 'clear' | 'compact' | 'info'; // For styling hooks + // Optional long-form text the marker can reveal on click. Currently only + // set on compact markers — the actual conversation summary CC produced. + summary?: string; } // /copy [N] picker — shown inline when the Nth assistant turn has multiple @@ -344,6 +347,7 @@ export type ChatAction = markerId: string; afterContextTokens: number | null; aborted?: boolean; // true when watchdog fires — marker text differs + summary?: string; // Full compaction summary, surfaced as expandable section under the marker } // /copy picker for multi-block turns | { diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 39dfa348..d71c4595 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -120,6 +120,13 @@ export interface TranscriptEvent { * (plain) vs `[Request interrupted by user for tool use]` (tool-use). */ kind?: 'plain' | 'tool-use'; + /** + * Populated on `compact-summary` events. The full text of the compaction + * summary CC wrote into the JSONL — pre-stripped of system tags. The + * reducer attaches it to the SystemMarker so the user can click-to-expand + * the otherwise-thin "Compacted" divider. + */ + summary?: string; }; } diff --git a/docs/cc-dependencies.md b/docs/cc-dependencies.md index a4d94663..24fbe907 100644 --- a/docs/cc-dependencies.md +++ b/docs/cc-dependencies.md @@ -53,6 +53,11 @@ Update this table when you re-run snapshots after a CC version bump. Anything th - **Depends on:** JSONL entries in `~/.claude/projects//*.jsonl` with fields `type`, `message.role`, `message.content[]` (including `text`, `tool_use`, `tool_result`, `thinking` block shapes), `message.usage`, `requestId`, `stop_reason`, and per-turn heartbeats for extended-thinking models - **Break symptom:** Transcript events stop dispatching; chat UI goes silent while CC still runs. Per-turn metadata (model, usage, requestId, stopReason) disappears from turn bubbles and attention banners. +### Post-/compact stdout echo + isCompactSummary line shape +- **Files:** `desktop/src/main/transcript-watcher.ts`, `app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt` +- **Depends on:** Two consecutive JSONL lines CC writes when `/compact` completes: (1) an `isCompactSummary: true` user-type line carrying the full conversation summary in `message.content`, and (2) an immediately-following user-type line whose `message.content` is `Compacted (ctrl+o to see full summary)` (CC's dimmed status echo of the local /compact command). The parser strips `` / `` ENTIRELY (not unwrap) — see the long comment on `STRIP_ENTIRELY_RE` in `transcript-watcher.ts`. Unwrapping let CC's dimmed echo reach the reducer's `TRANSCRIPT_USER_MESSAGE` "no pending match" path, which both (a) appended a fake user bubble reading "Compacted (ctrl+o to see full summary)" and (b) set `isThinking: true` with no transcript turn to ever clear it, leaving chat permanently stuck thinking after every `/compact`. The `isCompactSummary` line's `message.content` is what powers the click-to-expand summary inside the thin "Compacted · freed X tokens" marker; if CC stops emitting that line, the marker still appears (driven by COMPACTION_PENDING + the shrink backup path) but loses its expand affordance. +- **Break symptom:** If CC reintroduces a different stdout-echo wrapper tag, the fake bubble + stuck-thinking pair returns. If CC stops setting `isCompactSummary: true`, the COMPACTION_COMPLETE path falls back to the shrink-detection branch in App.tsx — works for typed `/compact` but loses summary text. If CC starts wrapping the summary in something other than plain text in `message.content`, the expandable panel shows the wrong thing or stays empty. + ### Per-turn metadata fields - **Files:** `desktop/src/renderer/state/chat-reducer.ts` (`TRANSCRIPT_TURN_COMPLETE`, `TRANSCRIPT_ASSISTANT_TEXT` handlers) - **Depends on:** `message.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}`, outer `requestId` (Anthropic `req_…`), `stop_reason` values (`end_turn`, `max_tokens`, `refusal`, `stop_sequence`, `pause_turn`), Anthropic model ID in `message.model`