claude_session_viewer/lib/screens/timeline/timeline_screen.dart
Mathias Beaulieu-Duncan 364877d376 Initial commit: Claude Code session viewer (Flutter macOS)
A desktop app that parses Claude Code .jsonl session logs and provides
a rich UI for exploring conversations, tool usage, subagents, and token
consumption. Features include project browser with auto-discovery of
~/.claude/projects, conversation timeline with inline subagent expansion,
agents overview, toolbelt chart, and token usage dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:17:23 -04:00

275 lines
8.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/log_entry.dart';
import '../../providers/session_provider.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 {
const TimelineScreen({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionProvider>();
final session = provider.session;
if (session == null) {
return const Center(
child: Text('No session loaded', style: TextStyle(color: AppColors.textMuted)),
);
}
final entries = provider.filteredEntries;
return Scaffold(
backgroundColor: AppColors.background,
body: Column(
children: [
_FilterBar(provider: provider),
Expanded(
child: entries.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,
itemBuilder: (context, index) {
return _buildEntryCard(entries[index], index);
},
),
),
],
),
);
}
Widget _buildEntryCard(LogEntry entry, int index) {
if (entry is UserEntry && !entry.isToolResult) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: UserMessageCard(entry: entry, index: index),
);
}
if (entry is AssistantEntry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AssistantMessageCard(entry: entry, index: index),
);
}
if (entry is SystemEntry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SystemMessageCard(entry: entry),
);
}
return const SizedBox.shrink();
}
}
class _FilterBar extends StatelessWidget {
final SessionProvider provider;
const _FilterBar({required this.provider});
@override
Widget build(BuildContext context) {
final session = provider.session!;
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: 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: '${provider.filteredEntries.length} entries'),
const SizedBox(width: 8),
_InfoChip(
label:
'${_formatTokens(session.totalUsage.totalTokens)} tokens'),
],
),
),
// 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,
),
),
),
);
}
}