Added Agent Details including Task Details
This commit is contained in:
@@ -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})';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user