diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 5fba61e..2689ae5 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -461,6 +461,42 @@ class HubService { .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> listApprovalsRecords({ + List 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 approve(String id, String 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 { final bool enabled; final String provider; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 6816cfa..9d58981 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -15,8 +15,11 @@ class ApprovalsPage extends StatefulWidget { State createState() => _ApprovalsPageState(); } -class _ApprovalsPageState extends State { - late Future> _future; +class _ApprovalsPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tab; + late Future> _pendingFuture; + late Future> _historyFuture; // Reviewer identity recorded in the audit log. Defaults to // the OS user (closest stable identity Studio has without an // auth backend); operators can override it per session. @@ -32,14 +35,29 @@ class _ApprovalsPageState extends State { @override void initState() { super.initState(); - _future = HubService.instance.pendingApprovals(); + _tab = TabController(length: 2, vsync: this); + _refresh(); } - void _refresh() => setState(() { - _future = HubService.instance.pendingApprovals(); - }); + @override + void dispose() { + _tab.dispose(); + super.dispose(); + } - Future _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 _approve(ApprovalRecord a) async { try { await HubService.instance.approve(a.id, _reviewer); _toast('approved · ${a.flowName} › ${a.stepId}'); @@ -49,7 +67,7 @@ class _ApprovalsPageState extends State { } } - Future _reject(PendingApproval a) async { + Future _reject(ApprovalRecord a) async { final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; try { @@ -105,6 +123,13 @@ class _ApprovalsPageState extends State { backgroundColor: theme.scaffoldBackgroundColor, appBar: AppBar( title: const Text('Approvals'), + bottom: TabBar( + controller: _tab, + tabs: const [ + Tab(text: 'Pending'), + Tab(text: 'History'), + ], + ), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), @@ -114,54 +139,138 @@ class _ApprovalsPageState extends State { const SizedBox(width: FaiSpace.sm), ], ), - body: FutureBuilder>( - 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: _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), - ); - }, - ); - }, + body: TabBarView( + controller: _tab, + children: [ + _PendingList( + future: _pendingFuture, + onApprove: _approve, + onReject: _reject, + onRetry: _refresh, + ), + _HistoryList( + future: _historyFuture, + onRetry: _refresh, + ), + ], ), ); } } +class _PendingList extends StatelessWidget { + final Future> 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>( + 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> future; + final VoidCallback onRetry; + + const _HistoryList({required this.future, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FutureBuilder>( + 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 { - final PendingApproval approval; + final ApprovalRecord approval; final VoidCallback onApprove; final VoidCallback onReject; @@ -181,7 +290,7 @@ class _ApprovalCard extends StatelessWidget { children: [ Row( children: [ - FaiPill( + const FaiPill( label: 'pending', tone: FaiPillTone.warning, icon: Icons.pending_outlined, @@ -208,7 +317,7 @@ class _ApprovalCard extends StatelessWidget { approval.prompt, style: theme.textTheme.bodyLarge, ), - if (approval.showPreview != null) ...[ + if (approval.payloadPreview != null) ...[ const SizedBox(height: FaiSpace.md), Padding( padding: const EdgeInsets.only(bottom: 4), @@ -230,7 +339,7 @@ class _ApprovalCard extends StatelessWidget { border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( - _prettyPreview(approval.showPreview!), + _prettyPreview(approval.payloadPreview!), style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, @@ -292,3 +401,218 @@ class _ApprovalCard extends StatelessWidget { 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( + 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'; +}