feat(studio): MCP suggestions + Approvals/Audit/Settings i18n (v0.26.0)

- Add curated catalogue of 9 official Anthropic MCP servers as
  click-to-prefill chips in the Add-MCP-server dialog. Operator
  still confirms via "Add + discover"; nothing is auto-spawned,
  preserving the regulated-environment trust model.
- Localize Approvals page: pending/history empty states, status
  pills, payload preview, approve/reject toasts, reject dialog,
  history detail dialog, expires-in pill.
- Localize Audit page: filter chips, clear-log dialog and toasts,
  no-events / hub-unreachable empty states, live-status bar with
  pluralized event count, hash-chain-verified badge, System AI
  panel (cached pill, latency, regenerate tooltip, asking
  state), event-detail dialog actions.
- Localize Settings dialog: hub-endpoint section, channel
  blurb + popup-menu items, System AI panel headers and
  off-blurb, MCP/N8N panel headers + pluralized counts +
  refresh/add/remove tooltips, Add-MCP and Add-n8n dialogs
  (intro, suggestions label, all field labels and hints, save
  buttons, toasts).

Field labels that map directly to JSON keys (event_id, flow,
step, module, provider, endpoint, model) stay English by design
— they're technical identifiers, not UI copy.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 02:38:11 +02:00
parent 349619d68e
commit d25b4c87ae
9 changed files with 2022 additions and 209 deletions

View file

