Added Agent Details including Task Details

This commit is contained in:
2026-09-01 13:57:08 +02:00
parent 7a9ce396f4
commit 525aaa6555
7 changed files with 469 additions and 39 deletions
+25 -34
View File
@@ -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 <String>[],
});
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<String> 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<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'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})';
}
+12
View File
@@ -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<String> stringList(Map<String, dynamic> json, String key) {
final raw = value(json, key);
if (raw is! List) {
return const <String>[];
}
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<T> list<T>(
Map<String, dynamic> json,
+241
View File
@@ -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<AgentDetails> createState() => _AgentDetailsState();
}
class _AgentDetailsState extends State<AgentDetails> {
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<void> ReloadAgent() async {
if (mIsReloading) {
return;
}
setState(() {
mIsReloading = true;
});
try {
final List<AgentDto> 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<Widget> _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<AgentTaskDto> 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<String> _monthNames = <String>[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
}
@@ -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<AgentAvailabilityColumn>{
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<void>(
builder: (context) => AgentDetails(agent: agent),
),
);
}
}
+134
View File
@@ -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";
}
}
+25
View File
@@ -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;
}
}
}
+19 -5
View File
@@ -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']);
});
});
}