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:
Mathias Beaulieu-Duncan 2026-04-07 13:32:13 -04:00
parent aa484f6409
commit 659dade82d
8 changed files with 862 additions and 410 deletions

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/agent_info.dart';
import '../models/log_entry.dart';
@ -10,12 +12,21 @@ class SessionProvider with ChangeNotifier {
bool _isLoading = false;
String? _error;
String? _filePath;
List<ParseError> _parseErrors = const [];
// Filter state
String? _selectedAgentId;
Set<String> _visibleTypes = {'user', 'assistant', 'system'};
String _searchQuery = '';
// Cached filtered results
List<LogEntry> _cachedFilteredEntries = const [];
// Cache key tracks what was used to compute the cache
_FilterKey? _cachedFilterKey;
// Debounce timer for search
Timer? _searchDebounce;
SessionLog? get session => _session;
bool get isLoading => _isLoading;
String? get error => _error;
@ -23,15 +34,30 @@ class SessionProvider with ChangeNotifier {
String? get selectedAgentId => _selectedAgentId;
Set<String> get visibleTypes => _visibleTypes;
String get searchQuery => _searchQuery;
List<ParseError> get parseErrors => _parseErrors;
AgentInfo? get selectedAgent {
if (_session == null || _selectedAgentId == null) return null;
return _session!.agents
.where((a) => a.id == _selectedAgentId)
.firstOrNull;
return _session!.agents.where((a) => a.id == _selectedAgentId).firstOrNull;
}
List<LogEntry> get filteredEntries {
final key = _FilterKey(
selectedAgentId: _selectedAgentId,
visibleTypes: _visibleTypes,
searchQuery: _searchQuery,
hasSession: _session != null,
);
if (_cachedFilterKey == key) return _cachedFilteredEntries;
// Recompute
_cachedFilterKey = key;
_cachedFilteredEntries = _computeFilteredEntries();
return _cachedFilteredEntries;
}
List<LogEntry> _computeFilteredEntries() {
if (_session == null) return [];
List<LogEntry> entries;
@ -73,10 +99,14 @@ class SessionProvider with ChangeNotifier {
_filePath = filePath;
_selectedAgentId = null;
_searchQuery = '';
_parseErrors = const [];
_invalidateCache();
notifyListeners();
try {
_session = await _parser.parseFile(filePath);
final result = await _parser.parseFile(filePath);
_session = result.sessionLog;
_parseErrors = result.errors;
_error = null;
} catch (e) {
_error = e.toString();
@ -89,6 +119,7 @@ class SessionProvider with ChangeNotifier {
void selectAgent(String? agentId) {
_selectedAgentId = agentId;
_invalidateCache();
notifyListeners();
}
@ -98,12 +129,18 @@ class SessionProvider with ChangeNotifier {
} else {
_visibleTypes = Set.from(_visibleTypes)..add(type);
}
_invalidateCache();
notifyListeners();
}
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
// Update cache immediately but debounce notifyListeners
_invalidateCache();
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
notifyListeners();
});
}
void clearSession() {
@ -112,6 +149,55 @@ class SessionProvider with ChangeNotifier {
_error = null;
_selectedAgentId = null;
_searchQuery = '';
_parseErrors = const [];
_searchDebounce?.cancel();
_invalidateCache();
notifyListeners();
}
void _invalidateCache() {
_cachedFilterKey = null;
}
@override
void dispose() {
_searchDebounce?.cancel();
super.dispose();
}
}
/// Immutable key to detect whether the filter result has changed.
class _FilterKey {
final String? selectedAgentId;
final Set<String> visibleTypes;
final String searchQuery;
final bool hasSession;
const _FilterKey({
required this.selectedAgentId,
required this.visibleTypes,
required this.searchQuery,
required this.hasSession,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _FilterKey &&
selectedAgentId == other.selectedAgentId &&
_setEquals(visibleTypes, other.visibleTypes) &&
searchQuery == other.searchQuery &&
hasSession == other.hasSession;
@override
int get hashCode =>
Object.hash(selectedAgentId, Object.hashAll(visibleTypes), searchQuery, hasSession);
static bool _setEquals<T>(Set<T> a, Set<T> b) {
if (a.length != b.length) return false;
for (final v in a) {
if (!b.contains(v)) return false;
}
return true;
}
}

View File

@ -1,43 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/agent_info.dart';
import '../../models/log_entry.dart';
import '../../providers/session_provider.dart';
import '../../services/jsonl_parser.dart';
import '../../theme/app_theme.dart';
import 'widgets/assistant_message_card.dart';
import 'widgets/system_message_card.dart';
import 'widgets/user_message_card.dart';
class TimelineScreen extends StatelessWidget {
// Flat timeline item
enum _TimelineItemType { userPrompt, assistantResponse, systemMessage }
class _TimelineItem {
final _TimelineItemType type;
final LogEntry entry;
final int turnNumber;
final bool isFirstInTurn;
final bool isLastInTurn;
const _TimelineItem({
required this.type,
required this.entry,
required this.turnNumber,
this.isFirstInTurn = false,
this.isLastInTurn = false,
});
}
List<_TimelineItem> _flattenTurns(List<LogEntry> entries) {
final items = <_TimelineItem>[];
UserEntry? currentPrompt;
int turnNum = 0;
bool hadPrompt = false;
for (final entry in entries) {
if (entry is UserEntry && !entry.isToolResult) {
// Flush previous turn
if (hadPrompt && currentPrompt != null) {
// already flushed this is a new prompt
}
if (currentPrompt != null) {
// The previous turn is already flushed (items added inline below)
}
// Emit user prompt item
turnNum++;
currentPrompt = entry;
hadPrompt = true;
items.add(_TimelineItem(
type: _TimelineItemType.userPrompt,
entry: entry,
turnNumber: turnNum,
isFirstInTurn: true,
));
} else if (entry is UserEntry && entry.isToolResult) {
continue;
} else if (hadPrompt) {
final isAssistant = entry is AssistantEntry;
final isSystem = entry is SystemEntry;
items.add(_TimelineItem(
type: isAssistant
? _TimelineItemType.assistantResponse
: isSystem
? _TimelineItemType.systemMessage
: _TimelineItemType.systemMessage,
entry: entry,
turnNumber: turnNum,
));
}
// else: orphaned entries before first user prompt skip
}
// Mark last item in each turn
for (int i = 0; i < items.length; i++) {
final nextIsNewTurn =
i + 1 >= items.length || items[i + 1].isFirstInTurn;
if (!items[i].isFirstInTurn && nextIsNewTurn) {
items[i] = _TimelineItem(
type: items[i].type,
entry: items[i].entry,
turnNumber: items[i].turnNumber,
isFirstInTurn: items[i].isFirstInTurn,
isLastInTurn: true,
);
}
}
return items;
}
// Turn grouping for filter bar stats
class ConversationTurn {
final UserEntry prompt;
final List<LogEntry> responses;
final int turnNumber;
ConversationTurn({
required this.prompt,
required this.responses,
required this.turnNumber,
});
}
List<ConversationTurn> groupIntoTurns(List<LogEntry> entries) {
final turns = <ConversationTurn>[];
UserEntry? currentPrompt;
List<LogEntry> currentResponses = [];
List<LogEntry> orphanedEntries = [];
int turnNum = 0;
for (final entry in entries) {
if (entry is UserEntry && !entry.isToolResult) {
if (currentPrompt != null) {
turns.add(ConversationTurn(
prompt: currentPrompt,
responses: currentResponses,
turnNumber: turnNum,
));
}
if (orphanedEntries.isNotEmpty) {
currentResponses = [];
turnNum++;
currentPrompt = entry;
currentResponses.addAll(orphanedEntries);
orphanedEntries = [];
} else {
currentPrompt = entry;
currentResponses = [];
turnNum++;
}
} else if (entry is UserEntry && entry.isToolResult) {
continue;
} else if (currentPrompt != null) {
currentResponses.add(entry);
} else {
orphanedEntries.add(entry);
}
}
if (currentPrompt != null) {
turns.add(ConversationTurn(
prompt: currentPrompt,
responses: currentResponses,
turnNumber: turnNum,
));
}
return turns;
}
// Main screen
class TimelineScreen extends StatefulWidget {
const TimelineScreen({super.key});
@override
State<TimelineScreen> createState() => _TimelineScreenState();
}
class _TimelineScreenState extends State<TimelineScreen> {
List<_TimelineItem> _cachedItems = const [];
List<LogEntry>? _cachedEntriesKey;
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionProvider>();
final session = provider.session;
final session = context.select<SessionProvider, SessionLog?>((p) => p.session);
if (session == null) {
return const Center(
child: Text('No session loaded', style: TextStyle(color: AppColors.textMuted)),
child: Text('No session loaded',
style: TextStyle(color: AppColors.textMuted)),
);
}
final entries = provider.filteredEntries;
// Get filtered entries and flatten to items (cached)
final entries = context.select<SessionProvider, List<LogEntry>>(
(p) => p.filteredEntries);
// Cache the flat items list only recompute when entries reference changes
if (!identical(_cachedEntriesKey, entries)) {
_cachedEntriesKey = entries;
_cachedItems = _flattenTurns(entries);
}
return Scaffold(
backgroundColor: AppColors.background,
body: Column(
children: [
_FilterBar(provider: provider),
const _FilterBar(),
Expanded(
child: entries.isEmpty
child: _cachedItems.isEmpty
? const Center(
child: Text('No matching entries',
style: TextStyle(color: AppColors.textMuted)),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
itemCount: entries.length,
itemCount: _cachedItems.length,
addRepaintBoundaries: true,
itemBuilder: (context, index) {
return _buildEntryCard(entries[index], index);
return _TimelineItemWidget(
item: _cachedItems[index],
);
},
),
),
@ -45,38 +214,171 @@ class TimelineScreen extends StatelessWidget {
),
);
}
}
Widget _buildEntryCard(LogEntry entry, int index) {
if (entry is UserEntry && !entry.isToolResult) {
// Timeline item widget
class _TimelineItemWidget extends StatelessWidget {
final _TimelineItem item;
const _TimelineItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final isPrompt = item.type == _TimelineItemType.userPrompt;
if (isPrompt) {
// User prompt no tree line, just the card with turn indicator
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: UserMessageCard(entry: entry, index: index),
padding: EdgeInsets.only(
bottom: item.isLastInTurn ? 0 : 6,
top: 2,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Turn dot
SizedBox(
width: 24,
child: Column(
children: [
const SizedBox(height: 14),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.user,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.user.withAlpha(60),
blurRadius: 6,
spreadRadius: 1,
),
],
),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: RepaintBoundary(
child: UserMessageCard(
entry: item.entry as UserEntry,
index: item.turnNumber,
),
),
),
),
],
),
);
}
if (entry is AssistantEntry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AssistantMessageCard(entry: entry, index: index),
// Response/system with tree connector line
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tree line column
SizedBox(
width: 24,
height: 36, // Fixed height for the connector
child: CustomPaint(
painter: _TreeLinePainter(
color: AppColors.user.withAlpha(60),
isLastChild: item.isLastInTurn,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: RepaintBoundary(
child: _buildCard(item),
),
),
),
],
),
);
}
Widget _buildCard(_TimelineItem item) {
if (item.entry is AssistantEntry) {
return AssistantMessageCard(
entry: item.entry as AssistantEntry,
index: item.turnNumber,
);
}
if (entry is SystemEntry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SystemMessageCard(entry: entry),
);
if (item.entry is SystemEntry) {
return SystemMessageCard(entry: item.entry as SystemEntry);
}
return const SizedBox.shrink();
}
}
class _FilterBar extends StatelessWidget {
final SessionProvider provider;
// Custom painter for tree lines
const _FilterBar({required this.provider});
class _TreeLinePainter extends CustomPainter {
final Color color;
final bool isLastChild;
const _TreeLinePainter({
required this.color,
required this.isLastChild,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final centerX = size.width / 2;
const connectorY = 18.0;
// Vertical line from top to connector (or to bottom if not last)
canvas.drawLine(
Offset(centerX, 0),
Offset(centerX, isLastChild ? connectorY : size.height),
paint,
);
// Horizontal connector to right edge
canvas.drawLine(
Offset(centerX, connectorY),
Offset(size.width, connectorY),
paint,
);
}
@override
bool shouldRepaint(covariant _TreeLinePainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.isLastChild != isLastChild;
}
}
// Filter bar
class _FilterBar extends StatelessWidget {
const _FilterBar();
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionProvider>();
final session = provider.session!;
final entries = provider.filteredEntries;
final turns = groupIntoTurns(entries);
// Parse errors warning
final parseErrors = context.select<SessionProvider, List<ParseError>>(
(p) => p.parseErrors);
return Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 12),
@ -86,120 +388,137 @@ class _FilterBar extends StatelessWidget {
bottom: BorderSide(color: AppColors.surfaceBorder, width: 1),
),
),
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Session info
Expanded(
child: Row(
children: [
Text(
session.sessionId?.substring(0, 8) ?? 'Session',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
Row(
children: [
// Session info
Expanded(
child: Row(
children: [
Text(
session.sessionId?.substring(0, 8) ?? 'Session',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 12),
if (session.version != null)
_InfoChip(label: 'v${session.version}'),
const SizedBox(width: 8),
_InfoChip(label: '${turns.length} turns'),
const SizedBox(width: 8),
_InfoChip(
label:
'${_formatTokens(session.totalUsage.totalTokens)} tokens'),
// Parse errors warning
if (parseErrors.isNotEmpty) ...[
const SizedBox(width: 8),
Tooltip(
message:
'${parseErrors.length} lines could not be parsed',
child: _InfoChip(
label: '${parseErrors.length} parse errors',
),
),
],
],
),
),
// Agent filter
if (session.agents.length > 1) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.surfaceBorder),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: provider.selectedAgentId,
isDense: true,
dropdownColor: AppColors.surfaceLight,
style: const TextStyle(
fontSize: 12, color: AppColors.textPrimary),
hint: const Text('All Agents',
style: TextStyle(
fontSize: 12, color: AppColors.textSecondary)),
items: [
const DropdownMenuItem(
value: null,
child: Text('All Agents'),
),
...session.agents.map((a) => DropdownMenuItem(
value: a.id,
child: Text(a.name,
overflow: TextOverflow.ellipsis),
)),
],
onChanged: (v) => provider.selectAgent(v),
),
),
),
const SizedBox(width: 12),
if (session.version != null)
_InfoChip(label: 'v${session.version}'),
const SizedBox(width: 8),
_InfoChip(
label: '${provider.filteredEntries.length} entries'),
const SizedBox(width: 8),
_InfoChip(
label:
'${_formatTokens(session.totalUsage.totalTokens)} tokens'),
],
),
),
// Agent filter
if (session.agents.length > 1) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.surfaceBorder),
const SizedBox(width: 12),
// Type toggles
_TypeToggle(
label: 'User',
color: AppColors.user,
active: provider.visibleTypes.contains('user'),
onTap: () => provider.toggleTypeFilter('user'),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: provider.selectedAgentId,
isDense: true,
dropdownColor: AppColors.surfaceLight,
const SizedBox(width: 4),
_TypeToggle(
label: 'Assistant',
color: AppColors.assistant,
active: provider.visibleTypes.contains('assistant'),
onTap: () => provider.toggleTypeFilter('assistant'),
),
const SizedBox(width: 4),
_TypeToggle(
label: 'System',
color: AppColors.system,
active: provider.visibleTypes.contains('system'),
onTap: () => provider.toggleTypeFilter('system'),
),
const SizedBox(width: 12),
// Search
SizedBox(
width: 180,
child: TextField(
onChanged: provider.setSearchQuery,
style: const TextStyle(
fontSize: 12, color: AppColors.textPrimary),
hint: const Text('All Agents',
style: TextStyle(
fontSize: 12, color: AppColors.textSecondary)),
items: [
const DropdownMenuItem(
value: null,
child: Text('All Agents'),
decoration: InputDecoration(
hintText: 'Search...',
hintStyle: const TextStyle(
fontSize: 12, color: AppColors.textMuted),
prefixIcon: const Icon(Icons.search,
size: 16, color: AppColors.textMuted),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 8),
filled: true,
fillColor: AppColors.surfaceLight,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide:
const BorderSide(color: AppColors.surfaceBorder),
),
...session.agents.map((a) => DropdownMenuItem(
value: a.id,
child: Text(a.name,
overflow: TextOverflow.ellipsis),
)),
],
onChanged: (v) => provider.selectAgent(v),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide:
const BorderSide(color: AppColors.surfaceBorder),
),
),
),
),
),
],
const SizedBox(width: 12),
// Type toggles
_TypeToggle(
label: 'User',
color: AppColors.user,
active: provider.visibleTypes.contains('user'),
onTap: () => provider.toggleTypeFilter('user'),
),
const SizedBox(width: 4),
_TypeToggle(
label: 'Assistant',
color: AppColors.assistant,
active: provider.visibleTypes.contains('assistant'),
onTap: () => provider.toggleTypeFilter('assistant'),
),
const SizedBox(width: 4),
_TypeToggle(
label: 'System',
color: AppColors.system,
active: provider.visibleTypes.contains('system'),
onTap: () => provider.toggleTypeFilter('system'),
),
const SizedBox(width: 12),
// Search
SizedBox(
width: 180,
child: TextField(
onChanged: provider.setSearchQuery,
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Search...',
hintStyle:
const TextStyle(fontSize: 12, color: AppColors.textMuted),
prefixIcon: const Icon(Icons.search,
size: 16, color: AppColors.textMuted),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
filled: true,
fillColor: AppColors.surfaceLight,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide:
const BorderSide(color: AppColors.surfaceBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide:
const BorderSide(color: AppColors.surfaceBorder),
),
),
),
],
),
],
),

