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

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:
2026-05-22 20:30:30 +02:00
co-authored by Claude Opus 4.7
parent 76c1d24904
commit a6ed22405f
7 changed files with 237 additions and 173 deletions
@@ -56,11 +56,15 @@ void main() {
expect(c.isEmpty, isTrue);
ctrl.add(_user('hi'));
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.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();
});
@@ -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 = <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',
);
});
});
}