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
This commit is contained in:
+287
-243
@@ -1,278 +1,322 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
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<SessionLog> parseFile(String filePath) async {
|
||||
final file = File(filePath);
|
||||
final lines = await file.readAsLines(encoding: utf8);
|
||||
Future<ParseResult> parseFile(String filePath) async {
|
||||
final file = io.File(filePath);
|
||||
final mainLines = await file.readAsLines(encoding: utf8);
|
||||
|
||||
final entries = <LogEntry>[];
|
||||
for (final line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final json = jsonDecode(line) as Map<String, dynamic>;
|
||||
entries.add(LogEntry.fromJson(json));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Resolve the subagents directory from the session file path
|
||||
// e.g. /path/to/project/64a8386a.jsonl → /path/to/project/64a8386a/subagents/
|
||||
final sessionFileName = file.uri.pathSegments.last.replaceAll('.jsonl', '');
|
||||
final sessionFileName =
|
||||
file.uri.pathSegments.last.replaceAll('.jsonl', '');
|
||||
final parentDir = file.parent.path;
|
||||
final subagentsDir = Directory('$parentDir/$sessionFileName/subagents');
|
||||
final subagentsDir =
|
||||
io.Directory('$parentDir/$sessionFileName/subagents');
|
||||
|
||||
final subagentLines = <String, List<String>>{};
|
||||
final subagentMetaRaw = <String, String>{};
|
||||
|
||||
// Load all subagent .jsonl files
|
||||
final subagentFiles = <String, List<LogEntry>>{};
|
||||
final subagentMeta = <String, Map<String, dynamic>>{};
|
||||
if (await subagentsDir.exists()) {
|
||||
await for (final entity in subagentsDir.list()) {
|
||||
if (entity is File && entity.path.endsWith('.jsonl')) {
|
||||
final agentFileName = entity.uri.pathSegments.last;
|
||||
// agent-a014e30b71de602bb.jsonl → a014e30b71de602bb
|
||||
final agentId = agentFileName
|
||||
.replaceAll('.jsonl', '')
|
||||
.replaceFirst('agent-', '');
|
||||
|
||||
final agentEntries = <LogEntry>[];
|
||||
try {
|
||||
final agentLines = await entity.readAsLines(encoding: utf8);
|
||||
for (final line in agentLines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final json = jsonDecode(line) as Map<String, dynamic>;
|
||||
agentEntries.add(LogEntry.fromJson(json));
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (agentEntries.isNotEmpty) {
|
||||
subagentFiles[agentId] = agentEntries;
|
||||
}
|
||||
} else if (entity is File && entity.path.endsWith('.meta.json')) {
|
||||
try {
|
||||
final metaContent = await entity.readAsString();
|
||||
final meta = jsonDecode(metaContent) as Map<String, dynamic>;
|
||||
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-', '');
|
||||
subagentMeta[agentId] = meta;
|
||||
} catch (_) {}
|
||||
try {
|
||||
subagentMetaRaw[agentId] = await entity.readAsString();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _buildSessionLog(entries, subagentFiles, subagentMeta);
|
||||
// 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()));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Link tool results to tool use blocks in main session
|
||||
_linkToolResults(entries);
|
||||
|
||||
// Link tool results inside each subagent session
|
||||
for (final agentEntries in subagentFiles.values) {
|
||||
_linkToolResults(agentEntries);
|
||||
}
|
||||
|
||||
// Separate main conversation from progress entries
|
||||
final mainConversation = entries
|
||||
.where((e) => e.type != 'progress' && e.type != 'file-history-snapshot')
|
||||
.toList();
|
||||
|
||||
// Build agent info
|
||||
final agents = _buildAgents(
|
||||
mainConversation,
|
||||
subagentFiles,
|
||||
subagentMeta,
|
||||
);
|
||||
final mainAgent = agents.firstWhere((a) => a.id == 'main');
|
||||
|
||||
// Compute total usage
|
||||
var totalUsage = const TokenUsage();
|
||||
for (final agent in agents) {
|
||||
totalUsage = totalUsage + agent.aggregatedUsage;
|
||||
}
|
||||
|
||||
// Build tools by name index
|
||||
final toolsByName = <String, List<ToolUseBlock>>{};
|
||||
for (final agent in agents) {
|
||||
for (final tool in agent.toolsUsed) {
|
||||
toolsByName.putIfAbsent(tool.name, () => []).add(tool);
|
||||
String? slug;
|
||||
for (final e in agentEntries) {
|
||||
if (e.raw.containsKey('slug')) {
|
||||
slug = e.raw['slug'] as String?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
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>[];
|
||||
|
||||
// Main agent tools and usage
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final agentType =
|
||||
meta['agentType'] as String? ?? matchedCall?.subagentType;
|
||||
final description =
|
||||
matchedCall?.agentDescription ?? slug ?? agentType ?? 'Subagent';
|
||||
|
||||
agents.add(AgentInfo(
|
||||
id: 'main',
|
||||
name: 'Main Assistant',
|
||||
model: mainModel,
|
||||
messages: mainEntries,
|
||||
toolsUsed: mainToolUses,
|
||||
aggregatedUsage: mainUsage,
|
||||
id: agentId,
|
||||
name: description,
|
||||
subagentType: agentType,
|
||||
description: matchedCall?.agentDescription,
|
||||
prompt: matchedCall?.agentPrompt,
|
||||
model: subModel,
|
||||
messages: agentEntries,
|
||||
toolsUsed: subToolUses,
|
||||
aggregatedUsage: subUsage,
|
||||
));
|
||||
|
||||
// Find Agent tool calls from the main conversation to get descriptions
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build subagent infos from the actual subagent .jsonl files
|
||||
for (final entry in subagentFiles.entries) {
|
||||
final agentId = entry.key;
|
||||
final agentEntries = entry.value;
|
||||
final meta = subagentMeta[agentId] ?? {};
|
||||
|
||||
// Extract tool uses, model, and usage from subagent entries
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match this subagent to an Agent tool call for description/prompt.
|
||||
// The agentId in the file might be a prefix of the tool_use id.
|
||||
ToolUseBlock? matchedCall;
|
||||
for (final call in agentToolCalls.entries) {
|
||||
if (call.key.contains(agentId) || agentId.contains(call.key)) {
|
||||
matchedCall = call.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check the slug from the first entry to match
|
||||
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;
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user