feat: honest install badge — classify store resolvability before the click

Split each flow's missing capabilities into store-resolvable ones
(amber chip + Install link, which now installs only those) and a
quiet 'not in store' chip whose tooltip explains the three recovery
paths in place: local install via chain install --link, adding the
providing store, or configuring the MCP/n8n integration. The
analyzer's not-in-store message names the same three paths (EN+DE).
Previously the list's Install action covered every missing
capability and could end in the hub's 'no store entry' error.

Also replace a private-looking capability example name in a doc
comment and test with a neutral placeholder. (0.25.0)

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-22 13:42:11 +02:00
parent 7d6a575cae
commit ab97e5e834
8 changed files with 340 additions and 105 deletions

View file

@ -1,5 +1,26 @@
# Changelog
## 0.25.0
Honest install badge — the flow list and analyzer only offer
"Install" for capabilities a configured store can actually resolve:
- **Store-resolvability split in the flow list.** Missing
capabilities are classified BEFORE any click: store-resolvable
ones keep the amber chip + Install link (which now installs only
those), the rest render a quiet "not in store" chip whose tooltip
explains the three recovery paths (local install via
`chain install --link`, adding the providing store, configuring
the MCP/n8n integration). Previously the Install action covered
every missing capability and could end in the hub's
"no store entry for '<name>'" error.
- New host contract: `_FileList` consumes the host's
`storeCapabilities` (already exposed on `FlowEditorPage`) for the
split; hosts should pass only capabilities the store can install
(installable status, native kind).
- **Analyzer not-in-store message** now names all three recovery
paths instead of only the local-install hint (EN + DE).
## 0.24.1
- No ALL-CAPS "FLOWS" panel header: the page toolbar already names

View file

