claude_session_viewer/lib/services/jsonl_parser.dart
Mathias Beaulieu-Duncan 659dade82d perf: Phase 1 critical performance fixes + production macOS build
Performance:
- Move JSON parsing to background isolate via Isolate.run() (eliminates 2-4s UI freeze)
- Cache filteredEntries with key-based invalidation (eliminates O(n) recomputation)
- Debounce search queries at 300ms (prevents cascade rebuilds on keystroke)
- Flatten timeline from 2-level turn×response Column to single virtualized ListView.builder
- Add RepaintBoundary per timeline item (isolates repaints during scroll)
- Use context.select for granular rebuilds instead of top-level context.watch
- Lazy ExpandableCard: child not built until first expand (replaces AnimatedCrossFade)
- Use IndexedStack in AppShell (preserves screen state across tab switches)

Fixes:
- Collect parse errors instead of silently swallowing them
- Show parse error count in timeline filter bar
- Fix overflow in tokens screen pie chart legend

Build:
- Configure Developer ID signing with hardened runtime for production distribution
- Enable secure timestamps for notarization
- Update app name to Claude Session Viewer
- Signed, notarized, stapled DMG distribution
2026-04-07 13:32:13 -04:00

323 lines
8.8 KiB
Dart

import 'dart:convert';
import 'dart:io' as io;
import 'dart:isolate';
import '../models/agent_info.dart';
import '../models/content_block.dart';
import '../models/log_entry.dart';
import '../models/token_usage.dart';
class ParseError {
final int? line;
final String? source;
final String error;
const ParseError({this.line, this.source, required this.error});
}
class ParseResult {
final SessionLog sessionLog;
final List<ParseError> errors;
const ParseResult(this.sessionLog, this.errors);
}
class _IsolateInput {
final List<String> mainLines;
final String sessionFileName;
final Map<String, List<String>> subagentLines;
final Map<String, String> subagentMetaRaw;
const _IsolateInput({
required this.mainLines,
required this.sessionFileName,
required this.subagentLines,
required this.subagentMetaRaw,
});
}
class JsonlParser {
Future<ParseResult> parseFile(String filePath) async {
final file = io.File(filePath);
final mainLines = await file.readAsLines(encoding: utf8);
final sessionFileName =
file.uri.pathSegments.last.replaceAll('.jsonl', '');
final parentDir = file.parent.path;
final subagentsDir =
io.Directory('$parentDir/$sessionFileName/subagents');
final subagentLines = <String, List<String>>{};
final subagentMetaRaw = <String, String>{};
if (await subagentsDir.exists()) {
await for (final entity in subagentsDir.list()) {
if (entity is io.File) {
if (entity.path.endsWith('.jsonl')) {
final agentFileName = entity.uri.pathSegments.last;
final agentId = agentFileName
.replaceAll('.jsonl', '')
.replaceFirst('agent-', '');
try {
final lines = await entity.readAsLines(encoding: utf8);
subagentLines[agentId] = lines;
} catch (_) {}
} else if (entity.path.endsWith('.meta.json')) {
final agentFileName = entity.uri.pathSegments.last;
final agentId = agentFileName
.replaceAll('.meta.json', '')
.replaceFirst('agent-', '');
try {
subagentMetaRaw[agentId] = await entity.readAsString();
} catch (_) {}
}
}
}
}
// Run CPU-bound parsing in a background isolate
return Isolate.run(
() => _parseInIsolate(_IsolateInput(
mainLines: mainLines,
sessionFileName: sessionFileName,
subagentLines: subagentLines,
subagentMetaRaw: subagentMetaRaw,
)),
);
}
}
ParseResult _parseInIsolate(_IsolateInput input) {
final errors = <ParseError>[];
final entries = <LogEntry>[];
for (var i = 0; i < input.mainLines.length; i++) {
final line = input.mainLines[i];
if (line.trim().isEmpty) continue;
try {
final json = jsonDecode(line) as Map<String, dynamic>;
entries.add(LogEntry.fromJson(json));
} catch (e) {
errors.add(ParseError(line: i, error: e.toString()));
}
}
// Parse subagent entries
final subagentFiles = <String, List<LogEntry>>{};
for (final entry in input.subagentLines.entries) {
final agentEntries = <LogEntry>[];
for (var i = 0; i < entry.value.length; i++) {
final line = entry.value[i];
if (line.trim().isEmpty) continue;
try {
final json = jsonDecode(line) as Map<String, dynamic>;
agentEntries.add(LogEntry.fromJson(json));
} catch (e) {
errors.add(ParseError(
line: i, source: entry.key, error: e.toString()));
}
}
if (agentEntries.isNotEmpty) {
subagentFiles[entry.key] = agentEntries;
}
}
// Parse subagent metadata
final subagentMeta = <String, Map<String, dynamic>>{};
for (final entry in input.subagentMetaRaw.entries) {
try {
subagentMeta[entry.key] =
jsonDecode(entry.value) as Map<String, dynamic>;
} catch (e) {
errors.add(ParseError(source: entry.key, error: e.toString()));
}
}
final sessionLog = _buildSessionLog(entries, subagentFiles, subagentMeta);
return ParseResult(sessionLog, errors);
}
SessionLog _buildSessionLog(
List<LogEntry> entries,
Map<String, List<LogEntry>> subagentFiles,
Map<String, Map<String, dynamic>> subagentMeta,
) {
// Extract metadata from first user/assistant entry
String? sessionId, cwd, version, gitBranch;
for (final entry in entries) {
if (entry.sessionId != null) {
sessionId = entry.sessionId;
cwd = entry.cwd;
version = entry.version;
gitBranch = entry.gitBranch;
break;
}
}
_linkToolResults(entries);
for (final agentEntries in subagentFiles.values) {
_linkToolResults(agentEntries);
}
final mainConversation = entries
.where(
(e) => e.type != 'progress' && e.type != 'file-history-snapshot')
.toList();
final agents = _buildAgents(mainConversation, subagentFiles, subagentMeta);
final mainAgent = agents.firstWhere((a) => a.id == 'main');
var totalUsage = const TokenUsage();
for (final agent in agents) {
totalUsage = totalUsage + agent.aggregatedUsage;
}
final toolsByName = <String, List<ToolUseBlock>>{};
for (final agent in agents) {
for (final tool in agent.toolsUsed) {
toolsByName.putIfAbsent(tool.name, () => []).add(tool);
}
}
final timestamps = entries
.where((e) => e.timestamp != null)
.map((e) => e.timestamp!)
.toList();
timestamps.sort();
return SessionLog(
sessionId: sessionId,
cwd: cwd,
version: version,
gitBranch: gitBranch,
allEntries: entries,
mainConversation: mainConversation,
agents: agents,
mainAgent: mainAgent,
totalUsage: totalUsage,
toolsByName: toolsByName,
startTime: timestamps.isNotEmpty ? timestamps.first : null,
endTime: timestamps.isNotEmpty ? timestamps.last : null,
);
}
void _linkToolResults(List<LogEntry> entries) {
final resultMap = <String, ToolResultData>{};
for (final entry in entries) {
if (entry is UserEntry && entry.isToolResult) {
for (final result in entry.toolResults) {
resultMap[result.toolUseId] = result;
}
}
}
for (final entry in entries) {
if (entry is AssistantEntry) {
for (final block in entry.toolUseBlocks) {
block.linkedResult = resultMap[block.id];
}
}
}
}
List<AgentInfo> _buildAgents(
List<LogEntry> mainEntries,
Map<String, List<LogEntry>> subagentFiles,
Map<String, Map<String, dynamic>> subagentMeta,
) {
final agents = <AgentInfo>[];
final mainToolUses = <ToolUseBlock>[];
String? mainModel;
var mainUsage = const TokenUsage();
for (final entry in mainEntries) {
if (entry is AssistantEntry) {
mainModel ??= entry.model;
if (entry.usage != null) {
mainUsage = mainUsage + entry.usage!;
}
for (final block in entry.toolUseBlocks) {
if (!block.isAgentCall) {
mainToolUses.add(block);
}
}
}
}
agents.add(AgentInfo(
id: 'main',
name: 'Main Assistant',
model: mainModel,
messages: mainEntries,
toolsUsed: mainToolUses,
aggregatedUsage: mainUsage,
));
final agentToolCalls = <String, ToolUseBlock>{};
for (final entry in mainEntries) {
if (entry is AssistantEntry) {
for (final block in entry.toolUseBlocks) {
if (block.isAgentCall) {
agentToolCalls[block.id] = block;
}
}
}
}
for (final entry in subagentFiles.entries) {
final agentId = entry.key;
final agentEntries = entry.value;
final meta = subagentMeta[agentId] ?? {};
final subToolUses = <ToolUseBlock>[];
var subUsage = const TokenUsage();
String? subModel;
for (final e in agentEntries) {
if (e is AssistantEntry) {
subModel ??= e.model;
if (e.usage != null) {
subUsage = subUsage + e.usage!;
}
for (final block in e.toolUseBlocks) {
if (!block.isAgentCall) {
subToolUses.add(block);
}
}
}
}
ToolUseBlock? matchedCall;
for (final call in agentToolCalls.entries) {
if (call.key.contains(agentId) || agentId.contains(call.key)) {
matchedCall = call.value;
break;
}
}
String? slug;
for (final e in agentEntries) {
if (e.raw.containsKey('slug')) {
slug = e.raw['slug'] as String?;
break;
}
}
final agentType =
meta['agentType'] as String? ?? matchedCall?.subagentType;
final description =
matchedCall?.agentDescription ?? slug ?? agentType ?? 'Subagent';
agents.add(AgentInfo(
id: agentId,
name: description,
subagentType: agentType,
description: matchedCall?.agentDescription,
prompt: matchedCall?.agentPrompt,
model: subModel,
messages: agentEntries,
toolsUsed: subToolUses,
aggregatedUsage: subUsage,
));
}
return agents;
}