claude_session_viewer/lib/screens/timeline/widgets/assistant_message_card.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

707 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import '../../../models/agent_info.dart';
import '../../../models/content_block.dart';
import '../../../models/log_entry.dart';
import '../../../providers/session_provider.dart';
import '../../../theme/app_theme.dart';
import '../../../widgets/common/expandable_card.dart';
import '../../../widgets/common/json_tree_view.dart';
class AssistantMessageCard extends StatelessWidget {
final AssistantEntry entry;
final int index;
const AssistantMessageCard({
super.key,
required this.entry,
required this.index,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.assistantBg,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.assistant.withAlpha(40)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: AppColors.assistant.withAlpha(40),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(Icons.smart_toy,
size: 14, color: AppColors.assistant),
),
const SizedBox(width: 8),
Text(
entry.model ?? 'Assistant',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.assistant,
),
),
const SizedBox(width: 8),
Text(
'#$index',
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted),
),
const Spacer(),
if (entry.usage != null) ...[
_TokenBadge(
label: 'in',
value: entry.usage!.inputTokens,
color: AppColors.user,
),
const SizedBox(width: 6),
_TokenBadge(
label: 'out',
value: entry.usage!.outputTokens,
color: AppColors.assistant,
),
if (entry.usage!.cacheReadInputTokens > 0) ...[
const SizedBox(width: 6),
_TokenBadge(
label: 'cache',
value: entry.usage!.cacheReadInputTokens,
color: AppColors.tool,
),
],
const SizedBox(width: 12),
],
if (entry.timestamp != null)
Text(
_formatTime(entry.timestamp!),
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted),
),
],
),
),
// Thinking blocks
if (entry.hasThinking)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 6),
child: ExpandableCard(
backgroundColor: AppColors.surfaceLight,
borderColor: AppColors.surfaceBorder,
header: Row(
children: [
const Icon(Icons.psychology,
size: 14, color: AppColors.agent),
const SizedBox(width: 6),
const Text(
'Thinking',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.agent,
),
),
const SizedBox(width: 8),
Text(
entry.thinkingBlocks.first.thinking.isEmpty
? '(encrypted)'
: '${entry.thinkingBlocks.first.thinking.length} chars',
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted),
),
],
),
child: entry.thinkingBlocks.first.thinking.isEmpty
? const Text(
'Thinking content is encrypted and not available.',
style: TextStyle(
fontSize: 12,
color: AppColors.textMuted,
fontStyle: FontStyle.italic,
),
)
: SelectableText(
entry.thinkingBlocks.first.thinking,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
),
// Text blocks
for (final block in entry.textBlocks)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: MarkdownBody(
data: block.text,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
height: 1.5,
),
code: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.tool,
backgroundColor: AppColors.surfaceLight,
),
codeblockDecoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.surfaceBorder),
),
codeblockPadding: const EdgeInsets.all(12),
h1: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
h2: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
h3: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary),
listBullet: const TextStyle(
fontSize: 13, color: AppColors.textSecondary),
blockquoteDecoration: BoxDecoration(
border: Border(
left: BorderSide(
color: AppColors.assistant.withAlpha(80), width: 3),
),
),
),
),
),
// Tool use blocks
if (entry.hasToolUse)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Column(
children: entry.toolUseBlocks
.map((block) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _ToolUseCard(block: block),
))
.toList(),
),
),
// Raw JSON expandable
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: ExpandableCard(
backgroundColor: AppColors.surface,
borderColor: AppColors.surfaceBorder,
header: const Row(
children: [
Icon(Icons.data_object, size: 14, color: AppColors.textMuted),
SizedBox(width: 6),
Text(
'Raw JSON',
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
),
],
),
child: JsonTreeView(data: entry.raw),
),
),
],
),
);
}
String _formatTime(DateTime dt) {
return '${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}:'
'${dt.second.toString().padLeft(2, '0')}';
}
}
// ─── Tool use card (handles both regular tools and Agent calls) ───
class _ToolUseCard extends StatelessWidget {
final ToolUseBlock block;
const _ToolUseCard({required this.block});
@override
Widget build(BuildContext context) {
final isAgent = block.isAgentCall;
final color = isAgent ? AppColors.agent : AppColors.tool;
final bgColor = isAgent ? AppColors.agentBg : AppColors.toolBg;
final hasResult = block.linkedResult != null;
final isError = block.linkedResult?.isError ?? false;
// Look up subagent info if this is an Agent call
AgentInfo? subagent;
if (isAgent) {
final session = context.read<SessionProvider>().session;
subagent = session?.findAgentForToolUse(block.id);
}
return ExpandableCard(
backgroundColor: bgColor,
borderColor: color.withAlpha(40),
header: Row(
children: [
Icon(
isAgent ? Icons.smart_toy_outlined : Icons.build_outlined,
size: 14,
color: color,
),
const SizedBox(width: 6),
Text(
block.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
if (isAgent && block.subagentType != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(3),
),
child: Text(
block.subagentType!,
style: TextStyle(fontSize: 10, color: color),
),
),
],
if (isAgent && block.agentDescription != null) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
block.agentDescription!,
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
overflow: TextOverflow.ellipsis,
),
),
] else
const Spacer(),
// Subagent stats
if (subagent != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'${subagent.messages.where((m) => m.type == 'assistant').length} turns',
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
),
),
const SizedBox(width: 4),
if (subagent.toolsUsed.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'${subagent.toolsUsed.length} tools',
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
),
),
const SizedBox(width: 4),
],
if (hasResult)
Icon(
isError ? Icons.error_outline : Icons.check_circle_outline,
size: 14,
color: isError ? AppColors.error : AppColors.assistant,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Arguments
const Text(
'ARGUMENTS',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(6),
),
child: JsonTreeView(
data: block.input,
initiallyExpanded: true,
),
),
// Subagent conversation (inline nested timeline)
if (subagent != null) ...[
const SizedBox(height: 12),
_SubagentTimeline(agent: subagent),
],
// Result
if (hasResult) ...[
const SizedBox(height: 12),
Text(
isError ? 'ERROR' : 'RESULT',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isError ? AppColors.error : AppColors.textMuted,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(maxHeight: 400),
decoration: BoxDecoration(
color: isError ? AppColors.errorBg : AppColors.surface,
borderRadius: BorderRadius.circular(6),
border: isError
? Border.all(color: AppColors.error.withAlpha(40))
: null,
),
child: SingleChildScrollView(
child: SelectableText(
block.linkedResult!.textContent,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: isError
? AppColors.error
: AppColors.textSecondary,
height: 1.5,
),
),
),
),
],
// Raw tool_use JSON
const SizedBox(height: 12),
ExpandableCard(
backgroundColor: AppColors.surface,
borderColor: AppColors.surfaceBorder,
header: const Row(
children: [
Icon(Icons.data_object, size: 12, color: AppColors.textMuted),
SizedBox(width: 4),
Text(
'Raw Tool JSON',
style: TextStyle(fontSize: 10, color: AppColors.textMuted),
),
],
),
child: JsonTreeView(data: block.raw),
),
],
),
);
}
}
// ─── Inline subagent conversation ────────────────────────────
class _SubagentTimeline extends StatelessWidget {
final AgentInfo agent;
const _SubagentTimeline({required this.agent});
@override
Widget build(BuildContext context) {
// Filter to user and assistant messages only
final messages = agent.messages
.where((m) => m.type == 'user' || m.type == 'assistant')
.toList();
if (messages.isEmpty) {
return const Text(
'No subagent conversation data available.',
style: TextStyle(fontSize: 12, color: AppColors.textMuted, fontStyle: FontStyle.italic),
);
}
return ExpandableCard(
backgroundColor: AppColors.agentBg,
borderColor: AppColors.agent.withAlpha(30),
header: Row(
children: [
const Icon(Icons.account_tree_outlined, size: 14, color: AppColors.agent),
const SizedBox(width: 6),
Text(
'Subagent Conversation (${messages.length} messages)',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.agent,
),
),
if (agent.model != null) ...[
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(3),
),
child: Text(
agent.model!,
style: const TextStyle(
fontSize: 10,
color: AppColors.textSecondary,
fontFamily: 'JetBrains Mono',
),
),
),
],
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Toolbelt summary
if (agent.uniqueToolNames.isNotEmpty) ...[
Wrap(
spacing: 4,
runSpacing: 4,
children: agent.toolUsageCounts.entries.map((e) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.tool.withAlpha(15),
borderRadius: BorderRadius.circular(3),
border: Border.all(color: AppColors.tool.withAlpha(30)),
),
child: Text(
'${e.key} (${e.value})',
style: const TextStyle(
fontSize: 10,
color: AppColors.tool,
fontFamily: 'JetBrains Mono',
),
),
);
}).toList(),
),
const SizedBox(height: 10),
],
// Messages
for (int i = 0; i < messages.length; i++) ...[
_SubagentMessageTile(entry: messages[i], index: i),
if (i < messages.length - 1) const SizedBox(height: 4),
],
],
),
);
}
}
class _SubagentMessageTile extends StatelessWidget {
final LogEntry entry;
final int index;
const _SubagentMessageTile({required this.entry, required this.index});
@override
Widget build(BuildContext context) {
if (entry is UserEntry) {
return _buildUserTile(entry as UserEntry);
}
if (entry is AssistantEntry) {
return _buildAssistantTile(entry as AssistantEntry);
}
return const SizedBox.shrink();
}
Widget _buildUserTile(UserEntry user) {
if (user.isToolResult) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.userBg,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.user.withAlpha(25)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.person, size: 12, color: AppColors.user),
const SizedBox(width: 6),
Expanded(
child: SelectableText(
user.promptText,
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4),
),
),
],
),
);
}
Widget _buildAssistantTile(AssistantEntry assistant) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.surfaceBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header row
Row(
children: [
const Icon(Icons.smart_toy, size: 12, color: AppColors.assistant),
const SizedBox(width: 6),
Text(
assistant.model ?? 'assistant',
style: const TextStyle(fontSize: 10, color: AppColors.assistant, fontWeight: FontWeight.w600),
),
const Spacer(),
if (assistant.usage != null)
Text(
'in:${_fmt(assistant.usage!.inputTokens)} out:${_fmt(assistant.usage!.outputTokens)}',
style: const TextStyle(fontSize: 9, color: AppColors.textMuted, fontFamily: 'JetBrains Mono'),
),
],
),
// Text content
for (final block in assistant.textBlocks) ...[
const SizedBox(height: 6),
SelectableText(
block.text,
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4),
),
],
// Tool uses
for (final tool in assistant.toolUseBlocks) ...[
const SizedBox(height: 6),
ExpandableCard(
backgroundColor: AppColors.toolBg,
borderColor: AppColors.tool.withAlpha(30),
header: Row(
children: [
const Icon(Icons.build_outlined, size: 12, color: AppColors.tool),
const SizedBox(width: 4),
Text(
tool.name,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.tool),
),
const Spacer(),
if (tool.linkedResult != null)
Icon(
tool.linkedResult!.isError ? Icons.error_outline : Icons.check_circle_outline,
size: 12,
color: tool.linkedResult!.isError ? AppColors.error : AppColors.assistant,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
JsonTreeView(data: tool.input, initiallyExpanded: true),
if (tool.linkedResult != null) ...[
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: SelectableText(
tool.linkedResult!.textContent,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
color: tool.linkedResult!.isError ? AppColors.error : AppColors.textSecondary,
),
),
),
),
],
],
),
),
],
],
),
);
}
String _fmt(int v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
return '$v';
}
}
// ─── Token badge ─────────────────────────────────────────────
class _TokenBadge extends StatelessWidget {
final String label;
final int value;
final Color color;
const _TokenBadge({
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(15),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'$label: ${_format(value)}',
style: TextStyle(
fontSize: 10,
color: color.withAlpha(200),
fontFamily: 'JetBrains Mono',
),
),
);
}
String _format(int v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
return '$v';
}
}