feat(studio): trust pass — friendly errors + store clarity + MCP i18n
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 <sf@flemming.it> Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
34f2b7b313
commit
c11461b0f9
15 changed files with 1219 additions and 104 deletions
|
|
@ -29,30 +29,27 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
_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((_) => <ModuleSummary>[]),
|
||||
HubService.instance
|
||||
.allCapabilities()
|
||||
.catchError((_) => <CapabilityInfo>[]),
|
||||
]);
|
||||
return _FlowsBundle(
|
||||
flows: results[0] as List<SavedFlow>,
|
||||
installedCapabilities: (results[1] as List<ModuleSummary>)
|
||||
.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<CapabilityInfo>)
|
||||
.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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@ class _StorePageState extends State<StorePage> {
|
|||
/// 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<String> _addingSources = <String>{};
|
||||
|
|
@ -136,7 +143,19 @@ class _StorePageState extends State<StorePage> {
|
|||
final all = results[0] as List<StoreItem>;
|
||||
final mods = results[1] as List<ModuleSummary>;
|
||||
_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<StorePage> {
|
|||
_status = '';
|
||||
_source = '';
|
||||
_installedOnly = false;
|
||||
_installableOnly = false;
|
||||
_queryCtrl.clear();
|
||||
});
|
||||
_runSearch();
|
||||
|
|
@ -440,6 +460,10 @@ class _StorePageState extends State<StorePage> {
|
|||
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<StorePage> {
|
|||
status: _status,
|
||||
source: _source,
|
||||
installedOnly: _installedOnly,
|
||||
installableOnly: _installableOnly,
|
||||
),
|
||||
);
|
||||
if (outcome == null) return;
|
||||
|
|
@ -564,6 +589,7 @@ class _StorePageState extends State<StorePage> {
|
|||
_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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue