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 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 responses; final int turnNumber; ConversationTurn({ required this.prompt, required this.responses, required this.turnNumber, }); } List groupIntoTurns(List entries) { final turns = []; UserEntry? currentPrompt; List currentResponses = []; List 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 createState() => _TimelineScreenState(); } class _TimelineScreenState extends State { List<_TimelineItem> _cachedItems = const []; List? _cachedEntriesKey; @override Widget build(BuildContext context) { final session = context.select((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>( (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(); final session = provider.session!; final entries = provider.filteredEntries; final turns = groupIntoTurns(entries); // Parse errors warning final parseErrors = context.select>( (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( 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, ), ), ), ); } }