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`