From 659dade82def81ca8546e55554ceb202b726cae7 Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Tue, 7 Apr 2026 13:32:13 -0400 Subject: [PATCH] perf: Phase 1 critical performance fixes + production macOS build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/providers/session_provider.dart | 96 +++- lib/screens/timeline/timeline_screen.dart | 577 +++++++++++++++++----- lib/screens/tokens/tokens_screen.dart | 4 +- lib/services/jsonl_parser.dart | 530 +++++++++++--------- lib/widgets/common/expandable_card.dart | 27 +- lib/widgets/navigation/app_shell.dart | 26 +- macos/Runner.xcodeproj/project.pbxproj | 8 +- macos/Runner/Configs/AppInfo.xcconfig | 4 +- 8 files changed, 862 insertions(+), 410 deletions(-) diff --git a/lib/providers/session_provider.dart b/lib/providers/session_provider.dart index 4f7b47b..8463726 100644 --- a/lib/providers/session_provider.dart +++ b/lib/providers/session_provider.dart @@ -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 _parseErrors = const []; // Filter state String? _selectedAgentId; Set _visibleTypes = {'user', 'assistant', 'system'}; String _searchQuery = ''; + // Cached filtered results + List _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 get visibleTypes => _visibleTypes; String get searchQuery => _searchQuery; + List 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 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 _computeFilteredEntries() { if (_session == null) return []; List 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 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(Set a, Set b) { + if (a.length != b.length) return false; + for (final v in a) { + if (!b.contains(v)) return false; + } + return true; + } } diff --git a/lib/screens/timeline/timeline_screen.dart b/lib/screens/timeline/timeline_screen.dart index 4f6e874..b9b5458 100644 --- a/lib/screens/timeline/timeline_screen.dart +++ b/lib/screens/timeline/timeline_screen.dart @@ -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 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 responses; + final int turnNumber; + + ConversationTurn({ + required this.prompt, + required this.responses, + required this.turnNumber, + }); +} + +List groupIntoTurns(List entries) { + final turns = []; + UserEntry? currentPrompt; + List currentResponses = []; + List 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 createState() => _TimelineScreenState(); +} + +class _TimelineScreenState extends State { + List<_TimelineItem> _cachedItems = const []; + List? _cachedEntriesKey; + @override Widget build(BuildContext context) { - final provider = context.watch(); - final session = provider.session; + final session = context.select((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>( + (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(); final session = provider.session!; + final entries = provider.filteredEntries; + final turns = groupIntoTurns(entries); + + // Parse errors warning + final parseErrors = context.select>( + (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( + 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( - 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), - ), - ), - ), + ], ), ], ), diff --git a/lib/screens/tokens/tokens_screen.dart b/lib/screens/tokens/tokens_screen.dart index 3c863eb..bdc1ece 100644 --- a/lib/screens/tokens/tokens_screen.dart +++ b/lib/screens/tokens/tokens_screen.dart @@ -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(), ), + ), ), ], ), diff --git a/lib/services/jsonl_parser.dart b/lib/services/jsonl_parser.dart index 28c0edd..e043a87 100644 --- a/lib/services/jsonl_parser.dart +++ b/lib/services/jsonl_parser.dart @@ -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 errors; + const ParseResult(this.sessionLog, this.errors); +} + +class _IsolateInput { + final List mainLines; + final String sessionFileName; + final Map> subagentLines; + final Map subagentMetaRaw; + const _IsolateInput({ + required this.mainLines, + required this.sessionFileName, + required this.subagentLines, + required this.subagentMetaRaw, + }); +} + class JsonlParser { - Future parseFile(String filePath) async { - final file = File(filePath); - final lines = await file.readAsLines(encoding: utf8); + Future parseFile(String filePath) async { + final file = io.File(filePath); + final mainLines = await file.readAsLines(encoding: utf8); - final entries = []; - for (final line in lines) { - if (line.trim().isEmpty) continue; - try { - final json = jsonDecode(line) as Map; - 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 = >{}; + final subagentMetaRaw = {}; - // Load all subagent .jsonl files - final subagentFiles = >{}; - final subagentMeta = >{}; 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 = []; - 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; - 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; + 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 = []; + final entries = []; + + 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; + entries.add(LogEntry.fromJson(json)); + } catch (e) { + errors.add(ParseError(line: i, error: e.toString())); + } } - SessionLog _buildSessionLog( - List entries, - Map> subagentFiles, - Map> 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 = >{}; + for (final entry in input.subagentLines.entries) { + final agentEntries = []; + 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; + 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 = >{}; + for (final entry in input.subagentMetaRaw.entries) { + try { + subagentMeta[entry.key] = + jsonDecode(entry.value) as Map; + } catch (e) { + errors.add(ParseError(source: entry.key, error: e.toString())); + } + } + + final sessionLog = _buildSessionLog(entries, subagentFiles, subagentMeta); + return ParseResult(sessionLog, errors); +} + +SessionLog _buildSessionLog( + List entries, + Map> subagentFiles, + Map> 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 = >{}; + 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 entries) { + final resultMap = {}; + 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 _buildAgents( + List mainEntries, + Map> subagentFiles, + Map> subagentMeta, +) { + final agents = []; + + final mainToolUses = []; + 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 = {}; + 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 = []; + 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 = >{}; - 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 entries) { - final resultMap = {}; - 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 _buildAgents( - List mainEntries, - Map> subagentFiles, - Map> subagentMeta, - ) { - final agents = []; - - // Main agent tools and usage - final mainToolUses = []; - 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 = {}; - 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 = []; - 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; } diff --git a/lib/widgets/common/expandable_card.dart b/lib/widgets/common/expandable_card.dart index d1861ff..38b5baf 100644 --- a/lib/widgets/common/expandable_card.dart +++ b/lib/widgets/common/expandable_card.dart @@ -26,11 +26,13 @@ class _ExpandableCardState extends State late bool _expanded; late AnimationController _controller; late Animation _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 setState(() { _expanded = !_expanded; if (_expanded) { + _hasBeenExpanded = true; _controller.forward(); } else { _controller.reverse(); @@ -76,7 +79,8 @@ class _ExpandableCardState extends State 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 ), ), ), - 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), ), - ), ], ), ); diff --git a/lib/widgets/navigation/app_shell.dart b/lib/widgets/navigation/app_shell.dart index f53e605..298eae7 100644 --- a/lib/widgets/navigation/app_shell.dart +++ b/lib/widgets/navigation/app_shell.dart @@ -39,24 +39,18 @@ class _AppShellState extends State { 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(); - } - } } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 4d70757..85cb345 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ( diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 619cea5..f625168 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -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.