CODEX_ADK/FRONTEND/lib/components/conversation_log_viewer.dart
Svrnty 229a0698a3 Initial commit: CODEX_ADK monorepo
Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend.
Implements CQRS architecture, OpenAPI contract-first API design.

BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama
FRONTEND: Cross-platform UI with strict typing and Result-based error handling

Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
2025-10-26 23:12:32 -04:00

333 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:iconsax/iconsax.dart';
import 'package:animate_do/animate_do.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../api/api.dart';
/// Displays conversation logs with expandable messages and copy functionality
class ConversationLogViewer extends StatefulWidget {
final List<ConversationMessageDto> messages;
final ScrollController? scrollController;
const ConversationLogViewer({
super.key,
required this.messages,
this.scrollController,
});
@override
State<ConversationLogViewer> createState() => _ConversationLogViewerState();
}
class _ConversationLogViewerState extends State<ConversationLogViewer> {
final Set<String> _expandedMessageIds = {};
late ScrollController _internalScrollController;
@override
void initState() {
super.initState();
_internalScrollController =
widget.scrollController ?? ScrollController();
}
@override
void dispose() {
if (widget.scrollController == null) {
_internalScrollController.dispose();
}
super.dispose();
}
void _toggleExpand(String messageId) {
setState(() {
if (_expandedMessageIds.contains(messageId)) {
_expandedMessageIds.remove(messageId);
} else {
_expandedMessageIds.add(messageId);
}
});
}
Future<void> _copyToClipboard(String text, BuildContext context) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
children: [
Icon(Iconsax.tick_circle, color: Colors.white, size: 18),
SizedBox(width: 8),
Text('Message copied to clipboard'),
],
),
backgroundColor: Theme.of(context).colorScheme.primary,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return FadeInUp(
duration: const Duration(milliseconds: 400),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
children: [
// Header
_buildHeader(colorScheme),
// Divider
Divider(
height: 1,
color: colorScheme.outline.withValues(alpha: 0.2),
),
// Messages list
Expanded(
child: _buildMessagesList(colorScheme),
),
],
),
),
);
}
Widget _buildHeader(ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Iconsax.document_text,
color: colorScheme.secondary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Conversation Logs',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${widget.messages.length}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
),
),
),
],
),
);
}
Widget _buildMessagesList(ColorScheme colorScheme) {
if (widget.messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Iconsax.document_text,
size: 48,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No conversation logs',
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Start chatting to see logs here',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
return ListView.builder(
controller: _internalScrollController,
padding: const EdgeInsets.all(8),
itemCount: widget.messages.length,
itemBuilder: (BuildContext context, int index) {
final ConversationMessageDto message = widget.messages[index];
return FadeInUp(
duration: Duration(milliseconds: 200 + (index * 30)),
child: _buildLogRow(message, colorScheme),
);
},
);
}
Widget _buildLogRow(
ConversationMessageDto message,
ColorScheme colorScheme,
) {
final bool isUser = message.role.toLowerCase() == 'user';
final bool isExpanded = _expandedMessageIds.contains(message.id);
final bool needsTruncation = message.content.length > 80;
final String displayText = (isExpanded || !needsTruncation)
? message.content
: '${message.content.substring(0, 80)}...';
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: isUser
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: colorScheme.secondaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isUser
? colorScheme.primary.withValues(alpha: 0.2)
: colorScheme.secondary.withValues(alpha: 0.2),
width: 1,
),
),
child: InkWell(
onTap: needsTruncation ? () => _toggleExpand(message.id) : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row with role, timestamp, and copy button
Row(
children: [
// Role icon and name
Icon(
isUser ? Iconsax.user : Iconsax.cpu,
size: 14,
color: isUser
? colorScheme.primary
: colorScheme.secondary,
),
const SizedBox(width: 6),
Text(
isUser ? 'User' : message.role,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isUser
? colorScheme.primary
: colorScheme.secondary,
),
),
const SizedBox(width: 12),
// Timestamp
Icon(
Iconsax.clock,
size: 11,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
timeago.format(message.timestamp),
style: TextStyle(
fontSize: 11,
color: colorScheme.onSurfaceVariant,
),
),
const Spacer(),
// Copy button
Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _copyToClipboard(message.content, context),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Iconsax.copy,
size: 14,
color: colorScheme.onSurfaceVariant,
),
),
),
),
],
),
const SizedBox(height: 8),
// Message content
Text(
displayText,
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurface,
height: 1.4,
),
),
// Expand/collapse indicator
if (needsTruncation)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
Icon(
isExpanded
? Iconsax.arrow_up_2
: Iconsax.arrow_down_1,
size: 12,
color: colorScheme.primary,
),
const SizedBox(width: 4),
Text(
isExpanded ? 'Show less' : 'Show more',
style: TextStyle(
fontSize: 11,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
),
);
}
}