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
204 lines
5.3 KiB
Dart
204 lines
5.3 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import '../models/agent_info.dart';
|
|
import '../models/log_entry.dart';
|
|
import '../services/jsonl_parser.dart';
|
|
|
|
class SessionProvider with ChangeNotifier {
|
|
final JsonlParser _parser = JsonlParser();
|
|
|
|
SessionLog? _session;
|
|
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;
|
|
String? get filePath => _filePath;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
if (_selectedAgentId != null && _selectedAgentId != 'main') {
|
|
final agent = selectedAgent;
|
|
entries = agent?.messages ?? [];
|
|
} else {
|
|
entries = _session!.mainConversation;
|
|
}
|
|
|
|
return entries.where((e) {
|
|
if (!_visibleTypes.contains(e.type)) return false;
|
|
if (_searchQuery.isNotEmpty) {
|
|
return _entryMatchesSearch(e, _searchQuery.toLowerCase());
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
bool _entryMatchesSearch(LogEntry entry, String query) {
|
|
if (entry is UserEntry) {
|
|
return entry.promptText.toLowerCase().contains(query);
|
|
}
|
|
if (entry is AssistantEntry) {
|
|
for (final block in entry.textBlocks) {
|
|
if (block.text.toLowerCase().contains(query)) return true;
|
|
}
|
|
for (final block in entry.toolUseBlocks) {
|
|
if (block.name.toLowerCase().contains(query)) return true;
|
|
if (block.input.toString().toLowerCase().contains(query)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> loadSession(String filePath) async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
_filePath = filePath;
|
|
_selectedAgentId = null;
|
|
_searchQuery = '';
|
|
_parseErrors = const [];
|
|
_invalidateCache();
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _parser.parseFile(filePath);
|
|
_session = result.sessionLog;
|
|
_parseErrors = result.errors;
|
|
_error = null;
|
|
} catch (e) {
|
|
_error = e.toString();
|
|
_session = null;
|
|
}
|
|
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
void selectAgent(String? agentId) {
|
|
_selectedAgentId = agentId;
|
|
_invalidateCache();
|
|
notifyListeners();
|
|
}
|
|
|
|
void toggleTypeFilter(String type) {
|
|
if (_visibleTypes.contains(type)) {
|
|
_visibleTypes = Set.from(_visibleTypes)..remove(type);
|
|
} else {
|
|
_visibleTypes = Set.from(_visibleTypes)..add(type);
|
|
}
|
|
_invalidateCache();
|
|
notifyListeners();
|
|
}
|
|
|
|
void setSearchQuery(String query) {
|
|
_searchQuery = query;
|
|
// Update cache immediately but debounce notifyListeners
|
|
_invalidateCache();
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
void clearSession() {
|
|
_session = null;
|
|
_filePath = null;
|
|
_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;
|
|
}
|
|
}
|