fix Claude pane hang: cap tail, parse off-isolate, coalesce notifies
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 27s
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 27s
The native Claude pane (T-137) picks the newest transcript JSONL by mtime — which on a live session is this multi-MB active file. On attach it read and parsed the whole file synchronously on the UI isolate and fired notifyListeners per item, freezing the app. Three fixes: cap the initial read to a recent tail (256KB, injectable for tests); run JSON parsing in a background isolate via Isolate.run; coalesce the controller's notifications with a zero-duration Timer so a burst collapses into one rebuild (a microtask-scheduled notify wouldn't — stream events deliver one per microtask and the notify interleaves). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <munged>/<sid>/subagents/agent-<hex>.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;
|
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 <munged>/<sid>/subagents/agent-<hex>.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', '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;
|
||||||
|
|||||||
@@ -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 <munged>/<sid>/subagents/agent-<hex>.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);
|
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 <munged>/<sid>/subagents/agent-<hex>.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.', '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);
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- Daemon-not-connected on startup — panels and the Claude pane raced
|
||||||
the socket loopback. Requests now wait briefly for an in-flight
|
the socket loopback. Requests now wait briefly for an in-flight
|
||||||
connection, the server isn't restarted for the same workspace, and
|
connection, the server isn't restarted for the same workspace, and
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class ConversationController extends ChangeNotifier {
|
|||||||
final Future<void> Function()? _onDispose;
|
final Future<void> Function()? _onDispose;
|
||||||
late final StreamSubscription<ConversationItem> _sub;
|
late final StreamSubscription<ConversationItem> _sub;
|
||||||
final List<ConversationItem> _items = [];
|
final List<ConversationItem> _items = [];
|
||||||
|
Timer? _notifyTimer;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
/// Items in arrival (transcript) order.
|
/// Items in arrival (transcript) order.
|
||||||
List<ConversationItem> get items => List.unmodifiable(_items);
|
List<ConversationItem> get items => List.unmodifiable(_items);
|
||||||
@@ -41,11 +43,22 @@ class ConversationController extends ChangeNotifier {
|
|||||||
|
|
||||||
void _onItem(ConversationItem item) {
|
void _onItem(ConversationItem item) {
|
||||||
_items.add(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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_notifyTimer?.cancel();
|
||||||
unawaited(_sub.cancel());
|
unawaited(_sub.cancel());
|
||||||
unawaited(_onDispose?.call());
|
unawaited(_onDispose?.call());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ library;
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data model
|
// Data model
|
||||||
@@ -145,6 +146,12 @@ String _shortId(String uuid) => uuid.length >= 8 ? uuid.substring(0, 8) : uuid;
|
|||||||
// Reader
|
// 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.
|
/// Known major transcript versions.
|
||||||
const _knownMajorVersions = {1, 2};
|
const _knownMajorVersions = {1, 2};
|
||||||
|
|
||||||
@@ -174,14 +181,20 @@ class TranscriptReader {
|
|||||||
Duration pollInterval = const Duration(milliseconds: 500),
|
Duration pollInterval = const Duration(milliseconds: 500),
|
||||||
void Function(String)? onWarn,
|
void Function(String)? onWarn,
|
||||||
String? projectsBase,
|
String? projectsBase,
|
||||||
|
int? initialTailBytes,
|
||||||
}) : _pollInterval = pollInterval,
|
}) : _pollInterval = pollInterval,
|
||||||
_onWarn = onWarn ?? _defaultWarn,
|
_onWarn = onWarn ?? _defaultWarn,
|
||||||
_projectsBase = projectsBase ?? _defaultProjectsBase();
|
_projectsBase = projectsBase ?? _defaultProjectsBase(),
|
||||||
|
_initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes;
|
||||||
|
|
||||||
final String workspacePath;
|
final String workspacePath;
|
||||||
final Duration _pollInterval;
|
final Duration _pollInterval;
|
||||||
final void Function(String) _onWarn;
|
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
|
/// Base dir holding the per-workspace transcript dirs. Defaults to
|
||||||
/// `~/.claude/projects`; overridable so tests point the real reader at a
|
/// `~/.claude/projects`; overridable so tests point the real reader at a
|
||||||
/// temp directory instead of the user's home.
|
/// temp directory instead of the user's home.
|
||||||
@@ -276,11 +289,16 @@ class TranscriptReader {
|
|||||||
if (newest == null) return;
|
if (newest == null) return;
|
||||||
|
|
||||||
if (newest != _currentPath) {
|
if (newest != _currentPath) {
|
||||||
// Session switch — reset cursor so we replay from the beginning of the
|
// New session file. Start from the recent tail rather than byte 0:
|
||||||
// new file. We intentionally re-emit items from the new file start;
|
// an active transcript can be many MB (thousands of records), and
|
||||||
// a future UI layer can de-dup by uuid if required.
|
// 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;
|
_currentPath = newest;
|
||||||
_cursor = 0;
|
final length = await File(newest).length();
|
||||||
|
_cursor = length > _initialTailBytes ? length - _initialTailBytes : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _tail(controller, newest);
|
await _tail(controller, newest);
|
||||||
@@ -291,199 +309,186 @@ class TranscriptReader {
|
|||||||
final length = await file.length();
|
final length = await file.length();
|
||||||
if (length <= _cursor) return; // no new bytes
|
if (length <= _cursor) return; // no new bytes
|
||||||
|
|
||||||
|
final String chunk;
|
||||||
final raf = await file.open();
|
final raf = await file.open();
|
||||||
try {
|
try {
|
||||||
await raf.setPosition(_cursor);
|
await raf.setPosition(_cursor);
|
||||||
final newBytes = await raf.read(length - _cursor);
|
final newBytes = await raf.read(length - _cursor);
|
||||||
_cursor = length;
|
_cursor = length;
|
||||||
|
chunk = utf8.decode(newBytes, allowMalformed: true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await raf.close();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// Parse a single JSONL line into its items (forwarding any version
|
||||||
// Parsing — pure: takes a JSONL line, returns the items it yields.
|
/// warnings to [onWarn]). Public so tests exercise the real parser.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
List<ConversationItem> parseLine(String line) {
|
List<ConversationItem> parseLine(String line) {
|
||||||
Map<String, dynamic> envelope;
|
final parsed = parseTranscriptChunk(line);
|
||||||
try {
|
for (final w in parsed.warnings) {
|
||||||
envelope = (jsonDecode(line) as Map).cast<String, dynamic>();
|
_onWarn(w);
|
||||||
} catch (_) {
|
|
||||||
return const []; // malformed JSON — skip silently
|
|
||||||
}
|
}
|
||||||
|
return parsed.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Version drift-guard.
|
/// Result of [parseTranscriptChunk]: parsed items + version-drift warnings.
|
||||||
final rawVersion = envelope['version'] as String?;
|
typedef ParsedChunk = ({List<ConversationItem> items, List<String> warnings});
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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? ?? '';
|
ParsedChunk parseTranscriptChunk(String chunk) {
|
||||||
final isSidechain = envelope['isSidechain'] as bool? ?? false;
|
final items = <ConversationItem>[];
|
||||||
|
final warnings = <String>[];
|
||||||
|
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;
|
void _parseLineInto(String line, List<ConversationItem> out, List<String> warnings) {
|
||||||
try {
|
Map<String, dynamic> envelope;
|
||||||
timestamp = DateTime.parse(envelope['timestamp'] as String? ?? '');
|
try {
|
||||||
} catch (_) {
|
envelope = (jsonDecode(line) as Map).cast<String, dynamic>();
|
||||||
timestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
} catch (_) {
|
||||||
}
|
return; // malformed JSON — skip silently
|
||||||
|
|
||||||
final out = <ConversationItem>[];
|
|
||||||
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 _parseUser(
|
final rawVersion = envelope['version'] as String?;
|
||||||
Map<String, dynamic> envelope,
|
if (rawVersion != null) {
|
||||||
String uuid,
|
final dotIdx = rawVersion.indexOf('.');
|
||||||
DateTime timestamp,
|
final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion;
|
||||||
bool isSidechain,
|
final major = int.tryParse(majorStr);
|
||||||
List<ConversationItem> out,
|
if (major != null && !_knownMajorVersions.contains(major)) {
|
||||||
) {
|
warnings.add('unfamiliar transcript version "$rawVersion" (major=$major); '
|
||||||
final message = envelope['message'] as Map?;
|
'parsing will degrade gracefully');
|
||||||
if (message == null) return;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final content = message['content'];
|
final type = envelope['type'] as String?;
|
||||||
|
if (type == null || _skipTypes.contains(type)) return;
|
||||||
|
|
||||||
if (content is String) {
|
final uuid = envelope['uuid'] as String? ?? '';
|
||||||
// Plain string content.
|
final isSidechain = envelope['isSidechain'] as bool? ?? false;
|
||||||
if (content.isNotEmpty) {
|
|
||||||
out.add(UserMessage(
|
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<String, dynamic> envelope,
|
||||||
|
String uuid,
|
||||||
|
DateTime timestamp,
|
||||||
|
bool isSidechain,
|
||||||
|
List<ConversationItem> 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 = <String>[];
|
||||||
|
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,
|
uuid: uuid,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isSidechain: isSidechain,
|
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,
|
||||||
));
|
));
|
||||||
}
|
default:
|
||||||
return;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
if (content is! List) return;
|
|
||||||
|
|
||||||
// Array content — may contain text parts and/or tool_result parts.
|
|
||||||
final textParts = <String>[];
|
|
||||||
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'),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (textParts.isNotEmpty) {
|
||||||
|
out.add(UserMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: textParts.join('\n')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _parseAssistant(
|
void _parseAssistantInto(
|
||||||
Map<String, dynamic> envelope,
|
Map<String, dynamic> envelope,
|
||||||
String uuid,
|
String uuid,
|
||||||
DateTime timestamp,
|
DateTime timestamp,
|
||||||
bool isSidechain,
|
bool isSidechain,
|
||||||
List<ConversationItem> out,
|
List<ConversationItem> out,
|
||||||
) {
|
) {
|
||||||
final message = envelope['message'] as Map?;
|
final message = envelope['message'] as Map?;
|
||||||
if (message == null) return;
|
if (message == null) return;
|
||||||
|
final content = message['content'];
|
||||||
|
if (content is! List) return;
|
||||||
|
|
||||||
final content = message['content'];
|
for (final item in content) {
|
||||||
if (content is! List) return;
|
if (item is! Map) continue;
|
||||||
|
switch (item['type'] as String?) {
|
||||||
for (final item in content) {
|
case 'text':
|
||||||
if (item is! Map) continue;
|
final text = item['text'] as String? ?? '';
|
||||||
final itemType = item['type'] as String?;
|
if (text.isNotEmpty) {
|
||||||
switch (itemType) {
|
out.add(AssistantTextMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, text: text));
|
||||||
case 'text':
|
}
|
||||||
final text = item['text'] as String? ?? '';
|
case 'thinking':
|
||||||
if (text.isNotEmpty) {
|
final thinking = item['thinking'] as String? ?? '';
|
||||||
out.add(AssistantTextMessage(
|
if (thinking.isNotEmpty) {
|
||||||
uuid: uuid,
|
out.add(AssistantThinkingMessage(uuid: uuid, timestamp: timestamp, isSidechain: isSidechain, thinking: thinking));
|
||||||
timestamp: timestamp,
|
}
|
||||||
isSidechain: isSidechain,
|
case 'tool_use':
|
||||||
text: text,
|
final rawInput = item['input'];
|
||||||
));
|
out.add(AssistantToolUse(
|
||||||
}
|
uuid: uuid,
|
||||||
case 'thinking':
|
timestamp: timestamp,
|
||||||
final thinking = item['thinking'] as String? ?? '';
|
isSidechain: isSidechain,
|
||||||
if (thinking.isNotEmpty) {
|
toolUseId: item['id'] as String? ?? '',
|
||||||
out.add(AssistantThinkingMessage(
|
name: item['name'] as String? ?? '',
|
||||||
uuid: uuid,
|
input: rawInput is Map ? rawInput.cast<String, dynamic>() : <String, dynamic>{},
|
||||||
timestamp: timestamp,
|
));
|
||||||
isSidechain: isSidechain,
|
default:
|
||||||
thinking: thinking,
|
break;
|
||||||
));
|
|
||||||
}
|
|
||||||
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<String, dynamic>() : <String, dynamic>{};
|
|
||||||
out.add(AssistantToolUse(
|
|
||||||
uuid: uuid,
|
|
||||||
timestamp: timestamp,
|
|
||||||
isSidechain: isSidechain,
|
|
||||||
toolUseId: toolUseId,
|
|
||||||
name: name,
|
|
||||||
input: input,
|
|
||||||
));
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,15 @@ void main() {
|
|||||||
expect(c.isEmpty, isTrue);
|
expect(c.isEmpty, isTrue);
|
||||||
ctrl.add(_user('hi'));
|
ctrl.add(_user('hi'));
|
||||||
ctrl.add(_asst('hello'));
|
ctrl.add(_asst('hello'));
|
||||||
await Future<void>.delayed(Duration.zero);
|
// Wait past the coalescing timer (zero-duration, fires after the
|
||||||
|
// microtask queue drains).
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
|
||||||
expect(c.items, hasLength(2));
|
expect(c.items, hasLength(2));
|
||||||
expect(c.items.first, isA<UserMessage>());
|
expect(c.items.first, isA<UserMessage>());
|
||||||
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();
|
await ctrl.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -669,6 +669,43 @@ void main() {
|
|||||||
expect(collected, hasLength(1));
|
expect(collected, hasLength(1));
|
||||||
expect((collected.first as UserMessage).text, 'real message');
|
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 = <ConversationItem>[];
|
||||||
|
final sub = reader.stream.listen(collected.add);
|
||||||
|
|
||||||
|
await Future<void>.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<AssistantTextMessage>().last.text,
|
||||||
|
'reply number 199',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user