claude_session_viewer/lib/screens/timeline/timeline_screen.dart
Mathias Beaulieu-Duncan 659dade82d 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
2026-04-07 13:32:13 -04:00

594 lines
18 KiB
Dart

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';
// ─── 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 session = context.select<SessionProvider, SessionLog?>((p) => p.session);
if (session == null) {
return const Center(
child: Text('No session loaded',
style: TextStyle(color: AppColors.textMuted)),
);
}
// 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: [
const _FilterBar(),
Expanded(
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: _cachedItems.length,
addRepaintBoundaries: true,
itemBuilder: (context, index) {
return _TimelineItemWidget(
item: _cachedItems[index],
);
},
),
),
],
),
);
}
}
// ─── 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: 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,
),
),
),
),
],
),
);
}
// 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 (item.entry is SystemEntry) {
return SystemMessageCard(entry: item.entry as SystemEntry);
}
return const SizedBox.shrink();
}
}
// ─── Custom painter for tree lines ────────────────────────────
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),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(color: AppColors.surfaceBorder, width: 1),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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),
// 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),
),
),
),
),
],
),
],
),
);
}
String _formatTokens(int tokens) {
if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M';
if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K';
return '$tokens';
}
}
class _InfoChip extends StatelessWidget {
final String label;
const _InfoChip({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
);
}
}
class _TypeToggle extends StatelessWidget {
final String label;
final Color color;
final bool active;
final VoidCallback onTap;
const _TypeToggle({
required this.label,
required this.color,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: active ? color.withAlpha(25) : Colors.transparent,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: active ? color.withAlpha(80) : AppColors.surfaceBorder,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: active ? color : AppColors.textMuted,
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}
}