From c11461b0f99a55cf28ec4e641d4c415a97d68d92 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 12:36:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(studio):=20trust=20pass=20=E2=80=94=20frie?= =?UTF-8?q?ndly=20errors=20+=20store=20clarity=20+=20MCP=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the May-2026 trust pass. Drops the wall of gRPC trailers from every error surface and makes the Store honest about what is and isn't installable. Friendly errors: - New `friendlyError(Object, AppLocalizations)` mapper turns GrpcError + arbitrary throwables into a localised headline, optional recovery hint, and a verbatim detail string kept behind a "Show details" expander. Duck-typed on `.code` / `.message` so Studio doesn't have to depend on package:grpc directly. - `FaiErrorBox` gains an `error:` constructor that runs the mapper. Every call site that used to render `snap.error.toString()` (flows, welcome, store) switches to it. - 9 .arb entries per locale cover the gRPC codes we actually emit (INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED, FAILED_PRECONDITION, INTERNAL, UNAVAILABLE, UNAUTHENTICATED) plus copy/details affordances. - `test/friendly_error_test.dart` — 6 unit tests for the mapper. Covers the mapping table, locale-switching, and the non-gRPC fallback so future regressions show up in CI. Capability discovery: - New `HubService.allCapabilities()` reads the kind-aware capability list (wasm + builtin + federated) and returns a Dart-side `CapabilityInfo` value type. The flow page's missing-dependency check uses it so `system.approval` and federated MCP/n8n tools count as "available" — fixes the Run button staying disabled forever. - `HubService.listModules()` filters to kind=wasm so the Modules page doesn't sprout synthetic "system" entries that the operator can't uninstall. Store clarity: - New "Installable only" filter, on by default. Roughly 2/3 of seed entries currently carry `status: planned`; the default view stops being noise. - Featured-strip cards for planned modules now show a "Coming soon" pill instead of an empty action area. - Main-grid cards for non-installable modules dim to 60% opacity so the eye lands on actionable cards first. - Detail-sheet "Nicht installierbar" tooltip → inline hint box. The reason is visible without hovering. MCP localisation: - `_kMcpSuggestions` no longer holds 11 hardcoded English description strings. The `description` field is replaced with a `resolveDescription(AppLocalizations)` lookup that switches on the suggestion `name` to read the matching `mcpSuggestion*Desc` .arb key. EN + DE shipped. - New `FaiEnBadge` widget renders a small `[EN]` pill when the active locale isn't English. Used next to MCP / federated store entries' tagline + description because the server supplies them in English and we can't translate on the fly yet — the badge is the honest signal until the planned `studio.translate` plugin lands. Plus housekeeping: removed the unused `_keepImport` lint escape in the test and the dangling library doc-comment in `format.dart`. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/data/friendly_error.dart | 148 +++++++++++++++++++ lib/data/hub.dart | 58 +++++++- lib/l10n/app_de.arb | 38 +++++ lib/l10n/app_en.arb | 38 +++++ lib/l10n/app_localizations.dart | 210 +++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 126 ++++++++++++++++ lib/l10n/app_localizations_en.dart | 125 ++++++++++++++++ lib/pages/flows.dart | 51 +++---- lib/pages/store.dart | 172 +++++++++++++++++++--- lib/pages/welcome.dart | 2 +- lib/widgets/fai_en_badge.dart | 61 ++++++++ lib/widgets/fai_error_box.dart | 139 ++++++++++++++---- lib/widgets/fai_settings_dialog.dart | 70 ++++++--- lib/widgets/widgets.dart | 1 + test/friendly_error_test.dart | 84 +++++++++++ 15 files changed, 1219 insertions(+), 104 deletions(-) create mode 100644 lib/data/friendly_error.dart create mode 100644 lib/widgets/fai_en_badge.dart create mode 100644 test/friendly_error_test.dart diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart new file mode 100644 index 0000000..dfe4b6b --- /dev/null +++ b/lib/data/friendly_error.dart @@ -0,0 +1,148 @@ +// Friendly-error mapper. Turns a thrown object (typically a +// `GrpcError` from package:grpc) into a short human sentence +// plus an optional recovery hint, ready to drop into the UI. +// +// Why this exists: Studio used to render `GrpcError.toString()` +// verbatim — operators saw walls of text like +// "gRPC Error (code: 13, codeName: INTERNAL, message: ..., details: [], rawResponse: null, trailers: {...})". +// The friendly mapper folds every gRPC code into a one-line +// explanation we can localise, plus a recovery action when one +// is obvious (open settings, restart hub, retry). + +import '../l10n/app_localizations.dart'; + +/// Result of running an error through [friendlyError]. The +/// `headline` is the short user-facing sentence; `detail` is +/// the verbatim original error string (kept around in case the +/// operator wants to copy-paste it into a bug report). `hint` +/// is a one-sentence recovery suggestion or `null`. +class FriendlyError { + final String headline; + final String detail; + final String? hint; + + const FriendlyError({ + required this.headline, + required this.detail, + this.hint, + }); +} + +/// Map an arbitrary thrown object to a [FriendlyError]. Always +/// returns a value — never throws — so callers can drop the +/// result straight into UI without try/catch ceremony. +FriendlyError friendlyError(Object error, AppLocalizations l) { + // We deliberately don't import package:grpc here so Studio + // doesn't have to add it to its own pubspec — the dependency + // lives one layer down in fai_dart_sdk. `GrpcError` has a + // stable `.code` (int) and `.message` (String?) shape; we + // duck-type on those instead of an `is` check. + final code = _intField(error, 'code'); + final detail = _stringField(error, 'message') ?? ''; + if (code != null) { + switch (code) { + case 3: // INVALID_ARGUMENT + return FriendlyError( + headline: l.errInvalidArgument, + detail: detail, + hint: l.errInvalidArgumentHint, + ); + case 5: // NOT_FOUND + return FriendlyError( + headline: l.errNotFound, + detail: detail, + hint: l.errNotFoundHint, + ); + case 6: // ALREADY_EXISTS + return FriendlyError( + headline: l.errAlreadyExists, + detail: detail, + hint: null, + ); + case 7: // PERMISSION_DENIED + return FriendlyError( + headline: l.errPermissionDenied, + detail: detail, + hint: l.errPermissionDeniedHint, + ); + case 9: // FAILED_PRECONDITION + return FriendlyError( + headline: l.errFailedPrecondition, + detail: detail, + hint: l.errFailedPreconditionHint, + ); + case 13: // INTERNAL + return FriendlyError( + headline: l.errInternal, + detail: detail, + hint: l.errInternalHint, + ); + case 14: // UNAVAILABLE + return FriendlyError( + headline: l.errUnavailable, + detail: detail, + hint: l.errUnavailableHint, + ); + case 16: // UNAUTHENTICATED + return FriendlyError( + headline: l.errUnauthenticated, + detail: detail, + hint: l.errUnauthenticatedHint, + ); + default: + final codeName = _stringField(error, 'codeName') ?? 'gRPC $code'; + return FriendlyError( + headline: '$codeName: ${detail.isEmpty ? l.errGeneric : detail}', + detail: detail, + hint: null, + ); + } + } + // Non-gRPC error (e.g. FormatException from a bad URL). Show + // its toString — but as the headline, not buried in a wall. + return FriendlyError( + headline: error.toString(), + detail: error.toString(), + hint: null, + ); +} + +/// Try to read an `int` field by name off an arbitrary object. +/// Returns `null` when the field doesn't exist or has another +/// runtime type. Used to duck-type `GrpcError.code` without +/// pulling package:grpc as a Studio dependency. +int? _intField(Object obj, String field) { + try { + final dyn = obj as dynamic; + // ignore: avoid_dynamic_calls + final v = (() { + switch (field) { + case 'code': + return dyn.code; + } + return null; + })(); + return v is int ? v : null; + } catch (_) { + return null; + } +} + +String? _stringField(Object obj, String field) { + try { + final dyn = obj as dynamic; + // ignore: avoid_dynamic_calls + final v = (() { + switch (field) { + case 'message': + return dyn.message; + case 'codeName': + return dyn.codeName; + } + return null; + })(); + return v is String ? v : null; + } catch (_) { + return null; + } +} diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 0745dd2..1a461cb 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -93,13 +93,18 @@ class HubService { Future healthy() => _client.healthy(); + /// List of installed WASM modules, grouped by `module_name`. + /// Built-in and federated capabilities are excluded — they + /// don't correspond to a bundle on disk and would confuse the + /// Modules page (a "system" or "via:filesystem" pseudo-module + /// has nothing to uninstall). Callers that need every callable + /// capability — e.g. the flow's missing-dependencies check — + /// use [allCapabilities] instead. Future> listModules() async { final caps = await _client.listCapabilities(); - - // Group capabilities by module so Studio's UI maps a card - // to a module rather than a capability. + final wasm = caps.where((c) => c.kind.isEmpty || c.kind == 'wasm'); final byModule = >{}; - for (final c in caps) { + for (final c in wasm) { byModule.putIfAbsent(c.moduleName, () => []).add(c); } return byModule.entries.map((e) { @@ -115,6 +120,25 @@ class HubService { ..sort((a, b) => a.name.compareTo(b.name)); } + /// Every capability the hub can execute, with the `kind` tag + /// telling apart wasm / builtin / federated. The flow page + /// uses this to decide which capabilities are "already + /// available" — including built-ins like `system.approval` + /// and federated MCP / n8n tools — so the Run button enables + /// when the dependency is reachable, not only when a bundle + /// happens to be installed. + Future> allCapabilities() async { + final caps = await _client.listCapabilities(); + return caps + .map((c) => CapabilityInfo( + capability: c.capability, + version: c.version, + moduleName: c.moduleName, + kind: c.kind.isEmpty ? 'wasm' : c.kind, + )) + .toList(); + } + /// Fully-detailed manifest for one installed module. Future moduleInfo(String name) async { final r = await _client.moduleInfo(name); @@ -126,6 +150,7 @@ class HubService { .toList(), permissions: r.permissions, directory: r.directory, + acceptsMime: r.acceptsMime, ); } @@ -834,6 +859,26 @@ enum ThemeModeValue { } } +/// Single capability with its provenance. `kind` is one of +/// `wasm` (installed module), `builtin` (hub-internal, e.g. +/// `system.approval`), or `federated` (MCP / n8n tool reachable +/// through the bridge). UI uses `kind` to decide what actions +/// to offer — only `wasm` capabilities have an install / +/// uninstall affordance. +class CapabilityInfo { + final String capability; + final String version; + final String moduleName; + final String kind; + + const CapabilityInfo({ + required this.capability, + required this.version, + required this.moduleName, + required this.kind, + }); +} + /// UI-side type, decoupled from the proto wire type so pages /// don't import protobuf packages. class ModuleSummary { @@ -872,6 +917,10 @@ class ModuleDetail { final List capabilities; final List permissions; final String directory; + /// MIME allow-list declared in the module's `module.yaml`. + /// Empty when the module didn't declare any — caller falls + /// back to its built-in extension heuristic. + final List acceptsMime; const ModuleDetail({ required this.name, @@ -879,6 +928,7 @@ class ModuleDetail { required this.capabilities, required this.permissions, required this.directory, + this.acceptsMime = const [], }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5409e7e..ad60a1a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -75,6 +75,25 @@ "buttonReadDocs": "Doku öffnen", "buttonCopy": "In Zwischenablage kopieren", "buttonCopied": "Kopiert", + "buttonShowDetails": "Details anzeigen", + "buttonHideDetails": "Details ausblenden", + "storeInstallableOnly": "Nur installierbare", + "storePillComingSoon": "Bald verfügbar", + "storeNotInstallableInline": "Dieses Modul ist auf der Roadmap (Status: {status}). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.", + "@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } }, + + "mcpSuggestionDeepwikiDesc": "Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.", + "mcpSuggestionSemgrepDesc": "Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.", + "mcpSuggestionFilesystemDesc": "Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.", + "mcpSuggestionFetchDesc": "Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.", + "mcpSuggestionGithubDesc": "Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.", + "mcpSuggestionPuppeteerDesc": "Anthropic — Headless-Browser-Automation für Screenshots / Scraping.", + "mcpSuggestionPostgresDesc": "Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.", + "mcpSuggestionSqliteDesc": "Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.", + "mcpSuggestionBraveSearchDesc": "Anthropic — Websuche via Brave. API-Key erforderlich.", + "mcpSuggestionMemoryDesc": "Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.", + "mcpSuggestionTimeDesc": "Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.", + "mcpServerEnLanguageBadge": "Server-Text in Englisch — Studio zeigt ihn wie geliefert.", "settingsTitle": "Hub-Endpunkt", "settingsHost": "Host", @@ -512,6 +531,25 @@ "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow läuft…", "flowsNoOutputs": "Flow abgeschlossen ohne deklarierte Outputs.", + + "errGeneric": "Da ist etwas schiefgegangen.", + "errInvalidArgument": "Ungültige Eingabe.", + "errInvalidArgumentHint": "Werte im Formular prüfen und erneut versuchen.", + "errNotFound": "Nicht gefunden.", + "errNotFoundHint": "Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.", + "errAlreadyExists": "Existiert bereits.", + "errPermissionDenied": "Berechtigung verweigert.", + "errPermissionDeniedHint": "Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.", + "errFailedPrecondition": "Setup-Problem hat das blockiert.", + "errFailedPreconditionHint": "Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.", + "errInternal": "Unerwarteter interner Fehler.", + "errInternalHint": "Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.", + "errUnavailable": "Hub nicht erreichbar.", + "errUnavailableHint": "Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.", + "errUnauthenticated": "Authentifizierung erforderlich.", + "errUnauthenticatedHint": "`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.", + "errCopyDetail": "Detail kopieren", + "errDetailCopied": "Detail in die Zwischenablage kopiert.", "flowsOutputUnknown": "Unbekannter Output-Payload-Typ.", "flowsOutputBytesUnknownMime": "Binärdaten", "flowsOutputSaveAs": "Speichern unter…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da2c961..b5078ab 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -76,6 +76,25 @@ "buttonReadDocs": "Read docs", "buttonCopy": "Copy to clipboard", "buttonCopied": "Copied", + "buttonShowDetails": "Show details", + "buttonHideDetails": "Hide details", + "storeInstallableOnly": "Installable only", + "storePillComingSoon": "Coming soon", + "storeNotInstallableInline": "This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.", + "@storeNotInstallableInline": { "placeholders": { "status": { "type": "String" } } }, + + "mcpSuggestionDeepwikiDesc": "Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.", + "mcpSuggestionSemgrepDesc": "Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.", + "mcpSuggestionFilesystemDesc": "Anthropic — read / write files in /tmp. Edit the path before saving.", + "mcpSuggestionFetchDesc": "Anthropic — fetch arbitrary HTTP(S) URLs as markdown.", + "mcpSuggestionGithubDesc": "Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.", + "mcpSuggestionPuppeteerDesc": "Anthropic — headless browser automation for screenshots / scraping.", + "mcpSuggestionPostgresDesc": "Anthropic — read-only SQL queries against a Postgres database. Edit the URL.", + "mcpSuggestionSqliteDesc": "Anthropic — query a local SQLite file. Edit the path.", + "mcpSuggestionBraveSearchDesc": "Anthropic — web search via Brave. API key required.", + "mcpSuggestionMemoryDesc": "Anthropic — persistent knowledge graph for an agent's memory.", + "mcpSuggestionTimeDesc": "Anthropic — current time + timezone conversion.", + "mcpServerEnLanguageBadge": "Server text in English — Studio shows it as supplied.", "settingsTitle": "Hub endpoint", "settingsHost": "Host", @@ -513,6 +532,25 @@ "@flowsErrorTitle": { "placeholders": { "name": { "type": "String" } } }, "flowsRunning": "Flow running…", "flowsNoOutputs": "Flow completed with no declared outputs.", + + "errGeneric": "Something went wrong.", + "errInvalidArgument": "Invalid input.", + "errInvalidArgumentHint": "Check the form values and try again.", + "errNotFound": "Not found.", + "errNotFoundHint": "The item you asked for isn't here — it may have been removed or never existed.", + "errAlreadyExists": "Already exists.", + "errPermissionDenied": "Permission denied.", + "errPermissionDeniedHint": "The hub's operator policy blocks this action. Check `~/.fai/config.yaml`.", + "errFailedPrecondition": "Setup issue blocked this.", + "errFailedPreconditionHint": "The hub couldn't proceed because something it needs isn't ready. The detail below explains what.", + "errInternal": "Unexpected internal error.", + "errInternalHint": "Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.", + "errUnavailable": "Hub not reachable.", + "errUnavailableHint": "Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio's settings.", + "errUnauthenticated": "Authentication required.", + "errUnauthenticatedHint": "Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.", + "errCopyDetail": "Copy detail", + "errDetailCopied": "Detail copied to clipboard.", "flowsOutputUnknown": "Output payload variant not recognised.", "flowsOutputBytesUnknownMime": "binary", "flowsOutputSaveAs": "Save as…", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b733d6b..9715977 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -518,6 +518,108 @@ abstract class AppLocalizations { /// **'Copied'** String get buttonCopied; + /// No description provided for @buttonShowDetails. + /// + /// In en, this message translates to: + /// **'Show details'** + String get buttonShowDetails; + + /// No description provided for @buttonHideDetails. + /// + /// In en, this message translates to: + /// **'Hide details'** + String get buttonHideDetails; + + /// No description provided for @storeInstallableOnly. + /// + /// In en, this message translates to: + /// **'Installable only'** + String get storeInstallableOnly; + + /// No description provided for @storePillComingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get storePillComingSoon; + + /// No description provided for @storeNotInstallableInline. + /// + /// In en, this message translates to: + /// **'This module is on the roadmap (status: {status}). It has no installable bundle yet — follow the repository for updates.'** + String storeNotInstallableInline(String status); + + /// No description provided for @mcpSuggestionDeepwikiDesc. + /// + /// In en, this message translates to: + /// **'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.'** + String get mcpSuggestionDeepwikiDesc; + + /// No description provided for @mcpSuggestionSemgrepDesc. + /// + /// In en, this message translates to: + /// **'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.'** + String get mcpSuggestionSemgrepDesc; + + /// No description provided for @mcpSuggestionFilesystemDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — read / write files in /tmp. Edit the path before saving.'** + String get mcpSuggestionFilesystemDesc; + + /// No description provided for @mcpSuggestionFetchDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.'** + String get mcpSuggestionFetchDesc; + + /// No description provided for @mcpSuggestionGithubDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.'** + String get mcpSuggestionGithubDesc; + + /// No description provided for @mcpSuggestionPuppeteerDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — headless browser automation for screenshots / scraping.'** + String get mcpSuggestionPuppeteerDesc; + + /// No description provided for @mcpSuggestionPostgresDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.'** + String get mcpSuggestionPostgresDesc; + + /// No description provided for @mcpSuggestionSqliteDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — query a local SQLite file. Edit the path.'** + String get mcpSuggestionSqliteDesc; + + /// No description provided for @mcpSuggestionBraveSearchDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — web search via Brave. API key required.'** + String get mcpSuggestionBraveSearchDesc; + + /// No description provided for @mcpSuggestionMemoryDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — persistent knowledge graph for an agent\'s memory.'** + String get mcpSuggestionMemoryDesc; + + /// No description provided for @mcpSuggestionTimeDesc. + /// + /// In en, this message translates to: + /// **'Anthropic — current time + timezone conversion.'** + String get mcpSuggestionTimeDesc; + + /// No description provided for @mcpServerEnLanguageBadge. + /// + /// In en, this message translates to: + /// **'Server text in English — Studio shows it as supplied.'** + String get mcpServerEnLanguageBadge; + /// No description provided for @settingsTitle. /// /// In en, this message translates to: @@ -2306,6 +2408,114 @@ abstract class AppLocalizations { /// **'Flow completed with no declared outputs.'** String get flowsNoOutputs; + /// No description provided for @errGeneric. + /// + /// In en, this message translates to: + /// **'Something went wrong.'** + String get errGeneric; + + /// No description provided for @errInvalidArgument. + /// + /// In en, this message translates to: + /// **'Invalid input.'** + String get errInvalidArgument; + + /// No description provided for @errInvalidArgumentHint. + /// + /// In en, this message translates to: + /// **'Check the form values and try again.'** + String get errInvalidArgumentHint; + + /// No description provided for @errNotFound. + /// + /// In en, this message translates to: + /// **'Not found.'** + String get errNotFound; + + /// No description provided for @errNotFoundHint. + /// + /// In en, this message translates to: + /// **'The item you asked for isn\'t here — it may have been removed or never existed.'** + String get errNotFoundHint; + + /// No description provided for @errAlreadyExists. + /// + /// In en, this message translates to: + /// **'Already exists.'** + String get errAlreadyExists; + + /// No description provided for @errPermissionDenied. + /// + /// In en, this message translates to: + /// **'Permission denied.'** + String get errPermissionDenied; + + /// No description provided for @errPermissionDeniedHint. + /// + /// In en, this message translates to: + /// **'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.'** + String get errPermissionDeniedHint; + + /// No description provided for @errFailedPrecondition. + /// + /// In en, this message translates to: + /// **'Setup issue blocked this.'** + String get errFailedPrecondition; + + /// No description provided for @errFailedPreconditionHint. + /// + /// In en, this message translates to: + /// **'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.'** + String get errFailedPreconditionHint; + + /// No description provided for @errInternal. + /// + /// In en, this message translates to: + /// **'Unexpected internal error.'** + String get errInternal; + + /// No description provided for @errInternalHint. + /// + /// In en, this message translates to: + /// **'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.'** + String get errInternalHint; + + /// No description provided for @errUnavailable. + /// + /// In en, this message translates to: + /// **'Hub not reachable.'** + String get errUnavailable; + + /// No description provided for @errUnavailableHint. + /// + /// In en, this message translates to: + /// **'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.'** + String get errUnavailableHint; + + /// No description provided for @errUnauthenticated. + /// + /// In en, this message translates to: + /// **'Authentication required.'** + String get errUnauthenticated; + + /// No description provided for @errUnauthenticatedHint. + /// + /// In en, this message translates to: + /// **'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.'** + String get errUnauthenticatedHint; + + /// No description provided for @errCopyDetail. + /// + /// In en, this message translates to: + /// **'Copy detail'** + String get errCopyDetail; + + /// No description provided for @errDetailCopied. + /// + /// In en, this message translates to: + /// **'Detail copied to clipboard.'** + String get errDetailCopied; + /// No description provided for @flowsOutputUnknown. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1493743..7701ca1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -237,6 +237,71 @@ class AppLocalizationsDe extends AppLocalizations { @override String get buttonCopied => 'Kopiert'; + @override + String get buttonShowDetails => 'Details anzeigen'; + + @override + String get buttonHideDetails => 'Details ausblenden'; + + @override + String get storeInstallableOnly => 'Nur installierbare'; + + @override + String get storePillComingSoon => 'Bald verfügbar'; + + @override + String storeNotInstallableInline(String status) { + return 'Dieses Modul ist auf der Roadmap (Status: $status). Es gibt noch kein installierbares Bundle — Repository verfolgen für Updates.'; + } + + @override + String get mcpSuggestionDeepwikiDesc => + 'Öffentliches HTTPS — KI-gestützte Doku-Suche in GitHub-Repos. Kein Node, kein API-Key.'; + + @override + String get mcpSuggestionSemgrepDesc => + 'Öffentliches HTTPS — Security-Scanning von Code-Schwachstellen. Kein Node, kein API-Key.'; + + @override + String get mcpSuggestionFilesystemDesc => + 'Anthropic — Dateien in /tmp lesen / schreiben. Pfad vor dem Speichern anpassen.'; + + @override + String get mcpSuggestionFetchDesc => + 'Anthropic — beliebige HTTP(S)-URLs als Markdown abrufen.'; + + @override + String get mcpSuggestionGithubDesc => + 'Anthropic — Issues, PRs, Repo-Dateien. Braucht einen GitHub-PAT in der Env-Variable.'; + + @override + String get mcpSuggestionPuppeteerDesc => + 'Anthropic — Headless-Browser-Automation für Screenshots / Scraping.'; + + @override + String get mcpSuggestionPostgresDesc => + 'Anthropic — Read-only-SQL-Queries gegen eine Postgres-Datenbank. URL anpassen.'; + + @override + String get mcpSuggestionSqliteDesc => + 'Anthropic — Abfragen gegen eine lokale SQLite-Datei. Pfad anpassen.'; + + @override + String get mcpSuggestionBraveSearchDesc => + 'Anthropic — Websuche via Brave. API-Key erforderlich.'; + + @override + String get mcpSuggestionMemoryDesc => + 'Anthropic — persistenter Knowledge-Graph als Agenten-Gedächtnis.'; + + @override + String get mcpSuggestionTimeDesc => + 'Anthropic — aktuelle Uhrzeit + Zeitzonen-Konvertierung.'; + + @override + String get mcpServerEnLanguageBadge => + 'Server-Text in Englisch — Studio zeigt ihn wie geliefert.'; + @override String get settingsTitle => 'Hub-Endpunkt'; @@ -1324,6 +1389,67 @@ class AppLocalizationsDe extends AppLocalizations { @override String get flowsNoOutputs => 'Flow abgeschlossen ohne deklarierte Outputs.'; + @override + String get errGeneric => 'Da ist etwas schiefgegangen.'; + + @override + String get errInvalidArgument => 'Ungültige Eingabe.'; + + @override + String get errInvalidArgumentHint => + 'Werte im Formular prüfen und erneut versuchen.'; + + @override + String get errNotFound => 'Nicht gefunden.'; + + @override + String get errNotFoundHint => + 'Das Angefragte ist nicht hier — entweder entfernt oder nie existiert.'; + + @override + String get errAlreadyExists => 'Existiert bereits.'; + + @override + String get errPermissionDenied => 'Berechtigung verweigert.'; + + @override + String get errPermissionDeniedHint => + 'Die Operator-Policy des Hubs blockiert diese Aktion. `~/.fai/config.yaml` prüfen.'; + + @override + String get errFailedPrecondition => 'Setup-Problem hat das blockiert.'; + + @override + String get errFailedPreconditionHint => + 'Der Hub konnte nicht weiter, weil etwas Vorausgesetztes fehlt. Details unten erklären was.'; + + @override + String get errInternal => 'Unerwarteter interner Fehler.'; + + @override + String get errInternalHint => + 'Hub-Logs unter `~/.fai/logs/` prüfen. Wenn das öfter passiert, bitte melden.'; + + @override + String get errUnavailable => 'Hub nicht erreichbar.'; + + @override + String get errUnavailableHint => + 'Sicherstellen, dass `fai serve` (oder `fai daemon start`) läuft und der Daemon-Endpunkt zu Studios Einstellungen passt.'; + + @override + String get errUnauthenticated => 'Authentifizierung erforderlich.'; + + @override + String get errUnauthenticatedHint => + '`FAI_REGISTRY_TOKEN` setzen oder in den Einstellungen anmelden, dann erneut versuchen.'; + + @override + String get errCopyDetail => 'Detail kopieren'; + + @override + String get errDetailCopied => 'Detail in die Zwischenablage kopiert.'; + @override String get flowsOutputUnknown => 'Unbekannter Output-Payload-Typ.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b9523e3..f045bd3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -237,6 +237,71 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonCopied => 'Copied'; + @override + String get buttonShowDetails => 'Show details'; + + @override + String get buttonHideDetails => 'Hide details'; + + @override + String get storeInstallableOnly => 'Installable only'; + + @override + String get storePillComingSoon => 'Coming soon'; + + @override + String storeNotInstallableInline(String status) { + return 'This module is on the roadmap (status: $status). It has no installable bundle yet — follow the repository for updates.'; + } + + @override + String get mcpSuggestionDeepwikiDesc => + 'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.'; + + @override + String get mcpSuggestionSemgrepDesc => + 'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.'; + + @override + String get mcpSuggestionFilesystemDesc => + 'Anthropic — read / write files in /tmp. Edit the path before saving.'; + + @override + String get mcpSuggestionFetchDesc => + 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.'; + + @override + String get mcpSuggestionGithubDesc => + 'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.'; + + @override + String get mcpSuggestionPuppeteerDesc => + 'Anthropic — headless browser automation for screenshots / scraping.'; + + @override + String get mcpSuggestionPostgresDesc => + 'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.'; + + @override + String get mcpSuggestionSqliteDesc => + 'Anthropic — query a local SQLite file. Edit the path.'; + + @override + String get mcpSuggestionBraveSearchDesc => + 'Anthropic — web search via Brave. API key required.'; + + @override + String get mcpSuggestionMemoryDesc => + 'Anthropic — persistent knowledge graph for an agent\'s memory.'; + + @override + String get mcpSuggestionTimeDesc => + 'Anthropic — current time + timezone conversion.'; + + @override + String get mcpServerEnLanguageBadge => + 'Server text in English — Studio shows it as supplied.'; + @override String get settingsTitle => 'Hub endpoint'; @@ -1337,6 +1402,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get flowsNoOutputs => 'Flow completed with no declared outputs.'; + @override + String get errGeneric => 'Something went wrong.'; + + @override + String get errInvalidArgument => 'Invalid input.'; + + @override + String get errInvalidArgumentHint => 'Check the form values and try again.'; + + @override + String get errNotFound => 'Not found.'; + + @override + String get errNotFoundHint => + 'The item you asked for isn\'t here — it may have been removed or never existed.'; + + @override + String get errAlreadyExists => 'Already exists.'; + + @override + String get errPermissionDenied => 'Permission denied.'; + + @override + String get errPermissionDeniedHint => + 'The hub\'s operator policy blocks this action. Check `~/.fai/config.yaml`.'; + + @override + String get errFailedPrecondition => 'Setup issue blocked this.'; + + @override + String get errFailedPreconditionHint => + 'The hub couldn\'t proceed because something it needs isn\'t ready. The detail below explains what.'; + + @override + String get errInternal => 'Unexpected internal error.'; + + @override + String get errInternalHint => + 'Check the hub logs at `~/.fai/logs/`. If this repeats, please report it.'; + + @override + String get errUnavailable => 'Hub not reachable.'; + + @override + String get errUnavailableHint => + 'Make sure `fai serve` (or `fai daemon start`) is running and the daemon endpoint matches Studio\'s settings.'; + + @override + String get errUnauthenticated => 'Authentication required.'; + + @override + String get errUnauthenticatedHint => + 'Set `FAI_REGISTRY_TOKEN` or sign in through Settings before retrying.'; + + @override + String get errCopyDetail => 'Copy detail'; + + @override + String get errDetailCopied => 'Detail copied to clipboard.'; + @override String get flowsOutputUnknown => 'Output payload variant not recognised.'; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 8d4246a..48eb306 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -29,30 +29,27 @@ class _FlowsPageState extends State { _future = _load(); } - /// Pulls the saved flows AND the installed-modules list in - /// parallel so the page can compute "is this flow runnable" - /// per row from the same data the Run button gates on. + /// Pulls the saved flows AND every capability the hub can + /// execute (WASM modules + built-ins like `system.approval` + + /// federated MCP/n8n tools) in parallel so the page can + /// compute "is this flow runnable" per row. + /// + /// Using `allCapabilities()` instead of `listModules()` is + /// load-bearing: built-ins are not WASM modules, so they would + /// never appear in a module-grouped list — and a flow that + /// uses `system.approval@^0` would stay stuck on "missing + /// dependency" forever. Future<_FlowsBundle> _load() async { final results = await Future.wait([ HubService.instance.listFlows(), - HubService.instance.listModules().catchError((_) => []), + HubService.instance + .allCapabilities() + .catchError((_) => []), ]); return _FlowsBundle( flows: results[0] as List, - installedCapabilities: (results[1] as List) - .expand((m) => m.capabilities) - // `listModules()` emits capability strings with a - // `@version` suffix (e.g. `text.extract@0.1.0`). The - // flow's `requiredCapabilities` carry their own - // user-typed version constraint, so we compare on the - // bare capability name and let the hub do the strict - // version match at run time. Without this strip, the - // contains-check would never hit and the Run button - // would stay disabled even after a successful install. - .map((c) { - final at = c.indexOf('@'); - return at >= 0 ? c.substring(0, at) : c; - }) + installedCapabilities: (results[1] as List) + .map((c) => c.capability) .toSet(), ); } @@ -638,7 +635,7 @@ class _FlowInputDialogState extends State<_FlowInputDialog> { ), const SizedBox(height: FaiSpace.sm), FaiErrorBox( - text: snap.error.toString(), + error: snap.error, isError: true, maxHeight: 200, ), @@ -887,7 +884,7 @@ class _FlowRunDialogState extends State<_FlowRunDialog> { ), const SizedBox(height: FaiSpace.md), FaiErrorBox( - text: snapshot.error.toString(), + error: snapshot.error, isError: true, maxHeight: 280, ), @@ -957,7 +954,10 @@ class _InstallItemState { _InstallStatus status; String? installedVersion; - String? error; + /// Original thrown object so the UI can run it through + /// [friendlyError] later instead of staring at a wall of gRPC + /// trailers. + Object? error; _InstallItemState(this.spec) : bareName = _stripVersion(spec), @@ -1015,7 +1015,7 @@ class _InstallDependenciesDialogState if (!mounted) return; setState(() { item.status = _InstallStatus.failed; - item.error = e.toString(); + item.error = e; }); } } @@ -1042,8 +1042,9 @@ class _InstallDependenciesDialogState i.status == _InstallStatus.failed && _isAuthWallError(i.error), ); - static bool _isAuthWallError(String? msg) { - if (msg == null) return false; + static bool _isAuthWallError(Object? err) { + if (err == null) return false; + final msg = err.toString(); return msg.contains('registry returned an HTML page') || msg.contains('no registry token configured'); } @@ -1163,7 +1164,7 @@ class _InstallRow extends StatelessWidget { item.error != null) ...[ const SizedBox(height: FaiSpace.xs), FaiErrorBox( - text: item.error!, + error: item.error, isError: true, maxHeight: 120, ), diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 59b4868..dae419e 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -36,6 +36,13 @@ class _StorePageState extends State { /// has no source field. String _source = ''; bool _installedOnly = false; + /// "Show installable modules only". On by default: roughly + /// 2/3 of the seed-index entries currently carry status + /// `planned` (no bundle to install), and pre-filter polish + /// found that operators couldn't tell why they couldn't + /// install half the cards. Planned modules are one chip-flip + /// away in the filter dialog. + bool _installableOnly = true; /// Tracks per-recommended-source add buttons to disable them /// while the request is in flight. final Set _addingSources = {}; @@ -136,7 +143,19 @@ class _StorePageState extends State { final all = results[0] as List; final mods = results[1] as List; _installedVersions = {for (final m in mods) m.name: m.version}; - return _installedOnly ? all.where((e) => e.installed).toList() : all; + var out = all; + if (_installedOnly) { + out = out.where((e) => e.installed).toList(); + } + if (_installableOnly) { + // An already-installed item counts as "installable" for + // this filter — the user can still update / uninstall it. + out = out + .where((e) => + e.installed || e.status == 'published' || e.status == 'alpha') + .toList(); + } + return out; } void _runSearch() { @@ -261,6 +280,7 @@ class _StorePageState extends State { _status = ''; _source = ''; _installedOnly = false; + _installableOnly = false; _queryCtrl.clear(); }); _runSearch(); @@ -440,6 +460,10 @@ class _StorePageState extends State { if (_status.isNotEmpty) n++; if (_source.isNotEmpty) n++; if (_installedOnly) n++; + // `installableOnly` defaults to ON, so we only highlight it + // as an "active filter" when the operator turned it OFF — + // the surprising state is the one worth flagging. + if (!_installableOnly) n++; return n; } @@ -557,6 +581,7 @@ class _StorePageState extends State { status: _status, source: _source, installedOnly: _installedOnly, + installableOnly: _installableOnly, ), ); if (outcome == null) return; @@ -564,6 +589,7 @@ class _StorePageState extends State { _status = outcome.status; _source = outcome.source; _installedOnly = outcome.installedOnly; + _installableOnly = outcome.installableOnly; }); _runSearch(); } @@ -892,10 +918,12 @@ class _FilterOutcome { final String status; final String source; final bool installedOnly; + final bool installableOnly; const _FilterOutcome({ required this.status, required this.source, required this.installedOnly, + required this.installableOnly, }); } @@ -908,11 +936,13 @@ class _FilterDialog extends StatefulWidget { final String status; final String source; final bool installedOnly; + final bool installableOnly; const _FilterDialog({ required this.status, required this.source, required this.installedOnly, + required this.installableOnly, }); @override @@ -923,6 +953,7 @@ class _FilterDialogState extends State<_FilterDialog> { late String _status = widget.status; late String _source = widget.source; late bool _installedOnly = widget.installedOnly; + late bool _installableOnly = widget.installableOnly; @override Widget build(BuildContext context) { @@ -989,11 +1020,23 @@ class _FilterDialogState extends State<_FilterDialog> { ), const SizedBox(height: FaiSpace.lg), header(l.storeFilterInstalledGroup), - FilterChip( - label: Text(l.storeInstalledOnly), - selected: _installedOnly, - onSelected: (v) => setState(() => _installedOnly = v), - showCheckmark: true, + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + FilterChip( + label: Text(l.storeInstallableOnly), + selected: _installableOnly, + onSelected: (v) => setState(() => _installableOnly = v), + showCheckmark: true, + ), + FilterChip( + label: Text(l.storeInstalledOnly), + selected: _installedOnly, + onSelected: (v) => setState(() => _installedOnly = v), + showCheckmark: true, + ), + ], ), ], ), @@ -1005,6 +1048,11 @@ class _FilterDialogState extends State<_FilterDialog> { _status = ''; _source = ''; _installedOnly = false; + // Reset puts `installableOnly` back to its default + // (on) — the operator hits Reset to escape a + // custom-filter state and expects the default view + // back, not an everything-including-planned view. + _installableOnly = true; }); }, child: Text(l.storeFilterReset), @@ -1021,6 +1069,7 @@ class _FilterDialogState extends State<_FilterDialog> { status: _status, source: _source, installedOnly: _installedOnly, + installableOnly: _installableOnly, ), ), child: Text(l.storeFilterApply), @@ -1566,11 +1615,29 @@ class _FeaturedTile extends StatelessWidget { ), const SizedBox(height: FaiSpace.md), Expanded( - child: Text( - tagline.isEmpty ? l.storeNoTagline : tagline, - style: theme.textTheme.bodyLarge, - maxLines: 3, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.isFederated) + // Federated items get tagline copy straight + // from the upstream MCP / n8n server, which + // is English regardless of Studio's locale. + // The [EN] pill is the honest signal until + // the planned `studio.translate` plugin + // rewrites it on the fly. + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: FaiEnBadge.forContext(context), + ), + Expanded( + child: Text( + tagline.isEmpty ? l.storeNoTagline : tagline, + style: theme.textTheme.bodyLarge, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), const SizedBox(height: FaiSpace.sm), @@ -1600,6 +1667,16 @@ class _FeaturedTile extends StatelessWidget { onPressed: onInstall, icon: const Icon(Icons.download, size: 16), label: Text(l.buttonInstall), + ) + else + // Status is `planned` (or another non-installable + // wire value). Without an explicit pill here the + // featured tile would just go silent, leaving the + // operator wondering why the card has no action. + FaiPill( + label: l.storePillComingSoon, + tone: FaiPillTone.neutral, + icon: Icons.schedule, ), ], ), @@ -1647,7 +1724,14 @@ class _StoreCard extends StatelessWidget { ? item.taglineDe : item.taglineEn; final installable = item.status == 'published' || item.status == 'alpha'; - return Material( + // Dim planned/non-installable cards so the eye lands on + // actionable cards first. We don't disable interaction — the + // detail sheet still opens — but the visual hierarchy is + // honest about what the operator can act on. + final notInstallable = !installable && !item.installed; + return Opacity( + opacity: notInstallable ? 0.6 : 1.0, + child: Material( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.md), child: InkWell( @@ -1782,6 +1866,7 @@ class _StoreCard extends StatelessWidget { ), ), ), + ), ); } } @@ -2081,6 +2166,15 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { const SizedBox(height: FaiSpace.lg), ], if (description.isNotEmpty) ...[ + if (item.isFederated) ...[ + // Description copy was supplied by the + // upstream server, not by Studio's + // localisation pipeline — flag it. + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: FaiEnBadge.forContext(context), + ), + ], SelectableText( description, style: theme.textTheme.bodyMedium, @@ -2294,14 +2388,46 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { _busy ? l.storeInstallingButton : l.buttonInstall, ), ) - else - Tooltip( - message: l.storeNotInstallableTooltip(item.status), - child: FilledButton( - onPressed: null, - child: Text(l.storeNotInstallable), + else ...[ + // Inline hint replaces the previous + // tooltip-only affordance: the operator + // sees the reason without having to hover. + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: Row( + children: [ + Icon( + Icons.schedule, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + l.storeNotInstallableInline(item.status), + style: + theme.textTheme.bodySmall?.copyWith( + color: + theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), ), ), + ], ], ), ], @@ -2395,12 +2521,10 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { size: 32, ), const SizedBox(height: FaiSpace.md), - SelectableText( - snap.error.toString(), - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.error, - ), + FaiErrorBox( + error: snap.error, + isError: true, + maxHeight: 200, ), ], ), diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index ce8a7a1..94c34f5 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -1041,7 +1041,7 @@ class _DocReaderSheetState extends State<_DocReaderSheet> { ), const SizedBox(height: FaiSpace.sm), FaiErrorBox( - text: snap.error.toString(), + error: snap.error, isError: true, maxHeight: 200, ), diff --git a/lib/widgets/fai_en_badge.dart b/lib/widgets/fai_en_badge.dart new file mode 100644 index 0000000..42b837d --- /dev/null +++ b/lib/widgets/fai_en_badge.dart @@ -0,0 +1,61 @@ +// FaiEnBadge — small "[EN]" pill shown next to text that came +// from outside Studio's localization pipeline (MCP server tool +// names, n8n endpoint descriptions, native LLM responses). +// +// The honest move: when the active locale isn't English and we +// show a piece of text that we know is English, mark it so the +// operator doesn't blame Studio for a half-translated page. +// `studio.translate` plugin will eventually rewrite these in +// place — until then, the badge is the right amount of +// transparency. + +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; + +class FaiEnBadge extends StatelessWidget { + /// When true, the badge renders. When false (e.g. the active + /// locale already is English), it returns + /// `SizedBox.shrink()` so callers can drop it inline without + /// guarding visibility themselves. + final bool visible; + + const FaiEnBadge({super.key, required this.visible}); + + /// Convenience constructor: derives `visible` from the active + /// Localizations locale. Use this from inside a Build method + /// where you already have a BuildContext. + factory FaiEnBadge.forContext(BuildContext context) { + final lang = Localizations.localeOf(context).languageCode; + return FaiEnBadge(visible: lang != 'en'); + } + + @override + Widget build(BuildContext context) { + if (!visible) return const SizedBox.shrink(); + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Tooltip( + message: l.mcpServerEnLanguageBadge, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: Text( + 'EN', + style: theme.textTheme.labelSmall?.copyWith( + fontSize: 9, + letterSpacing: 0.4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/fai_error_box.dart b/lib/widgets/fai_error_box.dart index 1212702..c413ce0 100644 --- a/lib/widgets/fai_error_box.dart +++ b/lib/widgets/fai_error_box.dart @@ -10,13 +10,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../data/friendly_error.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; class FaiErrorBox extends StatefulWidget { /// The text the operator wants to read (and copy). Rendered - /// monospace, selectable, multi-line. + /// monospace, selectable, multi-line. Ignored when [error] is + /// supplied. final String text; /// When true, the box border + foreground colour come from the /// error palette. False renders neutral chrome — useful for @@ -27,13 +29,25 @@ class FaiErrorBox extends StatefulWidget { /// box. Falls back to no constraint when null so short /// messages don't get a useless scrollbar. final double? maxHeight; + /// When non-null, the box renders the [friendlyError] mapping + /// of this error: a one-line headline, a recovery-hint line + /// (when one applies), and the verbatim original message + /// collapsed behind a "Details" expander. Use this instead of + /// [text] anywhere we'd otherwise show a raw + /// `e.toString()` — gRPC-Errors come out as walls of code + + /// trailers otherwise. + final Object? error; const FaiErrorBox({ super.key, - required this.text, + this.text = '', this.isError = false, this.maxHeight, - }); + this.error, + }) : assert( + text != '' || error != null, + 'FaiErrorBox needs either text or error', + ); @override State createState() => _FaiErrorBoxState(); @@ -41,9 +55,10 @@ class FaiErrorBox extends StatefulWidget { class _FaiErrorBoxState extends State { bool _justCopied = false; + bool _detailExpanded = false; - Future _copy() async { - await Clipboard.setData(ClipboardData(text: widget.text)); + Future _copy(String what) async { + await Clipboard.setData(ClipboardData(text: what)); if (!mounted) return; setState(() => _justCopied = true); Future.delayed(const Duration(seconds: 2), () { @@ -61,6 +76,10 @@ class _FaiErrorBoxState extends State { final fg = widget.isError ? theme.colorScheme.error : theme.colorScheme.onSurface; + final friendly = widget.error == null + ? null + : friendlyError(widget.error!, l); + final copyText = friendly?.detail ?? widget.text; return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB( @@ -75,37 +94,101 @@ class _FaiErrorBoxState extends State { border: Border.all(color: accent.withValues(alpha: 0.4)), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Copy button sits above the text on its own row so - // long messages never collide with it. Compact enough - // not to dominate short single-line messages. - Tooltip( - message: _justCopied ? l.buttonCopied : l.buttonCopy, - child: IconButton( - icon: Icon( - _justCopied ? Icons.check : Icons.content_copy, - size: 14, + Align( + alignment: Alignment.centerRight, + child: Tooltip( + message: _justCopied ? l.buttonCopied : l.buttonCopy, + child: IconButton( + icon: Icon( + _justCopied ? Icons.check : Icons.content_copy, + size: 14, + ), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + onPressed: () => _copy(copyText), ), - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), - onPressed: _copy, ), ), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: widget.maxHeight ?? double.infinity, + if (friendly != null) ...[ + SelectableText( + friendly.headline, + style: theme.textTheme.bodyMedium?.copyWith( + color: fg, + fontWeight: FontWeight.w600, + ), ), - child: Scrollbar( - child: SingleChildScrollView( - child: SelectableText( - widget.text, - style: FaiTheme.mono(size: 11, color: fg), + if (friendly.hint != null) ...[ + const SizedBox(height: 4), + Text( + friendly.hint!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + if (friendly.detail.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.sm), + InkWell( + onTap: () => setState(() { + _detailExpanded = !_detailExpanded; + }), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _detailExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + _detailExpanded ? l.buttonHideDetails : l.buttonShowDetails, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (_detailExpanded) ...[ + const SizedBox(height: FaiSpace.xs), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: widget.maxHeight ?? double.infinity, + ), + child: Scrollbar( + child: SingleChildScrollView( + child: SelectableText( + friendly.detail, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ], + ], + ] else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: widget.maxHeight ?? double.infinity, + ), + child: Scrollbar( + child: SingleChildScrollView( + child: SelectableText( + widget.text, + style: FaiTheme.mono(size: 11, color: fg), + ), ), ), ), - ), ], ), ); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 439453b..9d007d6 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -1023,11 +1023,12 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> { /// Pick a suggestion → fill the form. Operators still hit /// "Add + discover" so nothing happens unprompted. void _applySuggestion(_McpSuggestion s) { + final l = AppLocalizations.of(context)!; setState(() { _name.text = s.name; _endpoint.text = s.endpoint; _apiKey.text = s.apiKeyEnv; - _desc.text = s.description; + _desc.text = s.resolveDescription(l); }); } @@ -1770,36 +1771,73 @@ class _McpSuggestion { final IconData icon; final String endpoint; final String apiKeyEnv; - final String description; + /// Localizable description. Use [resolveDescription] to fetch + /// the actual text in the active locale — the const list + /// can't hold a closure that takes [AppLocalizations], and + /// hardcoding English here is what got us into the + /// untranslated-helper-text bug. + String resolveDescription(AppLocalizations l) => + _suggestionDescription(l, name); const _McpSuggestion({ required this.name, required this.icon, required this.endpoint, required this.apiKeyEnv, - required this.description, }); } +/// Localizable lookup of an MCP-suggestion description. Keyed +/// on the same suggestion `name` field as the list below. New +/// suggestions need a matching .arb entry; the default-case +/// returns an empty string so an unconfigured suggestion fails +/// silently instead of leaking a key into the UI. +String _suggestionDescription(AppLocalizations l, String name) { + switch (name) { + case 'deepwiki': + return l.mcpSuggestionDeepwikiDesc; + case 'semgrep': + return l.mcpSuggestionSemgrepDesc; + case 'filesystem': + return l.mcpSuggestionFilesystemDesc; + case 'fetch': + return l.mcpSuggestionFetchDesc; + case 'github': + return l.mcpSuggestionGithubDesc; + case 'puppeteer': + return l.mcpSuggestionPuppeteerDesc; + case 'postgres': + return l.mcpSuggestionPostgresDesc; + case 'sqlite': + return l.mcpSuggestionSqliteDesc; + case 'brave-search': + return l.mcpSuggestionBraveSearchDesc; + case 'memory': + return l.mcpSuggestionMemoryDesc; + case 'time': + return l.mcpSuggestionTimeDesc; + default: + return ''; + } +} + const _kMcpSuggestions = <_McpSuggestion>[ // ── HTTPS / streamable-HTTP servers (no Node, no API key) ── - // Same set Studio promotes as one-click cards in the Today - // hero — listed here so the operator finds them even after - // the hero gets dismissed. + // Descriptions are resolved via [_McpSuggestion.resolveDescription] + // against the active locale — see `mcpSuggestion*Desc` keys in + // app_en.arb / app_de.arb. Adding a suggestion here means + // adding a matching key in both .arb files plus a case to + // `_suggestionDescription` above. _McpSuggestion( name: 'deepwiki', icon: Icons.menu_book_outlined, endpoint: 'https://mcp.deepwiki.com/mcp', apiKeyEnv: '', - description: - 'Public HTTPS — GitHub repo documentation search (AI-powered). No Node, no API key.', ), _McpSuggestion( name: 'semgrep', icon: Icons.security, endpoint: 'https://mcp.semgrep.ai/mcp', apiKeyEnv: '', - description: - 'Public HTTPS — security scanning for code vulnerabilities. No Node, no API key.', ), // ── stdio servers (require Node + npx) ─────────────────── _McpSuggestion( @@ -1807,30 +1845,24 @@ const _kMcpSuggestions = <_McpSuggestion>[ icon: Icons.folder_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp', apiKeyEnv: '', - description: - 'Anthropic — read/write files in /tmp. Edit the path before saving.', ), _McpSuggestion( name: 'fetch', icon: Icons.cloud_download_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch', apiKeyEnv: '', - description: 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.', ), _McpSuggestion( name: 'github', icon: Icons.code, endpoint: 'stdio://npx -y @modelcontextprotocol/server-github', apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN', - description: - 'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.', ), _McpSuggestion( name: 'puppeteer', icon: Icons.web, endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer', apiKeyEnv: '', - description: 'Anthropic — headless browser automation for screenshots / scraping.', ), _McpSuggestion( name: 'postgres', @@ -1838,8 +1870,6 @@ const _kMcpSuggestions = <_McpSuggestion>[ endpoint: 'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db', apiKeyEnv: '', - description: - 'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.', ), _McpSuggestion( name: 'sqlite', @@ -1847,27 +1877,23 @@ const _kMcpSuggestions = <_McpSuggestion>[ endpoint: 'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite', apiKeyEnv: '', - description: 'Anthropic — query a local SQLite file. Edit the path.', ), _McpSuggestion( name: 'brave-search', icon: Icons.search, endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search', apiKeyEnv: 'BRAVE_API_KEY', - description: 'Anthropic — web search via Brave. API key required.', ), _McpSuggestion( name: 'memory', icon: Icons.psychology_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory', apiKeyEnv: '', - description: 'Anthropic — persistent knowledge graph for an agent\'s memory.', ), _McpSuggestion( name: 'time', icon: Icons.schedule, endpoint: 'stdio://npx -y @modelcontextprotocol/server-time', apiKeyEnv: '', - description: 'Anthropic — current time + timezone conversion.', ), ]; diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 22dc088..ac2402a 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -8,6 +8,7 @@ export 'fai_card.dart'; export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; export 'fai_empty_state.dart'; +export 'fai_en_badge.dart'; export 'fai_error_box.dart'; export 'fai_flow_output.dart'; export 'fai_module_sheet.dart'; diff --git a/test/friendly_error_test.dart b/test/friendly_error_test.dart new file mode 100644 index 0000000..18364ad --- /dev/null +++ b/test/friendly_error_test.dart @@ -0,0 +1,84 @@ +// Unit tests for the `friendlyError` mapper. These check the +// duck-typed shape contract — `GrpcError` from package:grpc is +// expected to expose `.code` (int), `.message` (String?), and +// `.codeName` (String) — without taking grpc as a Studio +// dependency. The fakes here mimic that shape. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fai_studio/data/friendly_error.dart'; +import 'package:fai_studio/l10n/app_localizations.dart'; + +class _FakeGrpcError { + final int code; + final String? message; + final String codeName; + _FakeGrpcError(this.code, this.codeName, [this.message]); +} + +Future _loadL10n(Locale locale) async { + return await AppLocalizations.delegate.load(locale); +} + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + // Force loading the gen-l10n delegate before tests so each + // call to `_loadL10n` is fast. + await _loadL10n(const Locale('en')); + }); + + test('UNAVAILABLE maps to hub-not-reachable copy', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), l); + expect(r.headline, l.errUnavailable); + expect(r.hint, l.errUnavailableHint); + expect(r.detail, 'connection refused'); + }); + + test('FAILED_PRECONDITION carries module detail in body', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError( + _FakeGrpcError(9, 'FAILED_PRECONDITION', + 'install error: no store entry for system.approval'), + l, + ); + expect(r.headline, l.errFailedPrecondition); + expect(r.hint, l.errFailedPreconditionHint); + expect(r.detail, contains('system.approval')); + }); + + test('NOT_FOUND has a recovery hint', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(_FakeGrpcError(5, 'NOT_FOUND', 'no such module'), l); + expect(r.headline, l.errNotFound); + expect(r.hint, isNotNull); + }); + + test('Unknown code falls back to codeName: message', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(_FakeGrpcError(99, 'CUSTOM_CODE', 'something'), l); + expect(r.headline, contains('CUSTOM_CODE')); + expect(r.headline, contains('something')); + expect(r.hint, isNull); + }); + + test('Non-gRPC error renders toString as headline', () async { + final l = await _loadL10n(const Locale('en')); + final r = friendlyError(FormatException('bad url'), l); + expect(r.headline, contains('bad url')); + }); + + test('Locale switches the headline language', () async { + final lDe = await _loadL10n(const Locale('de')); + final r = + friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe); + expect(r.headline, lDe.errUnavailable); + // Sanity: the DE headline really is different from the EN + // one — guards against the test silently passing if l10n + // regen returned EN for both. + final lEn = await _loadL10n(const Locale('en')); + expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable))); + }); +} +