View File

@ -174,7 +174,8 @@ class TokensScreen extends StatelessWidget {
),
const SizedBox(width: 24),
Expanded(
child: Column(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: session.agents
@ -225,6 +226,7 @@ class TokensScreen extends StatelessWidget {
);
}).toList(),
),
),
),
],
),

View File

@ -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;
}

View File

@ -26,11 +26,13 @@ class _ExpandableCardState extends State<ExpandableCard>
late bool _expanded;
late AnimationController _controller;
late Animation<double> _rotation;
bool _hasBeenExpanded = false; // Track if ever expanded to lazy-build
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
_hasBeenExpanded = _expanded;
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
@ -51,6 +53,7 @@ class _ExpandableCardState extends State<ExpandableCard>
setState(() {
_expanded = !_expanded;
if (_expanded) {
_hasBeenExpanded = true;
_controller.forward();
} else {
_controller.reverse();
@ -76,7 +79,8 @@ class _ExpandableCardState extends State<ExpandableCard>
onTap: _toggle,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
RotationTransition(
@ -93,19 +97,18 @@ class _ExpandableCardState extends State<ExpandableCard>
),
),
),
ClipRect(
child: AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: widget.child,
// Lazy animated expand: only build child once ever expanded
if (_hasBeenExpanded)
ClipRect(
child: SizeTransition(
sizeFactor: _controller,
axisAlignment: -1.0,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: widget.child,
),
),
crossFadeState: _expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
],
),
);

