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

@ -41,6 +41,7 @@ class _AuditPageState extends State<AuditPage> {
}
Future<void> _onClearPressed() async {
final l = AppLocalizations.of(context)!;
final outcome = await _ClearAuditDialog.show(context);
if (outcome == null || !mounted) return;
try {
@ -50,18 +51,13 @@ class _AuditPageState extends State<AuditPage> {
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Cleared ${r.purged} events on channel "${r.channel}". '
'Chain reseeded with marker.',
),
),
SnackBar(content: Text(l.auditClearedToast(r.purged, r.channel))),
);
_refresh();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Clear failed: ${_friendly(e)}')),
SnackBar(content: Text(l.auditClearFailed(_friendly(e)))),
);
}
}
@ -115,7 +111,7 @@ class _AuditPageState extends State<AuditPage> {
),
IconButton(
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
tooltip: 'Clear log (local/dev channels only)',
tooltip: AppLocalizations.of(context)!.auditClearLogTooltip,
onPressed: _onClearPressed,
),
const SizedBox(width: FaiSpace.sm),
@ -131,15 +127,15 @@ class _AuditPageState extends State<AuditPage> {
? FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
title: AppLocalizations.of(context)!.hubUnreachable,
hint: AppLocalizations.of(context)!.hubUnreachableHint,
)
: filtered.isEmpty
? const FaiEmptyState(
? FaiEmptyState(
icon: Icons.timeline_outlined,
title: 'No events yet',
title: AppLocalizations.of(context)!.auditNoEvents,
hint:
'Run a flow to populate the audit stream.\nEvents appear here within seconds.',
AppLocalizations.of(context)!.auditNoEventsHint,
)
: ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
@ -220,11 +216,12 @@ class _FilterChips extends StatelessWidget {
@override
Widget build(BuildContext context) {
const items = [
('all', 'all'),
('flow.', 'flow'),
('step.', 'step'),
('module.', 'module'),
final l = AppLocalizations.of(context)!;
final items = [
('all', l.auditFilterAll),
('flow.', l.auditFilterFlow),
('step.', l.auditFilterStep),
('module.', l.auditFilterModule),
];
return Row(
children: [
@ -317,6 +314,7 @@ class _LiveStatusBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final live = error == null;
return Container(
width: double.infinity,
@ -339,8 +337,8 @@ class _LiveStatusBar extends StatelessWidget {
const SizedBox(width: FaiSpace.sm),
Text(
live
? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}'
: 'disconnected · $error',
? l.auditLiveStatus(eventCount)
: l.auditDisconnected(error!),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -356,7 +354,7 @@ class _LiveStatusBar extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
'hash chain verified',
l.auditHashChainVerified,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
@ -445,6 +443,7 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
String prettyDetail() {
final raw = event.detail;
if (raw == null || raw.isEmpty) return '';
@ -511,7 +510,7 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
if (detail.isNotEmpty) ...[
const SizedBox(height: FaiSpace.md),
Text(
'DETAIL',
l.auditDetailHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -556,24 +555,24 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
icon: const Icon(Icons.auto_awesome, size: 16),
label: Text(
_explaining
? 'Asking…'
? l.auditAsking
: _explanation == null
? 'Explain'
: 'Re-ask',
? l.auditExplain
: l.auditReask,
),
)
else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null))
Tooltip(
message: 'Configure System AI in Settings (Cmd+,)',
message: l.auditConfigureSystemAi,
child: OutlinedButton.icon(
onPressed: null,
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
label: const Text('Explain'),
label: Text(l.auditExplain),
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(l.buttonClose),
),
],
);
@ -648,6 +647,7 @@ class _ExplanationPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = result?.isSuccess ?? false;
final color = explaining
? theme.colorScheme.primary
@ -670,7 +670,7 @@ class _ExplanationPanel extends StatelessWidget {
Icon(Icons.auto_awesome, size: 14, color: color),
const SizedBox(width: FaiSpace.xs),
Text(
'SYSTEM AI',
l.auditSystemAi,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -686,12 +686,16 @@ class _ExplanationPanel extends StatelessWidget {
if (result != null && result!.isSuccess && result!.cached) ...[
const SizedBox(width: FaiSpace.xs),
Tooltip(
message:
'Served from the local cache. Originally generated '
'${result!.cachedAt.isEmpty ? "at unknown time" : "on ${result!.cachedAt}"}'
'${result!.cacheHits > 1 ? " · ${result!.cacheHits} hits" : ""}.',
child: const FaiPill(
label: 'cached',
message: () {
final hits = result!.cacheHits > 1
? l.auditCachedTooltipHits(result!.cacheHits)
: '';
return result!.cachedAt.isEmpty
? l.auditCachedTooltipUnknown(hits)
: l.auditCachedTooltipKnown(result!.cachedAt, hits);
}(),
child: FaiPill(
label: l.auditCachedPill,
tone: FaiPillTone.success,
icon: Icons.bolt,
),
@ -701,8 +705,8 @@ class _ExplanationPanel extends StatelessWidget {
if (result != null && result!.isSuccess && result!.latencyMs > 0)
Text(
result!.cached
? 'orig ${result!.latencyMs} ms'
: '${result!.latencyMs} ms',
? l.auditOriginalLatency(result!.latencyMs)
: l.auditLatency(result!.latencyMs),
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
@ -711,7 +715,7 @@ class _ExplanationPanel extends StatelessWidget {
if (result != null && result!.isSuccess && onRegenerate != null) ...[
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: 'Regenerate — skip cache, ask the model again',
message: l.auditRegenerateTooltip,
child: IconButton(
icon: const Icon(Icons.refresh, size: 14),
visualDensity: VisualDensity.compact,
@ -732,7 +736,7 @@ class _ExplanationPanel extends StatelessWidget {
),
const SizedBox(width: FaiSpace.sm),
Text(
'Asking the configured System AI…',
l.auditAskingFull,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -749,7 +753,7 @@ class _ExplanationPanel extends StatelessWidget {
if (result!.fixHint.isNotEmpty) ...[
const SizedBox(height: FaiSpace.sm),
Text(
'FIX',
l.auditFixLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -820,8 +824,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: const Text('Clear audit log?'),
title: Text(l.auditClearDialogTitle),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
@ -829,9 +834,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wipes every audit event on the active channel and seeds a '
'fresh chain.reset marker carrying reviewer + reason. '
'Refused on beta / production. Irreversible.',
l.auditClearDialogBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -839,9 +842,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
const SizedBox(height: FaiSpace.lg),
TextField(
controller: _reviewer,
decoration: const InputDecoration(
labelText: 'Reviewer',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l.auditClearReviewerLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
@ -852,9 +855,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
controller: _reason,
autofocus: true,
decoration: InputDecoration(
labelText: 'Reason (recorded in chain.reset marker)',
labelText: l.auditClearReasonLabel,
helperText: _reason.text.trim().isEmpty
? 'Required — auditors will read this.'
? l.auditClearReasonHelper
: null,
border: const OutlineInputBorder(),
isDense: true,
@ -867,7 +870,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text('Cancel'),
child: Text(l.buttonCancel),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _reason,
@ -885,7 +888,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('Clear log'),
child: Text(l.auditClearLogButton),
),
),
],