diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 3184224c..65108742 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2140,3 +2140,4 @@ Refinement (2026-05-22, refinement-agent review): --- Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks answered from real artifacts + tmux manual + synthetic control-mode test (no live run/quota). Key risk for T-139: pane->teammate->transcript identity joins only on agentType (config <-> .meta.json), ambiguous for same-type teammates -> needs joinedAt/mtime or lead-transcript tiebreaker. tmux 3.6a has no %pane-died; poll list-panes -a. Teammate transcripts at //subagents/agent-.jsonl + .meta.json{agentType,description}.', NULL, '2026-05-22 17:55:11', '2026-05-22 17:55:11', '2026-05-22 17:55:11', NULL, 'dbf4f56d1ec9d2234f255826c5f88f68', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 17:58:07', '2026-05-22 17:58:07', '2026-05-22 17:58:07', NULL, '4e67262d4b04f8541e71fe8f03106fbf', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'in_progress', 'done', NULL, '2026-05-22 18:14:32', '2026-05-22 18:14:32', '2026-05-22 18:14:32', NULL, 'a284c7249dda3c63a52a202bad07f105', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 55cf670e..f2b20885 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2485,3 +2485,4 @@ Refinement (2026-05-22, refinement-agent review): --- Findings (2026-05-22): docs/spikes/cc-team-internals-2.1.148.md. All 5 checks answered from real artifacts + tmux manual + synthetic control-mode test (no live run/quota). Key risk for T-139: pane->teammate->transcript identity joins only on agentType (config <-> .meta.json), ambiguous for same-type teammates -> needs joinedAt/mtime or lead-transcript tiebreaker. tmux 3.6a has no %pane-died; poll list-panes -a. Teammate transcripts at //subagents/agent-.jsonl + .meta.json{agentType,description}.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:55:11', NULL, '2f9a445df8c60beafe126aff339410b8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 17:58:07', NULL, 'e8244b90386314574545f0c1a7bd012c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '6651a2c3c9099f7d38553f6cf9e41e98', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index c50f5c70..645de5e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Claude pane no longer freezes the app on open — the transcript reader + caps its initial read to the recent tail, parses off the UI isolate, + and coalesces view notifications into one rebuild per burst (T-137). - Daemon-not-connected on startup — panels and the Claude pane raced the socket loopback. Requests now wait briefly for an in-flight connection, the server isn't restarted for the same workspace, and diff --git a/lib/builtin/claude/src/conversation_controller.dart b/lib/builtin/claude/src/conversation_controller.dart index 2ccd6c46..7ab8777a 100644 --- a/lib/builtin/claude/src/conversation_controller.dart +++ b/lib/builtin/claude/src/conversation_controller.dart @@ -33,6 +33,8 @@ class ConversationController extends ChangeNotifier { final Future Function()? _onDispose; late final StreamSubscription _sub; final List _items = []; + Timer? _notifyTimer; + bool _disposed = false; /// Items in arrival (transcript) order. List get items => List.unmodifiable(_items); @@ -41,11 +43,22 @@ class ConversationController extends ChangeNotifier { void _onItem(ConversationItem item) { _items.add(item); - notifyListeners(); + // Coalesce notifications: the reader emits a burst (the initial tail + // read), and a notify-per-item would thrash the view's rebuild + + // auto-scroll. A zero-duration Timer fires only after the microtask + // queue drains — the stream delivers one event per microtask, so a + // microtask-scheduled notify would interleave between deliveries and + // fire per item. The timer collapses a whole burst into one rebuild. + _notifyTimer ??= Timer(Duration.zero, () { + _notifyTimer = null; + if (!_disposed) notifyListeners(); + }); } @override void dispose() { + _disposed = true; + _notifyTimer?.cancel(); unawaited(_sub.cancel()); unawaited(_onDispose?.call()); super.dispose(); diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 03dafb2d..86dc2854 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -31,6 +31,7 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; // --------------------------------------------------------------------------- // Data model @@ -145,6 +146,12 @@ String _shortId(String uuid) => uuid.length >= 8 ? uuid.substring(0, 8) : uuid; // Reader // --------------------------------------------------------------------------- +/// On first attach to a session file, read at most this many recent +/// bytes (not the whole file) — an active transcript can be many MB and +/// parsing it all synchronously would freeze the UI. Appends after that +/// stream incrementally. +const _defaultInitialTailBytes = 256 * 1024; + /// Known major transcript versions. const _knownMajorVersions = {1, 2}; @@ -174,14 +181,20 @@ class TranscriptReader { Duration pollInterval = const Duration(milliseconds: 500), void Function(String)? onWarn, String? projectsBase, + int? initialTailBytes, }) : _pollInterval = pollInterval, _onWarn = onWarn ?? _defaultWarn, - _projectsBase = projectsBase ?? _defaultProjectsBase(); + _projectsBase = projectsBase ?? _defaultProjectsBase(), + _initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes; final String workspacePath; final Duration _pollInterval; final void Function(String) _onWarn; + /// Max bytes of recent history to read when first attaching to a + /// session file (overridable for tests). + final int _initialTailBytes; + /// Base dir holding the per-workspace transcript dirs. Defaults to /// `~/.claude/projects`; overridable so tests point the real reader at a /// temp directory instead of the user's home. @@ -276,11 +289,16 @@ class TranscriptReader { if (newest == null) return; if (newest != _currentPath) { - // Session switch — reset cursor so we replay from the beginning of the - // new file. We intentionally re-emit items from the new file start; - // a future UI layer can de-dup by uuid if required. + // New session file. Start from the recent tail rather than byte 0: + // an active transcript can be many MB (thousands of records), and + // parsing the whole thing synchronously on first attach freezes the + // UI. Read at most [_initialTailBytes] of recent history, then stream + // appends. A partial first line (from landing mid-record) simply + // fails to JSON-parse and is skipped. Older scrollback is a future + // load-more concern. _currentPath = newest; - _cursor = 0; + final length = await File(newest).length(); + _cursor = length > _initialTailBytes ? length - _initialTailBytes : 0; } await _tail(controller, newest); @@ -291,199 +309,186 @@ class TranscriptReader { final length = await file.length(); if (length <= _cursor) return; // no new bytes + final String chunk; final raf = await file.open(); try { await raf.setPosition(_cursor); final newBytes = await raf.read(length - _cursor); _cursor = length; - - final chunk = utf8.decode(newBytes, allowMalformed: true); - final lines = chunk.split('\n'); - - for (final raw in lines) { - final line = raw.trim(); - if (line.isEmpty) continue; - for (final item in parseLine(line)) { - controller.add(item); - } - } + chunk = utf8.decode(newBytes, allowMalformed: true); } finally { await raf.close(); } + if (controller.isClosed) return; + + // Parse off the UI isolate — the initial chunk can be sizeable and + // JSON-decoding it on the main thread would jank the frame. + final parsed = await Isolate.run(() => parseTranscriptChunk(chunk)); + if (controller.isClosed) return; + for (final w in parsed.warnings) { + _onWarn(w); + } + for (final item in parsed.items) { + if (controller.isClosed) break; + controller.add(item); + } } - // --------------------------------------------------------------------------- - // Parsing — pure: takes a JSONL line, returns the items it yields. - // - // Public so tests exercise the real parser directly (no duplicate). The tail - // loop above feeds the returned items into the stream. Malformed JSON and - // skip/unknown types yield an empty list; the version drift-guard warns via - // [onWarn] but still parses what it can. - // --------------------------------------------------------------------------- - + /// Parse a single JSONL line into its items (forwarding any version + /// warnings to [onWarn]). Public so tests exercise the real parser. List parseLine(String line) { - Map envelope; - try { - envelope = (jsonDecode(line) as Map).cast(); - } catch (_) { - return const []; // malformed JSON — skip silently + final parsed = parseTranscriptChunk(line); + for (final w in parsed.warnings) { + _onWarn(w); } + return parsed.items; + } +} - // Version drift-guard. - final rawVersion = envelope['version'] as String?; - if (rawVersion != null) { - final dotIdx = rawVersion.indexOf('.'); - final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion; - final major = int.tryParse(majorStr); - if (major != null && !_knownMajorVersions.contains(major)) { - _onWarn('unfamiliar transcript version "$rawVersion" (major=$major); ' - 'parsing will degrade gracefully'); - } - } +/// Result of [parseTranscriptChunk]: parsed items + version-drift warnings. +typedef ParsedChunk = ({List items, List warnings}); - final type = envelope['type'] as String?; - if (type == null || _skipTypes.contains(type)) return const []; +// --------------------------------------------------------------------------- +// Parsing — pure + isolate-safe. Top-level (no instance state) so it can run +// via Isolate.run. Malformed JSON and skip/unknown types are dropped; an +// unfamiliar major `version` adds a warning but parsing still proceeds. +// --------------------------------------------------------------------------- - final uuid = envelope['uuid'] as String? ?? ''; - final isSidechain = envelope['isSidechain'] as bool? ?? false; +ParsedChunk parseTranscriptChunk(String chunk) { + final items = []; + final warnings = []; + for (final raw in chunk.split('\n')) { + final line = raw.trim(); + if (line.isEmpty) continue; + _parseLineInto(line, items, warnings); + } + return (items: items, warnings: warnings); +} - DateTime timestamp; - try { - timestamp = DateTime.parse(envelope['timestamp'] as String? ?? ''); - } catch (_) { - timestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - } - - final out = []; - switch (type) { - case 'user': - _parseUser(envelope, uuid, timestamp, isSidechain, out); - case 'assistant': - _parseAssistant(envelope, uuid, timestamp, isSidechain, out); - default: - // Unknown type — degrade gracefully (don't emit, don't crash). - break; - } - return out; +void _parseLineInto(String line, List out, List warnings) { + Map envelope; + try { + envelope = (jsonDecode(line) as Map).cast(); + } catch (_) { + return; // malformed JSON — skip silently } - void _parseUser( - Map envelope, - String uuid, - DateTime timestamp, - bool isSidechain, - List out, - ) { - final message = envelope['message'] as Map?; - if (message == null) return; + final rawVersion = envelope['version'] as String?; + if (rawVersion != null) { + final dotIdx = rawVersion.indexOf('.'); + final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion; + final major = int.tryParse(majorStr); + if (major != null && !_knownMajorVersions.contains(major)) { + warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); ' + 'parsing will degrade gracefully'); + } + } - final content = message['content']; + final type = envelope['type'] as String?; + if (type == null || _skipTypes.contains(type)) return; - if (content is String) { - // Plain string content. - if (content.isNotEmpty) { - out.add(UserMessage( + final uuid = envelope['uuid'] as String? ?? ''; + final isSidechain = envelope['isSidechain'] as bool? ?? false; + + DateTime timestamp; + try { + timestamp = DateTime.parse(envelope['timestamp'] as String? ?? ''); + } catch (_) { + timestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + } + + switch (type) { + case 'user': + _parseUserInto(envelope, uuid, timestamp, isSidechain, out); + case 'assistant': + _parseAssistantInto(envelope, uuid, timestamp, isSidechain, out); + default: + break; // unknown type — degrade gracefully + } +} + +void _parseUserInto( + Map envelope, + String uuid, + DateTime timestamp, + bool isSidechain, + List out, +) { + final message = envelope['message'] as Map?; + if (message == null) return; + final content = message['content']; + + if (content is String) { + if (content.isNotEmpty) { + out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: content)); + } + return; + } + if (content is! List) return; + + final textParts = []; + for (final item in content) { + if (item is! Map) continue; + switch (item['type'] as String?) { + case 'text': + final text = item['text'] as String? ?? ''; + if (text.isNotEmpty) textParts.add(text); + case 'tool_result': + final rawContent = item['content']; + out.add(ToolResultMessage( uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, - text: content, + toolUseId: item['tool_use_id'] as String? ?? '', + content: rawContent is String ? rawContent : jsonEncode(rawContent), + isError: item['is_error'] as bool? ?? false, )); - } - return; - } - - if (content is! List) return; - - // Array content — may contain text parts and/or tool_result parts. - final textParts = []; - for (final item in content) { - if (item is! Map) continue; - final itemType = item['type'] as String?; - switch (itemType) { - case 'text': - final text = item['text'] as String? ?? ''; - if (text.isNotEmpty) textParts.add(text); - case 'tool_result': - final toolUseId = item['tool_use_id'] as String? ?? ''; - final rawContent = item['content']; - final resultContent = rawContent is String ? rawContent : jsonEncode(rawContent); - final isError = item['is_error'] as bool? ?? false; - out.add(ToolResultMessage( - uuid: uuid, - timestamp: timestamp, - isSidechain: isSidechain, - toolUseId: toolUseId, - content: resultContent, - isError: isError, - )); - default: - break; - } - } - - if (textParts.isNotEmpty) { - out.add(UserMessage( - uuid: uuid, - timestamp: timestamp, - isSidechain: isSidechain, - text: textParts.join('\n'), - )); + default: + break; } } + if (textParts.isNotEmpty) { + out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n'))); + } +} - void _parseAssistant( - Map envelope, - String uuid, - DateTime timestamp, - bool isSidechain, - List out, - ) { - final message = envelope['message'] as Map?; - if (message == null) return; +void _parseAssistantInto( + Map envelope, + String uuid, + DateTime timestamp, + bool isSidechain, + List out, +) { + final message = envelope['message'] as Map?; + if (message == null) return; + final content = message['content']; + if (content is! List) return; - final content = message['content']; - if (content is! List) return; - - for (final item in content) { - if (item is! Map) continue; - final itemType = item['type'] as String?; - switch (itemType) { - case 'text': - final text = item['text'] as String? ?? ''; - if (text.isNotEmpty) { - out.add(AssistantTextMessage( - uuid: uuid, - timestamp: timestamp, - isSidechain: isSidechain, - text: text, - )); - } - case 'thinking': - final thinking = item['thinking'] as String? ?? ''; - if (thinking.isNotEmpty) { - out.add(AssistantThinkingMessage( - uuid: uuid, - timestamp: timestamp, - isSidechain: isSidechain, - thinking: thinking, - )); - } - case 'tool_use': - final toolUseId = item['id'] as String? ?? ''; - final name = item['name'] as String? ?? ''; - final rawInput = item['input']; - final input = rawInput is Map ? rawInput.cast() : {}; - out.add(AssistantToolUse( - uuid: uuid, - timestamp: timestamp, - isSidechain: isSidechain, - toolUseId: toolUseId, - name: name, - input: input, - )); - default: - break; - } + for (final item in content) { + if (item is! Map) continue; + switch (item['type'] as String?) { + case 'text': + final text = item['text'] as String? ?? ''; + if (text.isNotEmpty) { + out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: text)); + } + case 'thinking': + final thinking = item['thinking'] as String? ?? ''; + if (thinking.isNotEmpty) { + out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, thinking: thinking)); + } + case 'tool_use': + final rawInput = item['input']; + out.add(AssistantToolUse( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + toolUseId: item['id'] as String? ?? '', + name: item['name'] as String? ?? '', + input: rawInput is Map ? rawInput.cast() : {}, + )); + default: + break; } } } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index c4cdcbdf..2650d8a2 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -56,11 +56,15 @@ void main() { expect(c.isEmpty, isTrue); ctrl.add(_user('hi')); ctrl.add(_asst('hello')); - await Future.delayed(Duration.zero); + // Wait past the coalescing timer (zero-duration, fires after the + // microtask queue drains). + await Future.delayed(const Duration(milliseconds: 20)); expect(c.items, hasLength(2)); expect(c.items.first, isA()); - expect(notifications, 2); + // Notifications are coalesced: a burst of items collapses to a + // single notify so the view rebuilds once, not per item. + expect(notifications, 1); await ctrl.close(); }); diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index bb2b7acc..1c9017b8 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -669,6 +669,43 @@ void main() { expect(collected, hasLength(1)); expect((collected.first as UserMessage).text, 'real message'); }); + + test('initial attach reads only the recent tail (initialTailBytes cap)', () async { + final projectDir = mungedDir(tempBase, workspace); + await projectDir.create(recursive: true); + final sessionFile = File('${projectDir.path}/session-abc.jsonl'); + + // A long pre-existing transcript — the kind that froze the UI when + // parsed in full on attach. + writeLines(sessionFile, [ + for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i'), + ]); + + // Cap the initial read well below the file size so only the last + // records are within the tail window. + final reader = TranscriptReader( + workspace, + projectsBase: tempBase.path, + pollInterval: const Duration(milliseconds: 20), + initialTailBytes: 256, + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + await Future.delayed(const Duration(milliseconds: 200)); + + await sub.cancel(); + await reader.dispose(); + + // Far fewer than the 200 records — only the recent tail was read. + expect(collected, isNotEmpty); + expect(collected.length, lessThan(200)); + // The most recent record is always intact at the end of the file. + expect( + collected.whereType().last.text, + 'reply number 199', + ); + }); }); }