@ -41,6 +41,7 @@ import 'tokens.dart';
import 'widgets.dart';
import 'widgets/capability_picker.dart';
import 'widgets/flow_canvas.dart';
import 'widgets/missing_modules_badge.dart';
import 'widgets/properties_panel.dart';
import 'widgets/run_tab.dart';
@ -591,6 +592,7 @@ outputs:
installedNames: _installedNames(
widget.availableCapabilities,
),
storeNames: _installedNames(widget.storeCapabilities),
onOpen: _openFile,
onRefresh: _refreshFiles,
onStart: _startFile,
@ -1347,6 +1349,12 @@ class _FileList extends StatefulWidget {
/// Bare capability NAMES the host reports as installed. Used
/// to compute each row's missing-module count.
final Set<String> installedNames;
/// Bare capability NAMES a configured store can install
/// (host-filtered to installable entries). Missing caps
/// outside this set render the "not in store" state instead
/// of an install action that the hub would refuse.
final Set<String> storeNames;
final void Function(_FlowFile) onOpen;
final VoidCallback onRefresh;
@ -1365,6 +1373,7 @@ class _FileList extends StatefulWidget {
required this.activeName,
required this.strings,
required this.installedNames,
required this.storeNames,
required this.onOpen,
required this.onRefresh,
required this.onStart,
@ -1502,6 +1511,7 @@ class _FileListState extends State<_FileList> {
final f = files[i];
final isActive = f.name == widget.activeName;
final missing = f.meta.missingCaps(widget.installedNames);
final split = splitMissingCaps(missing, widget.storeNames);
return InkWell(
onTap: () => widget.onOpen(f),
child: Container(
@ -1579,13 +1589,20 @@ class _FileListState extends State<_FileList> {
if (f.meta.isExample)
_ExampleBadge(strings: strings),
if (missing.isNotEmpty)
_MissingModulesBadge(
missing: missing,
MissingModulesBadge(
installable: split.installable,
notInStore: split.notInStore,
strings: strings,
onInstall: widget.onInstallMissing == null
// Install only what the store
// resolves the not-in-store
// chip explains the rest.
onInstall:
widget.onInstallMissing == null ||
split.installable.isEmpty
? null
: () =>
widget.onInstallMissing!(missing),
: () => widget.onInstallMissing!(
split.installable,
),
),
],
),
@ -1665,95 +1682,6 @@ class _ExampleBadge extends StatelessWidget {
}
}
/// "N modules missing" status on flows whose steps reference
/// capabilities the hub doesn't provide. Status and action are
/// SEPARATE elements (usertest: the combined orange chip made
/// the whole list read like an error wall and ellipsized the
/// action word):
/// - status = quiet neutral chip with a small amber dot a
/// note, not an alarm; warning orange stays reserved for real
/// failures.
/// - action = its own "Install" link that is never truncated;
/// both live in the row's Wrap, so tight widths wrap to a
/// second line instead of cutting text mid-word.
class _MissingModulesBadge extends StatelessWidget {
final List<String> missing;
final FlowEditorStrings strings;
final VoidCallback? onInstall;
const _MissingModulesBadge({
required this.missing,
required this.strings,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const dot = Color(0xFFEF6C00);
final fg = theme.colorScheme.onSurfaceVariant;
final chip = Tooltip(
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
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.withValues(alpha: 0.6),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: dot,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
strings.flowListNeedsModules(missing.length),
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
],
),
),
);
if (onInstall == null) return chip;
return Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
chip,
InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Text(
strings.flowListInstallMissing,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
),
],
);
}
}
// --- empty state ---
class _EmptyState extends StatelessWidget {

View file

@ -259,11 +259,14 @@ class FlowEditorStrings {
);
String analyzerUnknownCapNotInStore(String cap) => _t(
'Unknown capability "$cap". '
'Not in the store — install locally with '
'`chain install --link <path>` or check the spelling.',
'No configured store can install it — install a local module '
'(`chain install --link <path>`), add the store that provides '
'it, or configure the integration (MCP/n8n) that supplies it.',
'Unbekannte Capability "$cap". '
'Nicht im Store — lokal mit '
'`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
'Kein eingerichteter Store kann sie installieren — lokales '
'Modul installieren (`chain install --link <pfad>`), den '
'passenden Store hinzufügen oder die Anbindung (MCP/n8n) '
'einrichten, die sie bereitstellt.',
);
String analyzerInputKind() => _t('input', 'Eingabe');
String analyzerOutputKind() => _t('output', 'Ausgabe');
@ -292,6 +295,24 @@ class FlowEditorStrings {
'Klicken, um die fehlenden zu installieren.',
);
String get flowListInstallMissing => _t('Install', 'Installieren');
String flowListNotInStore(int n) => _t(
n == 1 ? 'not in store' : '$n not in store',
n == 1 ? 'nicht im Store' : '$n nicht im Store',
);
String flowListNotInStoreTooltip(String caps) => _t(
'This flow needs capabilities no configured store can install:\n'
'$caps\n'
'Ways to get them: install a local module '
'(`chain install --link <path>`), add the store that provides '
'them (Settings → Stores), or configure the integration '
'(MCP/n8n) that supplies the capability.',
'Dieser Flow braucht Capabilities, die kein eingerichteter Store '
'installieren kann:\n$caps\n'
'Wege: lokales Modul installieren '
'(`chain install --link <pfad>`), den passenden Store '
'hinzufügen (Einstellungen → Stores) oder die Anbindung '
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
);
// Flow-list filter (first iteration: plain substring match).
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
@ -387,8 +408,9 @@ class AnalyzerStrings {
'Unknown capability "$cap". Did you mean "$suggestion"?';
static String _enUnknownCapNotInStore(String cap) =>
'Unknown capability "$cap". '
'Not in the store — install locally with '
'`chain install --link <path>` or check the spelling.';
'No configured store can install it — install a local module '
'(`chain install --link <path>`), add the store that provides '
'it, or configure the integration (MCP/n8n) that supplies it.';
static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input';

View file

@ -73,8 +73,8 @@ class InstallCapabilityFix extends QuickFix {
/// (`chain install <url>`), then installs and reanalyzes.
///
/// This is the recovery path for private modules: the public
/// store doesn't know about `htw.digiscout/onet-lookup`, but
/// the operator can point the hub at the local clone.
/// store doesn't know about `acme.internal/directory-lookup`,
/// but the operator can point the hub at the local clone.
@immutable
class AddModuleSourceFix extends QuickFix {
/// The capability the operator wrote the host uses it to

View file

@ -0,0 +1,154 @@
// Flow-list badge for flows whose steps reference capabilities
// the hub doesn't provide. Split out of flow_editor_page.dart so
// the resolvability states (installable from a store vs. not in
// any store) stay unit-testable.
import 'package:flutter/material.dart';
import '../l10n.dart';
import '../tokens.dart';
/// Split a flow's missing capabilities into the ones a configured
/// store can actually install and the ones no store resolves.
///
/// [storeNames] is the host-supplied set of bare capability names
/// the store can install (already filtered to installable entries
/// published/alpha, native). An empty set therefore means
/// "nothing is store-installable", not "unknown": the honest
/// state without store data is the not-in-store explanation, and
/// the local-install / add-store / configure-integration paths
/// remain available.
({List<String> installable, List<String> notInStore}) splitMissingCaps(
List<String> missing,
Set<String> storeNames,
) {
final installable = <String>[];
final notInStore = <String>[];
for (final cap in missing) {
final bare = cap.split('@').first;
(storeNames.contains(bare) ? installable : notInStore).add(cap);
}
return (installable: installable, notInStore: notInStore);
}
/// "N modules missing" status on flows whose steps reference
/// capabilities the hub doesn't provide. Status and action are
/// SEPARATE elements (usertest: the combined orange chip made
/// the whole list read like an error wall and ellipsized the
/// action word):
/// - status = quiet neutral chip with a small amber dot a
/// note, not an alarm; warning orange stays reserved for real
/// failures.
/// - action = its own "Install" link that is never truncated;
/// both live in the row's Wrap, so tight widths wrap to a
/// second line instead of cutting text mid-word.
///
/// The install action only covers [installable] capabilities a
/// configured store resolves. [notInStore] capabilities get their
/// own quiet chip whose tooltip explains the three recovery paths
/// (local install / add store / configure integration) BEFORE any
/// click, instead of an install button that would end in the
/// hub's "no store entry" error.
class MissingModulesBadge extends StatelessWidget {
final List<String> installable;
final List<String> notInStore;
final FlowEditorStrings strings;
final VoidCallback? onInstall;
const MissingModulesBadge({
super.key,
required this.installable,
required this.notInStore,
required this.strings,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final children = <Widget>[
if (installable.isNotEmpty)
_chip(
theme,
dot: const Color(0xFFEF6C00),
label: strings.flowListNeedsModules(installable.length),
tooltip: strings.flowListNeedsModulesTooltip(
installable.join('\n'),
),
),
if (installable.isNotEmpty && onInstall != null)
InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Text(
strings.flowListInstallMissing,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
),
if (notInStore.isNotEmpty)
_chip(
theme,
// Muted dot: this state has no one-click fix, so it
// must not borrow the actionable chip's amber.
dot: theme.colorScheme.outline,
label: strings.flowListNotInStore(notInStore.length),
tooltip: strings.flowListNotInStoreTooltip(notInStore.join('\n')),
),
];
if (children.length == 1) return children.single;
return Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: children,
);
}
Widget _chip(
ThemeData theme, {
required Color dot,
required String label,
required String tooltip,
}) {
final fg = theme.colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
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.withValues(alpha: 0.6),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
],
),
),
);
}
}

