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:
parent
d82738e17e
commit
d9aabfd00a
2 changed files with 448 additions and 53 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue