feat(studio): Approvals page gains History tab

Pending tab keeps the existing card layout for actionable
approvals. New History tab shows decided rows (approved /
rejected / expired) as compact one-liners, color-coded by
status with reviewer + decided-at metadata. Click expands to
the full record (prompt, payload preview, reason, ids).

Closes the visibility gap from the recent fix: rejected
approvals were correctly written to the hash-chained audit
log but invisible to operators on the Approvals page itself.
HubService gains `listApprovalsRecords(statuses:)` returning
the decided-side fields the Studio wire previously dropped.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 17:33:00 +02:00
parent d82738e17e
commit d9aabfd00a
2 changed files with 448 additions and 53 deletions

View file

@ -461,6 +461,42 @@ class HubService {
.toList(); .toList();
} }
/// List approvals filtered by status. Empty [statuses] returns
/// every row (pending + decided + expired). Returned records
/// carry the decided-side fields so the History tab can render
/// "rejected by alice@studio at … because …".
Future<List<ApprovalRecord>> listApprovalsRecords({
List<String> statuses = const [],
int limit = 200,
}) async {
final entries = await _client.listApprovals(
statuses: statuses,
limit: limit,
);
return entries
.map(
(e) => ApprovalRecord(
id: e.approvalId,
flowName: e.flowName,
stepId: e.stepId,
prompt: e.prompt,
payloadPreview:
e.payloadPreview.isEmpty ? null : e.payloadPreview,
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
expiresAt: e.expiresAt.isEmpty
? null
: DateTime.tryParse(e.expiresAt),
status: e.status,
decidedAt: e.decidedAt.isEmpty
? null
: DateTime.tryParse(e.decidedAt),
decidedBy: e.decidedBy,
reason: e.reason,
),
)
.toList();
}
Future<void> approve(String id, String reviewer) => Future<void> approve(String id, String reviewer) =>
_client.approve(approvalId: id, reviewer: reviewer); _client.approve(approvalId: id, reviewer: reviewer);
@ -695,6 +731,41 @@ class PendingApproval {
}); });
} }
/// Full approval record including decided-side fields. Used by
/// the History tab so operators can review what they (or the
/// system) decided.
class ApprovalRecord {
final String id;
final String flowName;
final String stepId;
final String prompt;
final String? payloadPreview;
final DateTime createdAt;
final DateTime? expiresAt;
/// One of: pending / approved / rejected / expired.
final String status;
final DateTime? decidedAt;
/// Reviewer handle (e.g. "alice@studio") or "system" for
/// auto-expired approvals. Empty until decided.
final String decidedBy;
/// Reject reason empty for approve / pending / expired.
final String reason;
const ApprovalRecord({
required this.id,
required this.flowName,
required this.stepId,
required this.prompt,
required this.payloadPreview,
required this.createdAt,
required this.expiresAt,
required this.status,
required this.decidedAt,
required this.decidedBy,
required this.reason,
});
}
class SystemAiStatus { class SystemAiStatus {
final bool enabled; final bool enabled;
final String provider; final String provider;

View file

@ -15,8 +15,11 @@ class ApprovalsPage extends StatefulWidget {
State<ApprovalsPage> createState() => _ApprovalsPageState(); State<ApprovalsPage> createState() => _ApprovalsPageState();
} }
class _ApprovalsPageState extends State<ApprovalsPage> { class _ApprovalsPageState extends State<ApprovalsPage>
late Future<List<PendingApproval>> _future; with SingleTickerProviderStateMixin {
late final TabController _tab;
late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture;
// Reviewer identity recorded in the audit log. Defaults to // Reviewer identity recorded in the audit log. Defaults to
// the OS user (closest stable identity Studio has without an // the OS user (closest stable identity Studio has without an
// auth backend); operators can override it per session. // auth backend); operators can override it per session.
@ -32,14 +35,29 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_future = HubService.instance.pendingApprovals(); _tab = TabController(length: 2, vsync: this);
_refresh();
} }
void _refresh() => setState(() { @override
_future = HubService.instance.pendingApprovals(); void dispose() {
}); _tab.dispose();
super.dispose();
}
Future<void> _approve(PendingApproval a) async { void _refresh() {
setState(() {
_pendingFuture = HubService.instance.listApprovalsRecords(
statuses: const ['pending'],
);
_historyFuture = HubService.instance.listApprovalsRecords(
statuses: const ['approved', 'rejected', 'expired'],
limit: 200,
);
});
}
Future<void> _approve(ApprovalRecord a) async {
try { try {
await HubService.instance.approve(a.id, _reviewer); await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}'); _toast('approved · ${a.flowName} ${a.stepId}');
@ -49,7 +67,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
} }
} }
Future<void> _reject(PendingApproval a) async { Future<void> _reject(ApprovalRecord a) async {
final reason = await _promptReason(context); final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return; if (reason == null || reason.isEmpty) return;
try { try {
@ -105,6 +123,13 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar( appBar: AppBar(
title: const Text('Approvals'), title: const Text('Approvals'),
bottom: TabBar(
controller: _tab,
tabs: const [
Tab(text: 'Pending'),
Tab(text: 'History'),
],
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh, size: 18), icon: const Icon(Icons.refresh, size: 18),
@ -114,54 +139,138 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
], ],
), ),
body: FutureBuilder<List<PendingApproval>>( body: TabBarView(
future: _future, controller: _tab,
builder: (context, snapshot) { children: [
if (snapshot.connectionState == ConnectionState.waiting) { _PendingList(
return const Center(child: CircularProgressIndicator()); future: _pendingFuture,
} onApprove: _approve,
if (snapshot.hasError) { onReject: _reject,
return FaiEmptyState( onRetry: _refresh,
icon: Icons.cloud_off_outlined, ),
iconColor: theme.colorScheme.error, _HistoryList(
title: 'Hub unreachable', future: _historyFuture,
hint: 'Start the hub with `fai serve`.', onRetry: _refresh,
action: FilledButton.tonal( ),
onPressed: _refresh, ],
child: const Text('Retry'),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return const FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () => _approve(a),
onReject: () => _reject(a),
);
},
);
},
), ),
); );
} }
} }
class _PendingList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final void Function(ApprovalRecord) onApprove;
final void Function(ApprovalRecord) onReject;
final VoidCallback onRetry;
const _PendingList({
required this.future,
required this.onApprove,
required this.onReject,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<List<ApprovalRecord>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return const FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
);
},
);
}
}
class _HistoryList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final VoidCallback onRetry;
const _HistoryList({required this.future, required this.onRetry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<List<ApprovalRecord>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
);
}
final decided = snapshot.data ?? [];
if (decided.isEmpty) {
return const FaiEmptyState(
icon: Icons.history,
title: 'No history yet',
hint:
'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: decided.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final a = decided[i];
return _HistoryRow(record: a);
},
);
},
);
}
}
class _ApprovalCard extends StatelessWidget { class _ApprovalCard extends StatelessWidget {
final PendingApproval approval; final ApprovalRecord approval;
final VoidCallback onApprove; final VoidCallback onApprove;
final VoidCallback onReject; final VoidCallback onReject;
@ -181,7 +290,7 @@ class _ApprovalCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
FaiPill( const FaiPill(
label: 'pending', label: 'pending',
tone: FaiPillTone.warning, tone: FaiPillTone.warning,
icon: Icons.pending_outlined, icon: Icons.pending_outlined,
@ -208,7 +317,7 @@ class _ApprovalCard extends StatelessWidget {
approval.prompt, approval.prompt,
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
if (approval.showPreview != null) ...[ if (approval.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.only(bottom: 4),
@ -230,7 +339,7 @@ class _ApprovalCard extends StatelessWidget {
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: SelectableText( child: SelectableText(
_prettyPreview(approval.showPreview!), _prettyPreview(approval.payloadPreview!),
style: FaiTheme.mono( style: FaiTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
@ -292,3 +401,218 @@ class _ApprovalCard extends StatelessWidget {
return '${hours}h'; return '${hours}h';
} }
} }
/// Compact one-line entry for a decided approval. Click expands
/// the prompt, decision metadata, and payload preview in a
/// dialog. Keeps the History tab scannable when there are many
/// decisions.
class _HistoryRow extends StatelessWidget {
final ApprovalRecord record;
const _HistoryRow({required this.record});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (tone, icon, label) = _statusPresentation(record.status);
final decided = record.decidedAt;
final timeStr =
decided == null ? '' : _formatTimestamp(decided.toLocal());
return InkWell(
onTap: () => showDialog<void>(
context: context,
builder: (_) => _HistoryDialog(record: record),
),
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: FaiSpace.sm,
),
child: Row(
children: [
FaiPill(label: label, tone: tone, icon: icon),
const SizedBox(width: FaiSpace.md),
SizedBox(
width: 150,
child: Text(
timeStr,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
child: Text(
'${record.flowName} ${record.stepId}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: FaiSpace.md),
if (record.decidedBy.isNotEmpty)
Text(
record.decidedBy,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
static (FaiPillTone, IconData, String) _statusPresentation(String status) {
switch (status) {
case 'approved':
return (FaiPillTone.success, Icons.check, 'approved');
case 'rejected':
return (FaiPillTone.danger, Icons.close, 'rejected');
case 'expired':
return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired');
default:
return (FaiPillTone.neutral, Icons.help_outline, status);
}
}
}
class _HistoryDialog extends StatelessWidget {
final ApprovalRecord record;
const _HistoryDialog({required this.record});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (tone, icon, label) = _HistoryRow._statusPresentation(record.status);
return AlertDialog(
title: Row(
children: [
FaiPill(label: label, tone: tone, icon: icon),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
'${record.flowName} ${record.stepId}',
style: theme.textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540, maxHeight: 480),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (record.decidedAt != null)
_kv(theme, 'decided',
'${_formatTimestamp(record.decidedAt!.toLocal())} '
'· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
_kv(theme, 'created',
_formatTimestamp(record.createdAt.toLocal())),
if (record.reason.isNotEmpty)
_kv(theme, 'reason', record.reason),
const SizedBox(height: FaiSpace.md),
Text(
'PROMPT',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: 4),
SelectableText(
record.prompt,
style: theme.textTheme.bodyMedium,
),
if (record.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
'PAYLOAD PREVIEW',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
record.payloadPreview!,
style: FaiTheme.mono(size: 11),
),
),
],
const SizedBox(height: FaiSpace.md),
Text(
record.id,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
Widget _kv(ThemeData theme, String k, String v) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 70,
child: Text(
k,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
Expanded(
child: SelectableText(
v,
style: theme.textTheme.bodySmall,
),
),
],
),
);
}
}
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
/// the history row + dialog. Always renders in the operator's
/// local time zone ISO timestamps from the wire still appear
/// raw in the per-record dialog for cross-checking.
String _formatTimestamp(DateTime local) {
final yyyy = local.year.toString().padLeft(4, '0');
final mo = local.month.toString().padLeft(2, '0');
final dd = local.day.toString().padLeft(2, '0');
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0');
return '$yyyy-$mo-$dd $hh:$mm:$ss';
}