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:
parent
aa484f6409
commit
659dade82d
@ -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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
// Response/system — with tree connector line
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: AssistantMessageCard(entry: entry, index: index),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (entry is SystemEntry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: SystemMessageCard(entry: entry),
|
||||
|
||||
Widget _buildCard(_TimelineItem item) {
|
||||
if (item.entry is AssistantEntry) {
|
||||
return AssistantMessageCard(
|
||||
entry: item.entry as AssistantEntry,
|
||||
index: item.turnNumber,
|
||||
);
|
||||
}
|
||||
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,7 +388,10 @@ class _FilterBar extends StatelessWidget {
|
||||
bottom: BorderSide(color: AppColors.surfaceBorder, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Session info
|
||||
Expanded(
|
||||
@ -104,12 +409,22 @@ class _FilterBar extends StatelessWidget {
|
||||
if (session.version != null)
|
||||
_InfoChip(label: 'v${session.version}'),
|
||||
const SizedBox(width: 8),
|
||||
_InfoChip(
|
||||
label: '${provider.filteredEntries.length} entries'),
|
||||
_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',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -177,15 +492,17 @@ class _FilterBar extends StatelessWidget {
|
||||
width: 180,
|
||||
child: TextField(
|
||||
onChanged: provider.setSearchQuery,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search...',
|
||||
hintStyle:
|
||||
const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
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),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 8),
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceLight,
|
||||
border: OutlineInputBorder(
|
||||
@ -203,6 +520,8 @@ class _FilterBar extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -174,6 +174,7 @@ class TokensScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -226,6 +227,7 @@ class TokensScreen extends StatelessWidget {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,72 +1,137 @@
|
||||
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 JsonlParser {
|
||||
Future<SessionLog> parseFile(String filePath) async {
|
||||
final file = File(filePath);
|
||||
final lines = 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 (_) {}
|
||||
class ParseError {
|
||||
final int? line;
|
||||
final String? source;
|
||||
final String error;
|
||||
const ParseError({this.line, this.source, required this.error});
|
||||
}
|
||||
|
||||
// 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 parentDir = file.parent.path;
|
||||
final subagentsDir = Directory('$parentDir/$sessionFileName/subagents');
|
||||
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<ParseResult> parseFile(String filePath) async {
|
||||
final file = io.File(filePath);
|
||||
final mainLines = await file.readAsLines(encoding: utf8);
|
||||
|
||||
final sessionFileName =
|
||||
file.uri.pathSegments.last.replaceAll('.jsonl', '');
|
||||
final parentDir = file.parent.path;
|
||||
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')) {
|
||||
if (entity is io.File) {
|
||||
if (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));
|
||||
final lines = await entity.readAsLines(encoding: utf8);
|
||||
subagentLines[agentId] = lines;
|
||||
} 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>;
|
||||
} else if (entity.path.endsWith('.meta.json')) {
|
||||
final agentFileName = entity.uri.pathSegments.last;
|
||||
final agentId = agentFileName
|
||||
.replaceAll('.meta.json', '')
|
||||
.replaceFirst('agent-', '');
|
||||
subagentMeta[agentId] = meta;
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
@ -86,34 +151,24 @@ class JsonlParser {
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
.where(
|
||||
(e) => e.type != 'progress' && e.type != 'file-history-snapshot')
|
||||
.toList();
|
||||
|
||||
// Build agent info
|
||||
final agents = _buildAgents(
|
||||
mainConversation,
|
||||
subagentFiles,
|
||||
subagentMeta,
|
||||
);
|
||||
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) {
|
||||
@ -121,7 +176,6 @@ class JsonlParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
final timestamps = entries
|
||||
.where((e) => e.timestamp != null)
|
||||
.map((e) => e.timestamp!)
|
||||
@ -169,7 +223,6 @@ class JsonlParser {
|
||||
) {
|
||||
final agents = <AgentInfo>[];
|
||||
|
||||
// Main agent tools and usage
|
||||
final mainToolUses = <ToolUseBlock>[];
|
||||
String? mainModel;
|
||||
var mainUsage = const TokenUsage();
|
||||
@ -197,7 +250,6 @@ class JsonlParser {
|
||||
aggregatedUsage: mainUsage,
|
||||
));
|
||||
|
||||
// Find Agent tool calls from the main conversation to get descriptions
|
||||
final agentToolCalls = <String, ToolUseBlock>{};
|
||||
for (final entry in mainEntries) {
|
||||
if (entry is AssistantEntry) {
|
||||
@ -209,13 +261,11 @@ class JsonlParser {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@ -234,8 +284,6 @@ class JsonlParser {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
@ -244,7 +292,6 @@ class JsonlParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Also check the slug from the first entry to match
|
||||
String? slug;
|
||||
for (final e in agentEntries) {
|
||||
if (e.raw.containsKey('slug')) {
|
||||
@ -253,12 +300,10 @@ class JsonlParser {
|
||||
}
|
||||
}
|
||||
|
||||
final agentType = meta['agentType'] as String? ??
|
||||
matchedCall?.subagentType;
|
||||
final description = matchedCall?.agentDescription ??
|
||||
slug ??
|
||||
agentType ??
|
||||
'Subagent';
|
||||
final agentType =
|
||||
meta['agentType'] as String? ?? matchedCall?.subagentType;
|
||||
final description =
|
||||
matchedCall?.agentDescription ?? slug ?? agentType ?? 'Subagent';
|
||||
|
||||
agents.add(AgentInfo(
|
||||
id: agentId,
|
||||
@ -275,4 +320,3 @@ class JsonlParser {
|
||||
|
||||
return agents;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,17 +97,16 @@ class _ExpandableCardState extends State<ExpandableCard>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Lazy animated expand: only build child once ever expanded
|
||||
if (_hasBeenExpanded)
|
||||
ClipRect(
|
||||
child: AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: Padding(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user