View File

@ -39,24 +39,18 @@ class _AppShellState extends State<AppShell> {
hasSession: hasSession,
),
Expanded(
child: _buildScreen(),
child: IndexedStack(
index: _screen.index,
children: [
HomeScreen(onSessionLoaded: _onSessionLoaded),
const TimelineScreen(),
const AgentsScreen(),
const ToolbeltScreen(),
const TokensScreen(),
],
),
),
],
);
}
Widget _buildScreen() {
switch (_screen) {
case SidebarScreen.home:
return HomeScreen(onSessionLoaded: _onSessionLoaded);
case SidebarScreen.timeline:
return const TimelineScreen();
case SidebarScreen.agents:
return const AgentsScreen();
case SidebarScreen.toolbelt:
return const ToolbeltScreen();
case SidebarScreen.tokens:
return const TokensScreen();
}
}
}

View File

@ -671,7 +671,7 @@
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_IDENTITY = "Developer ID Application";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
@ -720,7 +720,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
"DEVELOPMENT_TEAM[sdk=macosx*]" = LD76P8L42W;
ENABLE_HARDENED_RUNTIME = YES;
OTHER_CODE_SIGN_FLAGS = "--timestamp --options runtime";
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (

View File

@ -5,10 +5,10 @@
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = Claude Session Analysis
PRODUCT_NAME = Claude Session Viewer
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionAnalysis
PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claude-session-viewer
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 Svrnty. All rights reserved.