View file

@ -1,6 +1,6 @@
name: chain_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.24.1
version: 0.25.0
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor

View file

@ -135,13 +135,20 @@ steps:
name: x
steps:
- id: c
use: htw.private/secret@^0.1
use: acme.internal/secret@^0.1
'''));
expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first);
expect(fixes, hasLength(1));
expect(fixes.first, isA<AddModuleSourceFix>());
expect(r.issues.first.message, contains('Not in the store'));
// The message must explain all three recovery paths in place:
// local install, adding a store, configuring an integration.
expect(
r.issues.first.message,
contains('No configured store can install it'),
);
expect(r.issues.first.message, contains('chain install --link'));
expect(r.issues.first.message, contains('integration'));
});
test('did-you-mean wins over install/add-source when a near miss exists',

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio_flow_editor/src/l10n.dart';
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
void main() {
group('splitMissingCaps', () {
test('separates store-resolvable from not-in-store capabilities', () {
final split = splitMissingCaps(
['text.extract', 'example-provider/tool.summarize'],
{'text.extract', 'debug.echo'},
);
expect(split.installable, ['text.extract']);
expect(split.notInStore, ['example-provider/tool.summarize']);
});
test('matches on the bare name when the flow pins a version', () {
final split = splitMissingCaps(['text.extract@^0'], {'text.extract'});
expect(split.installable, ['text.extract@^0']);
expect(split.notInStore, isEmpty);
});
test('empty store set classifies everything as not-in-store', () {
final split = splitMissingCaps(['text.extract'], {});
expect(split.installable, isEmpty);
expect(split.notInStore, ['text.extract']);
});
});
Future<void> pumpBadge(
WidgetTester tester, {
required List<String> installable,
required List<String> notInStore,
VoidCallback? onInstall,
}) {
return tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MissingModulesBadge(
installable: installable,
notInStore: notInStore,
strings: const FlowEditorStrings(FlowEditorLocale.en),
onInstall: onInstall,
),
),
),
);
}
group('MissingModulesBadge', () {
testWidgets('store-resolvable capability offers the install action', (
tester,
) async {
var installed = false;
await pumpBadge(
tester,
installable: ['text.extract'],
notInStore: [],
onInstall: () => installed = true,
);
expect(find.text('1 module missing'), findsOneWidget);
expect(find.text('Install'), findsOneWidget);
expect(find.textContaining('not in store'), findsNothing);
await tester.tap(find.text('Install'));
expect(installed, isTrue);
});
testWidgets(
'not-in-store capability shows the classified state, no install',
(tester) async {
await pumpBadge(
tester,
installable: [],
notInStore: ['example-provider/tool.summarize'],
onInstall: () => fail('no install action for not-in-store caps'),
);
expect(find.text('not in store'), findsOneWidget);
expect(find.text('Install'), findsNothing);
// The recovery paths are explained in place, before any click.
final tooltip = tester.widget<Tooltip>(find.byType(Tooltip));
expect(tooltip.message, contains('example-provider/tool.summarize'));
expect(tooltip.message, contains('chain install --link'));
expect(tooltip.message, contains('Settings → Stores'));
expect(tooltip.message, contains('integration'));
},
);
testWidgets('mixed state renders both chips, install covers store caps', (
tester,
) async {
await pumpBadge(
tester,
installable: ['text.extract'],
notInStore: ['example-provider/tool.summarize'],
onInstall: () {},
);
expect(find.text('1 module missing'), findsOneWidget);
expect(find.text('Install'), findsOneWidget);
expect(find.text('not in store'), findsOneWidget);
});
});
}