@ -59,24 +59,26 @@ class _ApprovalsPageState extends State<ApprovalsPage>
}
Future<void> _approve(ApprovalRecord a) async {
final l = AppLocalizations.of(context)!;
try {
await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}');
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
_refresh();
} catch (e) {
_toast('approve failed: $e');
_toast(l.approvalsApproveFailed(e.toString()));
}
}
Future<void> _reject(ApprovalRecord a) async {
final l = AppLocalizations.of(context)!;
final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return;
try {
await HubService.instance.reject(a.id, _reviewer, reason);
_toast('rejected · ${a.flowName} ${a.stepId}');
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
_refresh();
} catch (e) {
_toast('reject failed: $e');
_toast(l.approvalsRejectFailed(e.toString()));
}
}
@ -87,22 +89,23 @@ class _ApprovalsPageState extends State<ApprovalsPage>
Future<String?> _promptReason(BuildContext context) async {
final controller = TextEditingController();
final l = AppLocalizations.of(context)!;
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reject approval'),
title: Text(l.approvalsRejectDialogTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Reason (recorded in audit log)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l.approvalsRejectReasonLabel,
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: const Text('Cancel'),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text),
@ -110,7 +113,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: const Text('Reject'),
child: Text(l.approvalsRejectButton),
),
],
),
@ -134,7 +137,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip,
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
@ -175,6 +178,7 @@ class _PendingList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return FutureBuilder<List<ApprovalRecord>>(
future: future,
builder: (context, snapshot) {
@ -185,21 +189,20 @@ class _PendingList extends StatelessWidget {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
child: Text(l.buttonRetry),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return const FaiEmptyState(
return FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
title: l.approvalsInboxZero,
hint: l.approvalsInboxHint,
);
}
return ListView.separated(
@ -229,6 +232,7 @@ class _HistoryList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return FutureBuilder<List<ApprovalRecord>>(
future: future,
builder: (context, snapshot) {
@ -239,21 +243,20 @@ class _HistoryList extends StatelessWidget {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
child: Text(l.buttonRetry),
),
);
}
final decided = snapshot.data ?? [];
if (decided.isEmpty) {
return const FaiEmptyState(
return 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.',
title: l.approvalsHistoryEmpty,
hint: l.approvalsHistoryEmptyHint,
);
}
return ListView.separated(
@ -284,6 +287,7 @@ class _ApprovalCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return FaiCard(
accentTop: true,
child: Column(
@ -291,8 +295,8 @@ class _ApprovalCard extends StatelessWidget {
children: [
Row(
children: [
const FaiPill(
label: 'pending',
FaiPill(
label: l.approvalsPillPending,
tone: FaiPillTone.warning,
icon: Icons.pending_outlined,
),
@ -307,7 +311,7 @@ class _ApprovalCard extends StatelessWidget {
),
if (approval.expiresAt != null)
FaiPill(
label: _expiresIn(approval.expiresAt!),
label: _expiresInLabel(context, approval.expiresAt!),
tone: FaiPillTone.neutral,
icon: Icons.schedule,
),
@ -323,7 +327,7 @@ class _ApprovalCard extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'PAYLOAD PREVIEW',
l.approvalsPayloadPreview,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -362,7 +366,7 @@ class _ApprovalCard extends StatelessWidget {
OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close, size: 16),
label: const Text('Reject'),
label: Text(l.approvalsRejectButton),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
@ -374,7 +378,7 @@ class _ApprovalCard extends StatelessWidget {
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check, size: 16),
label: const Text('Approve'),
label: Text(l.approvalsApproveButton),
),
],
),
@ -393,13 +397,13 @@ class _ApprovalCard extends StatelessWidget {
}
}
String _expiresIn(DateTime t) {
String _expiresInLabel(BuildContext context, DateTime t) {
final l = AppLocalizations.of(context)!;
final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired';
if (remaining.isNegative) return l.approvalsPillExpired;
final minutes = remaining.inMinutes;
if (minutes < 60) return '${minutes}m';
final hours = remaining.inHours;
return '${hours}h';
if (minutes < 60) return l.approvalsExpiresIn(minutes);
return l.approvalsExpiresInHours(remaining.inHours);
}
}
@ -414,7 +418,7 @@ class _HistoryRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (tone, icon, label) = _statusPresentation(record.status);
final (tone, icon, label) = _statusPresentation(context, record.status);
final decided = record.decidedAt;
final timeStr =
decided == null ? '' : _formatTimestamp(decided.toLocal());
@ -467,14 +471,22 @@ class _HistoryRow extends StatelessWidget {
);
}
static (FaiPillTone, IconData, String) _statusPresentation(String status) {
static (FaiPillTone, IconData, String) _statusPresentation(
BuildContext context,
String status,
) {
final l = AppLocalizations.of(context)!;
switch (status) {
case 'approved':
return (FaiPillTone.success, Icons.check, 'approved');
return (FaiPillTone.success, Icons.check, l.approvalsPillApproved);
case 'rejected':
return (FaiPillTone.danger, Icons.close, 'rejected');
return (FaiPillTone.danger, Icons.close, l.approvalsPillRejected);
case 'expired':
return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired');
return (
FaiPillTone.neutral,
Icons.timer_off_outlined,
l.approvalsPillExpired,
);
default:
return (FaiPillTone.neutral, Icons.help_outline, status);
}
@ -488,7 +500,8 @@ class _HistoryDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (tone, icon, label) = _HistoryRow._statusPresentation(record.status);
final (tone, icon, label) =
_HistoryRow._statusPresentation(context, record.status);
return AlertDialog(
title: Row(
children: [
@ -511,16 +524,17 @@ class _HistoryDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (record.decidedAt != null)
_kv(theme, 'decided',
_kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided,
'${_formatTimestamp(record.decidedAt!.toLocal())} '
'· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
_kv(theme, 'created',
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
_kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated,
_formatTimestamp(record.createdAt.toLocal())),
if (record.reason.isNotEmpty)
_kv(theme, 'reason', record.reason),
_kv(theme, AppLocalizations.of(context)!.approvalsDialogReason,
record.reason),
const SizedBox(height: FaiSpace.md),
Text(
'PROMPT',
AppLocalizations.of(context)!.approvalsDialogPrompt,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -534,7 +548,7 @@ class _HistoryDialog extends StatelessWidget {
if (record.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
'PAYLOAD PREVIEW',
AppLocalizations.of(context)!.approvalsPayloadPreview,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -570,7 +584,7 @@ class _HistoryDialog extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(AppLocalizations.of(context)!.buttonClose),
),
],
);