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
594 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|