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
+91 -5
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;
}
}