diff --git a/ac_web/lib/Api/Dtos/AgentTaskDto.dart b/ac_web/lib/Api/Dtos/AgentTaskDto.dart index ce5dc7a..0d2d950 100644 --- a/ac_web/lib/Api/Dtos/AgentTaskDto.dart +++ b/ac_web/lib/Api/Dtos/AgentTaskDto.dart @@ -2,57 +2,48 @@ import 'package:ac_web/Api/JsonReader.dart'; /// Dart counterpart of the backend `AgentTaskDto`. /// -/// The exact shape of the backend dto is not final yet, therefore everything -/// except the name is treated as optional. Unknown properties are simply -/// ignored, so this dto keeps working while the api grows. +/// A task is one piece of work an agent is currently busy with. While it runs +/// the agent appends short lines to [statusUpdates], so the list doubles as +/// the history of the task. class AgentTaskDto { const AgentTaskDto({ - required this.name, - this.id, - this.description, - this.status, - this.progress, - this.createdAt, - this.finishedAt, + required this.title, + this.startTime, + this.statusUpdates = const [], }); - final String name; - final String? id; - final String? description; - final String? status; + final String title; - /// Completion between 0 and 100 if the api reports it. - final int? progress; + /// Moment the agent picked the task up, if the api reports it. + final DateTime? startTime; - final DateTime? createdAt; - final DateTime? finishedAt; + /// Every status line the agent reported so far, oldest first. + final List statusUpdates; - bool get isFinished => finishedAt != null; + bool get hasStatusUpdates => statusUpdates.isNotEmpty; + + /// The most recent status line, or null while the agent did not report one. + String? get latestStatus => statusUpdates.isEmpty ? null : statusUpdates.last; factory AgentTaskDto.fromJson(Map json) { return AgentTaskDto( - name: JsonReader.optionalString(json, 'Name') ?? '', - id: JsonReader.optionalString(json, 'Id'), - description: JsonReader.optionalString(json, 'Description'), - status: JsonReader.optionalString(json, 'Status'), - progress: JsonReader.optionalInt(json, 'Progress'), - createdAt: JsonReader.optionalDateTime(json, 'CreatedAt'), - finishedAt: JsonReader.optionalDateTime(json, 'FinishedAt'), + // Older builds of the api called the property "Name". + title: JsonReader.optionalString(json, 'Title') ?? + JsonReader.optionalString(json, 'Name') ?? + '', + startTime: JsonReader.optionalDateTime(json, 'StartTime'), + statusUpdates: JsonReader.stringList(json, 'StatusUpdates'), ); } Map toJson() { return { - 'Name': name, - if (id != null) 'Id': id, - if (description != null) 'Description': description, - if (status != null) 'Status': status, - if (progress != null) 'Progress': progress, - if (createdAt != null) 'CreatedAt': createdAt!.toIso8601String(), - if (finishedAt != null) 'FinishedAt': finishedAt!.toIso8601String(), + 'Title': title, + if (startTime != null) 'StartTime': startTime!.toIso8601String(), + 'StatusUpdates': statusUpdates, }; } @override - String toString() => 'AgentTaskDto(name: $name, status: $status)'; + String toString() => 'AgentTaskDto(title: $title, updates: ${statusUpdates.length})'; } diff --git a/ac_web/lib/Api/JsonReader.dart b/ac_web/lib/Api/JsonReader.dart index 2c72558..8d1a555 100644 --- a/ac_web/lib/Api/JsonReader.dart +++ b/ac_web/lib/Api/JsonReader.dart @@ -85,6 +85,18 @@ class JsonReader { return int.tryParse(raw.toString()); } + /// Maps a json array of plain values onto a list of strings. + static List stringList(Map json, String key) { + final raw = value(json, key); + if (raw is! List) { + return const []; + } + return raw + .where((entry) => entry != null) + .map((entry) => entry.toString()) + .toList(growable: false); + } + /// Maps a json array onto a list of models, tolerating a missing property. static List list( Map json, diff --git a/ac_web/lib/Pages/AgentDetails.dart b/ac_web/lib/Pages/AgentDetails.dart new file mode 100644 index 0000000..733f859 --- /dev/null +++ b/ac_web/lib/Pages/AgentDetails.dart @@ -0,0 +1,241 @@ +import 'package:ac_web/Api/AcApi.dart'; +import 'package:ac_web/Api/Dtos/AgentDto.dart'; +import 'package:ac_web/Api/Dtos/AgentTaskDto.dart'; +import 'package:ac_web/Widgets/AgentStatusIndicator.dart'; +import 'package:ac_web/Widgets/AgentTaskCard.dart'; +import 'package:ac_web/Widgets/AgentTypeIcons.dart'; +import 'package:flutter/material.dart'; + +/// Page that shows every detail of a single agent, including all of its tasks +/// together with the status updates the agent reported for them. +class AgentDetails extends StatefulWidget { + const AgentDetails({super.key, required this.agent}); + + /// The agent as it was known when the page got opened. + final AgentDto agent; + + @override + State createState() => _AgentDetailsState(); +} + +class _AgentDetailsState extends State { + + late AgentDto mAgent; + + bool mIsReloading = false; + + @override + void initState() { + super.initState(); + mAgent = widget.agent; + } + + /// The api only offers the whole list of agents, so the agent this page + /// shows is looked up by its name again. + Future ReloadAgent() async { + + if (mIsReloading) { + return; + } + + setState(() { + mIsReloading = true; + }); + + try { + final List agents = await AcApi.instance.agents.getAgents(); + + final AgentDto? updated = agents + .where((agent) => agent.name == mAgent.name) + .firstOrNull; + + if (!mounted) { + return; + } + + setState(() { + mIsReloading = false; + if (updated != null) { + mAgent = updated; + } + }); + + if (updated == null) { + _showMessage("This agent is no longer reported by the api."); + } + } catch (error) { + + if (!mounted) { + return; + } + + setState(() { + mIsReloading = false; + }); + + _showMessage("Could not reload the agent: $error"); + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + + return Scaffold( + appBar: AppBar( + title: Text(mAgent.name.isEmpty ? "Agent" : mAgent.name), + actions: [ + IconButton( + onPressed: mIsReloading ? null : ReloadAgent, + icon: const Icon(Icons.refresh), + tooltip: "Reload", + ), + ], + ), + body: RefreshIndicator( + onRefresh: ReloadAgent, + child: ListView( + padding: EdgeInsetsGeometry.all(16), + children: [ + _buildHeader(context), + const SizedBox(height: 12), + _buildInfoRow(context, "Type", mAgent.type.displayName), + const SizedBox(height: 8), + _buildStatusRow(context), + const SizedBox(height: 8), + _buildInfoRow(context, "Last seen", _formatTimestamp(mAgent.lastSeen.toLocal())), + const SizedBox(height: 8), + _buildInfoRow(context, "Tasks", _taskCountLabel()), + const Divider(height: 32), + Text("Tasks", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + ..._buildTasks(context), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 16, + children: [ + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + child: Icon(mAgent.type.icon, color: Theme.of(context).colorScheme.onSurface), + ), + Expanded( + child: Text( + mAgent.name.isEmpty ? "Unnamed Agent" : mAgent.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ); + } + + /// Same layout as the other info rows, but with the colored status circle. + Widget _buildStatusRow(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + SizedBox( + width: 96, + child: Text("Status", style: Theme.of(context).textTheme.labelLarge), + ), + AgentStatusIndicator(status: mAgent.status, size: 14), + const SizedBox(width: 4), + Expanded(child: Text(mAgent.status.displayName)), + ], + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + SizedBox( + width: 96, + child: Text(label, style: Theme.of(context).textTheme.labelLarge), + ), + Expanded(child: SelectableText(value)), + ], + ); + } + + /// One card per task, newest first, with the newest one already expanded. + List _buildTasks(BuildContext context) { + + if (!mAgent.hasTasks) { + return [ + Text( + "This agent is currently not working on any task.", + style: Theme.of(context).textTheme.bodyMedium, + ), + ]; + } + + final List tasks = mAgent.tasks.toList() + ..sort(_byStartTimeDescending); + + return [ + for (int i = 0; i < tasks.length; i++) + AgentTaskCard(task: tasks[i], initiallyExpanded: i == 0), + ]; + } + + /// Newest task first. Tasks without a start time are kept at the end. + int _byStartTimeDescending(AgentTaskDto left, AgentTaskDto right) { + + if (left.startTime == null && right.startTime == null) { + return 0; + } + if (left.startTime == null) { + return 1; + } + if (right.startTime == null) { + return -1; + } + return right.startTime!.compareTo(left.startTime!); + } + + String _taskCountLabel() { + + final int count = mAgent.tasks.length; + + if (count == 0) { + return "No running tasks"; + } + return count == 1 ? "1 running task" : "$count running tasks"; + } + + /// "15. August 2025, 14:32" + String _formatTimestamp(DateTime timestamp) { + + final String hour = timestamp.hour.toString().padLeft(2, '0'); + final String minute = timestamp.minute.toString().padLeft(2, '0'); + + return "${timestamp.day}. ${_monthNames[timestamp.month - 1]} ${timestamp.year}, $hour:$minute"; + } + + static const List _monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; +} diff --git a/ac_web/lib/Widgets/AgentAvailabilityColumn.dart b/ac_web/lib/Widgets/AgentAvailabilityColumn.dart index c82864a..125b637 100644 --- a/ac_web/lib/Widgets/AgentAvailabilityColumn.dart +++ b/ac_web/lib/Widgets/AgentAvailabilityColumn.dart @@ -1,6 +1,8 @@ import 'package:ac_web/Api/AcApi.dart'; import 'package:ac_web/Api/Dtos/AgentDto.dart'; +import 'package:ac_web/Pages/AgentDetails.dart'; import 'package:ac_web/Widgets/AgentStatusIndicator.dart'; +import 'package:ac_web/Widgets/AgentTypeIcons.dart'; import 'package:flutter/material.dart'; /// Fetches all agents from the AgentsController and lists them with a colored @@ -69,9 +71,20 @@ class _AgentAvailabilityColumnState extends State{ leading: AgentStatusIndicator(status: agent.status), title: Text(agent.name), subtitle: Text("${agent.type.displayName} - ${agent.status.displayName}"), + trailing: Icon(agent.type.icon), + onTap: () => _openAgent(agent), ); }, ), ); } + + /// Opens a new page that shows all details of the given agent. + void _openAgent(AgentDto agent) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AgentDetails(agent: agent), + ), + ); + } } diff --git a/ac_web/lib/Widgets/AgentTaskCard.dart b/ac_web/lib/Widgets/AgentTaskCard.dart new file mode 100644 index 0000000..2702818 --- /dev/null +++ b/ac_web/lib/Widgets/AgentTaskCard.dart @@ -0,0 +1,134 @@ +import 'package:ac_web/Api/Dtos/AgentTaskDto.dart'; +import 'package:flutter/material.dart'; + +/// Card that shows a single task of an agent. +/// +/// Collapsed it only shows the title, the start time and the newest status +/// line. Expanded it lists every status update the agent reported so far. +class AgentTaskCard extends StatelessWidget { + const AgentTaskCard({super.key, required this.task, this.initiallyExpanded = false}); + + final AgentTaskDto task; + + /// Expands the status update history right away, e.g. for the newest task. + final bool initiallyExpanded; + + @override + Widget build(BuildContext context) { + + final ThemeData theme = Theme.of(context); + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadiusGeometry.circular(12)), + shadowColor: Colors.white, + color: Colors.black38, + margin: EdgeInsetsGeometry.fromLTRB(0, 8, 0, 8), + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + initiallyExpanded: initiallyExpanded, + shape: const Border(), + collapsedShape: const Border(), + leading: CircleAvatar( + backgroundColor: theme.colorScheme.inversePrimary, + child: Icon(Icons.task_alt, color: theme.colorScheme.onSurface), + ), + title: Text( + task.title.isEmpty ? "Untitled Task" : task.title, + style: theme.textTheme.titleMedium, + ), + subtitle: Text( + _subtitle(), + style: theme.textTheme.bodySmall, + ), + childrenPadding: EdgeInsetsGeometry.fromLTRB(16, 0, 16, 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!task.hasStatusUpdates) + Text( + "This task has no status updates yet.", + style: theme.textTheme.bodyMedium, + ) + else + for (int i = 0; i < task.statusUpdates.length; i++) + _buildStatusUpdate( + context, + task.statusUpdates[i], + isLast: i == task.statusUpdates.length - 1, + ), + ], + ), + ); + } + + /// Start time and, when the agent already reported one, the newest status. + String _subtitle() { + + final String? latest = task.latestStatus; + final String started = task.startTime == null + ? "Start unknown" + : "Started ${_formatTimestamp(task.startTime!.toLocal())}"; + + if (latest == null || latest.isEmpty) { + return started; + } + return "$started • $latest"; + } + + /// One entry of the status update history, drawn as a small timeline. + Widget _buildStatusUpdate(BuildContext context, String update, {required bool isLast}) { + + final ThemeData theme = Theme.of(context); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + // Dot of this entry plus the line down to the following one. + Column( + children: [ + Container( + width: 10, + height: 10, + margin: EdgeInsetsGeometry.only(top: 6), + decoration: BoxDecoration( + color: isLast ? theme.colorScheme.primary : theme.colorScheme.outline, + shape: BoxShape.circle, + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + color: theme.colorScheme.outline, + ), + ), + ], + ), + Expanded( + child: Padding( + padding: EdgeInsetsGeometry.only(bottom: isLast ? 0 : 12), + child: SelectableText( + update, + style: isLast + ? theme.textTheme.bodyMedium + : theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ), + ], + ), + ); + } + + /// e.g. "15.08.2025 09:05" + String _formatTimestamp(DateTime timestamp) { + + final String day = timestamp.day.toString().padLeft(2, '0'); + final String month = timestamp.month.toString().padLeft(2, '0'); + final String hour = timestamp.hour.toString().padLeft(2, '0'); + final String minute = timestamp.minute.toString().padLeft(2, '0'); + + return "$day.$month.${timestamp.year} $hour:$minute"; + } +} diff --git a/ac_web/lib/Widgets/AgentTypeIcons.dart b/ac_web/lib/Widgets/AgentTypeIcons.dart new file mode 100644 index 0000000..ea42bc2 --- /dev/null +++ b/ac_web/lib/Widgets/AgentTypeIcons.dart @@ -0,0 +1,25 @@ +import 'package:ac_web/Api/Dtos/EAgentType.dart'; +import 'package:flutter/material.dart'; + +/// Icons used for the different agent types, so that every page draws an +/// agent type the same way. +extension AgentTypeIcons on EAgentType { + IconData get icon { + switch (this) { + case EAgentType.acApi: + return Icons.hub; + case EAgentType.logAnalyzer: + return Icons.receipt_long; + case EAgentType.coder: + return Icons.code; + case EAgentType.researcher: + return Icons.science; + case EAgentType.securityAnalyst: + return Icons.security; + case EAgentType.journalist: + return Icons.newspaper; + case EAgentType.unknown: + return Icons.question_mark; + } + } +} diff --git a/ac_web/test/Api/ApiLayerTest.dart b/ac_web/test/Api/ApiLayerTest.dart index 9634f53..b76bbca 100644 --- a/ac_web/test/Api/ApiLayerTest.dart +++ b/ac_web/test/Api/ApiLayerTest.dart @@ -35,7 +35,11 @@ void main() { 'lastSeen': '2026-08-28T12:00:00Z', 'status': 'WORKING', 'tasks': [ - {'name': 'Implement api layer', 'status': 'InProgress', 'progress': 42}, + { + 'title': 'Implement api layer', + 'startTime': '2026-08-28T11:00:00Z', + 'statusUpdates': ['Started', 'Half way through'], + }, ], }, ]), @@ -50,8 +54,10 @@ void main() { expect(agents.single.type, EAgentType.coder); expect(agents.single.status, EAgentStatus.working); expect(agents.single.lastSeen.isUtc, isTrue); - expect(agents.single.tasks.single.name, 'Implement api layer'); - expect(agents.single.tasks.single.progress, 42); + expect(agents.single.tasks.single.title, 'Implement api layer'); + expect(agents.single.tasks.single.startTime, DateTime.utc(2026, 8, 28, 11)); + expect(agents.single.tasks.single.statusUpdates, ['Started', 'Half way through']); + expect(agents.single.tasks.single.latestStatus, 'Half way through'); }); test('parses numeric enums and falls back to unknown', () async { @@ -149,7 +155,13 @@ void main() { name: 'Ada', type: EAgentType.securityAnalyst, lastSeen: DateTime.utc(2026, 8, 28, 12), - tasks: const [AgentTaskDto(name: 'Review', status: 'Open')], + tasks: [ + AgentTaskDto( + title: 'Review', + startTime: DateTime.utc(2026, 8, 28, 11), + statusUpdates: const ['Open'], + ), + ], status: EAgentStatus.available, ); @@ -159,7 +171,9 @@ void main() { expect(restored.type, original.type); expect(restored.status, original.status); expect(restored.lastSeen, original.lastSeen); - expect(restored.tasks.single.name, 'Review'); + expect(restored.tasks.single.title, 'Review'); + expect(restored.tasks.single.startTime, DateTime.utc(2026, 8, 28, 11)); + expect(restored.tasks.single.statusUpdates, ['Open']); }); }); }