feat: usertest fixes — hub-truth builtins, calm badge + separate install action, list filter, formal address

- drop the hardcoded builtin set {'debug.echo'}; missing-module
  detection trusts the host capability list (includes real hub
  builtins) — fixes hello's false play button
- split the orange combined chip into a neutral status chip and a
  never-truncated install link
- add a substring filter over the flow list with an honest
  no-match state
- German strings use the formal address throughout; grammar fixes
- analyzer hint says 'chain install --link' (was 'fai …')
- 0.24.0

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-17 23:49:17 +02:00
parent ae09a55146
commit 4e7abec24d
4 changed files with 199 additions and 80 deletions

View file

@ -1,5 +1,27 @@
# Changelog # Changelog
## 0.24.0
Usertest-panel fixes (flow list + language):
- **Builtin-capability truth from the hub.** Removed the hardcoded
client-side builtin set (`{'debug.echo'}`): missing-module
detection now trusts the host-supplied capability list alone,
which already includes the hub's real builtins. Fixes the bundled
`hello` flow showing a play button on hubs that don't have
`debug.echo` installed.
- **Missing-modules badge redesigned.** Status ("N Module fehlen",
quiet neutral chip with an amber dot) and the install action
(own link, never truncated) are separate elements; the former
combined orange chip read as an error wall and ellipsized the
action word.
- **Flow-list filter.** Case-insensitive substring filter above the
list, with an honest "no flow matches" empty state.
- **Formal address (Sie) in all German strings** plus missing
commas/quotes in the empty-state sentences.
- Analyzer hint now names the current CLI (`chain install --link`,
was `fai install --link`).
## 0.23.0 ## 0.23.0
- Run tab file inputs accept a host-injected native file picker - Run tab file inputs accept a host-injected native file picker

View file

@ -1252,11 +1252,6 @@ class _FlowFile {
/// content contains this exact string. /// content contains this exact string.
const String _sampleFlowMarker = 'F∆I sample flow'; const String _sampleFlowMarker = 'F∆I sample flow';
/// Built-in capabilities the Hub always provides, even when
/// they aren't in the host-supplied installed list. Treated as
/// satisfied so the "needs N modules" badge never flags them.
const Set<String> _builtinCapabilities = {'debug.echo'};
/// Result of scanning a single flow file: provenance + the /// Result of scanning a single flow file: provenance + the
/// capability NAMES (without `@version`) its steps reference. /// capability NAMES (without `@version`) its steps reference.
class _FlowMeta { class _FlowMeta {
@ -1266,15 +1261,16 @@ class _FlowMeta {
static const empty = _FlowMeta(isExample: false, requiredCaps: []); static const empty = _FlowMeta(isExample: false, requiredCaps: []);
/// Capabilities this flow needs that are neither installed nor /// Capabilities this flow needs that the hub does not provide.
/// built in. [availableNames] is the set of installed /// [availableNames] is the set of capability NAMES (the part
/// capability NAMES (the part before `@`). /// before `@`) from the host's live capability list — which
List<String> missingCaps(Set<String> availableNames) => requiredCaps /// already includes the hub's builtins (e.g. system.approval),
.where( /// so there is no client-side builtin list to drift out of
(c) => /// sync with the hub (the old hardcoded {'debug.echo'} made
!availableNames.contains(c) && !_builtinCapabilities.contains(c), /// the bundled hello flow look runnable on hubs that don't
) /// have the module).
.toList(); List<String> missingCaps(Set<String> availableNames) =>
requiredCaps.where((c) => !availableNames.contains(c)).toList();
} }
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading /// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
@ -1345,7 +1341,7 @@ String _formatBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} }
class _FileList extends StatelessWidget { class _FileList extends StatefulWidget {
final Future<List<_FlowFile>> filesFuture; final Future<List<_FlowFile>> filesFuture;
final String? activeName; final String? activeName;
final FlowEditorStrings strings; final FlowEditorStrings strings;
@ -1377,9 +1373,20 @@ class _FileList extends StatelessWidget {
required this.onInstallMissing, required this.onInstallMissing,
}); });
@override
State<_FileList> createState() => _FileListState();
}
class _FileListState extends State<_FileList> {
/// Case-insensitive substring filter over flow names. First
/// iteration of "find a flow fast" (usertest power-user
/// finding: no search over the flow list at all).
String _filter = '';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final strings = widget.strings;
return Container( return Container(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
child: Column( child: Column(
@ -1405,7 +1412,7 @@ class _FileList extends StatelessWidget {
), ),
), ),
IconButton( IconButton(
onPressed: onRefresh, onPressed: widget.onRefresh,
tooltip: strings.refresh, tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -1418,6 +1425,43 @@ class _FileList extends StatelessWidget {
], ],
), ),
), ),
Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.md,
FaiSpace.xs,
FaiSpace.md,
FaiSpace.xs,
),
child: SizedBox(
height: 28,
child: TextField(
onChanged: (v) => setState(() => _filter = v.trim()),
style: theme.textTheme.bodySmall,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
prefixIcon: const Icon(Icons.search, size: 14),
prefixIconConstraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
hintText: strings.listFilterHint,
hintStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
borderSide: BorderSide(
color: theme.colorScheme.outlineVariant,
),
),
),
),
),
),
Expanded(child: _buildBody(context, theme)), Expanded(child: _buildBody(context, theme)),
], ],
), ),
@ -1425,8 +1469,9 @@ class _FileList extends StatelessWidget {
} }
Widget _buildBody(BuildContext context, ThemeData theme) { Widget _buildBody(BuildContext context, ThemeData theme) {
final strings = widget.strings;
return FutureBuilder<List<_FlowFile>>( return FutureBuilder<List<_FlowFile>>(
future: filesFuture, future: widget.filesFuture,
builder: (context, snap) { builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -1437,8 +1482,8 @@ class _FileList extends StatelessWidget {
child: ChainErrorBox(error: snap.error, isError: true), child: ChainErrorBox(error: snap.error, isError: true),
); );
} }
final files = snap.data ?? <_FlowFile>[]; final all = snap.data ?? <_FlowFile>[];
if (files.isEmpty) { if (all.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(FaiSpace.md), padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState( child: FaiEmptyState(
@ -1448,15 +1493,33 @@ class _FileList extends StatelessWidget {
), ),
); );
} }
final needle = _filter.toLowerCase();
final files = needle.isEmpty
? all
: all
.where((f) => f.name.toLowerCase().contains(needle))
.toList();
if (files.isEmpty) {
// Flows exist, the filter just matches none say that
// instead of pretending the directory is empty.
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState(
icon: Icons.search_off_outlined,
title: strings.listFilterNoMatch(_filter),
hint: null,
),
);
}
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs), padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
itemCount: files.length, itemCount: files.length,
itemBuilder: (_, i) { itemBuilder: (_, i) {
final f = files[i]; final f = files[i];
final isActive = f.name == activeName; final isActive = f.name == widget.activeName;
final missing = f.meta.missingCaps(installedNames); final missing = f.meta.missingCaps(widget.installedNames);
return InkWell( return InkWell(
onTap: () => onOpen(f), onTap: () => widget.onOpen(f),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md, horizontal: FaiSpace.md,
@ -1535,9 +1598,10 @@ class _FileList extends StatelessWidget {
_MissingModulesBadge( _MissingModulesBadge(
missing: missing, missing: missing,
strings: strings, strings: strings,
onInstall: onInstallMissing == null onInstall: widget.onInstallMissing == null
? null ? null
: () => onInstallMissing!(missing), : () =>
widget.onInstallMissing!(missing),
), ),
], ],
), ),
@ -1550,7 +1614,7 @@ class _FileList extends StatelessWidget {
// the install badge is the actionable path then. // the install badge is the actionable path then.
if (missing.isEmpty) if (missing.isEmpty)
IconButton( IconButton(
onPressed: () => onStart(f), onPressed: () => widget.onStart(f),
tooltip: strings.listStartTooltip, tooltip: strings.listStartTooltip,
icon: Icon( icon: Icon(
Icons.play_arrow_rounded, Icons.play_arrow_rounded,
@ -1617,11 +1681,17 @@ class _ExampleBadge extends StatelessWidget {
} }
} }
/// Warning-toned "needs N modules" chip on flows whose steps /// "N modules missing" status on flows whose steps reference
/// reference capabilities that aren't installed. Tooltip lists /// capabilities the hub doesn't provide. Status and action are
/// the exact missing ids. When an install handler is wired, the /// SEPARATE elements (usertest: the combined orange chip made
/// chip is tappable and one-click installs all missing caps via /// the whole list read like an error wall and ellipsized the
/// the host's existing install path. /// 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 { class _MissingModulesBadge extends StatelessWidget {
final List<String> missing; final List<String> missing;
final FlowEditorStrings strings; final FlowEditorStrings strings;
@ -1635,50 +1705,67 @@ class _MissingModulesBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// Amber warning tone consistent with the diagnostic strip's const dot = Color(0xFFEF6C00);
// warning colour, readable on both light + dark surfaces. final fg = theme.colorScheme.onSurfaceVariant;
const warn = Color(0xFFEF6C00); final chip = Tooltip(
final label = '${strings.flowListNeedsModules(missing.length)}' message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
'${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}'; child: Container(
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: warn.withValues(alpha: 0.12), color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: warn.withValues(alpha: 0.5)), border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.extension_off_outlined, size: 10, color: warn), Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: dot,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4), const SizedBox(width: 4),
// Flexible + ellipsis: the chip sits in the narrow flow Text(
// list, where the full label otherwise overflows by a few strings.flowListNeedsModules(missing.length),
// pixels (the tooltip still carries the complete text).
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: warn, 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, fontSize: 10,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.2, letterSpacing: 0.2,
), ),
), ),
), ),
),
], ],
),
);
return Tooltip(
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
child: onInstall == null
? chip
: InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: chip,
),
); );
} }
} }

View file

@ -28,13 +28,14 @@ class FlowEditorStrings {
String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet'); String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet');
String get emptyBody => _t( String get emptyBody => _t(
'Pick one from the left, or click New flow to start a fresh one.', 'Pick one from the left, or click New flow to start a fresh one.',
'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.', 'Wählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", '
'um einen neuen anzulegen.',
); );
String get listEmptyTitle => String get listEmptyTitle =>
_t('No saved flows', 'Keine gespeicherten Flows'); _t('No saved flows', 'Keine gespeicherten Flows');
String get listEmptyBody => _t( String get listEmptyBody => _t(
'Click New flow to scaffold one from a template.', 'Click New flow to scaffold one from a template.',
'Klicke Neuer Flow um aus einer Vorlage zu starten.', 'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.',
); );
String get discardTitle => String get discardTitle =>
_t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?'); _t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
@ -95,14 +96,15 @@ class FlowEditorStrings {
String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte'); String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
String get graphEmptyBody => _t( String get graphEmptyBody => _t(
'Click Add step to drop the first capability onto the canvas.', 'Click Add step to drop the first capability onto the canvas.',
'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.', 'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf '
'die Fläche zu setzen.',
); );
// Properties panel. // Properties panel.
String get propPanelTitle => _t('Step details', 'Schritt-Details'); String get propPanelTitle => _t('Step details', 'Schritt-Details');
String get propNoSelection => _t( String get propNoSelection => _t(
'Click a step on the canvas to edit it.', 'Click a step on the canvas to edit it.',
'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.', 'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
); );
String get propStepId => _t('ID', 'ID'); String get propStepId => _t('ID', 'ID');
String get propCapability => _t('Capability', 'Capability'); String get propCapability => _t('Capability', 'Capability');
@ -142,11 +144,12 @@ class FlowEditorStrings {
String get runStart => _t('Start run', 'Lauf starten'); String get runStart => _t('Start run', 'Lauf starten');
String get runUnsavedBanner => _t( String get runUnsavedBanner => _t(
'You have unsaved changes — save first to run the latest version.', 'You have unsaved changes — save first to run the latest version.',
'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.', 'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die '
'neueste Version auszuführen.',
); );
String get runNoFlow => _t( String get runNoFlow => _t(
'Open a flow from the left to run it.', 'Open a flow from the left to run it.',
'Öffne links einen Flow um ihn auszuführen.', 'Öffnen Sie links einen Flow, um ihn auszuführen.',
); );
String get runOutputs => _t('Outputs', 'Ausgaben'); String get runOutputs => _t('Outputs', 'Ausgaben');
String get runChooseFile => _t('Choose file…', 'Datei wählen…'); String get runChooseFile => _t('Choose file…', 'Datei wählen…');
@ -253,15 +256,15 @@ class FlowEditorStrings {
); );
String analyzerUnknownCapTypo(String cap, String suggestion) => _t( String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
'Unknown capability "$cap". Did you mean "$suggestion"?', 'Unknown capability "$cap". Did you mean "$suggestion"?',
'Unbekannte Capability "$cap". Meintest du "$suggestion"?', 'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?',
); );
String analyzerUnknownCapNotInStore(String cap) => _t( String analyzerUnknownCapNotInStore(String cap) => _t(
'Unknown capability "$cap". ' 'Unknown capability "$cap". '
'Not in the store — install locally with ' 'Not in the store — install locally with '
'`fai install --link <path>` or check the spelling.', '`chain install --link <path>` or check the spelling.',
'Unbekannte Capability "$cap". ' 'Unbekannte Capability "$cap". '
'Nicht im Store — lokal mit ' 'Nicht im Store — lokal mit '
'`fai install --link <pfad>` installieren oder Tippfehler prüfen.', '`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
); );
String analyzerInputKind() => _t('input', 'Eingabe'); String analyzerInputKind() => _t('input', 'Eingabe');
String analyzerOutputKind() => _t('output', 'Ausgabe'); String analyzerOutputKind() => _t('output', 'Ausgabe');
@ -280,8 +283,8 @@ class FlowEditorStrings {
'Mitgelieferter Beispiel-Flow.', 'Mitgelieferter Beispiel-Flow.',
); );
String flowListNeedsModules(int n) => _t( String flowListNeedsModules(int n) => _t(
n == 1 ? 'needs 1 module' : 'needs $n modules', n == 1 ? '1 module missing' : '$n modules missing',
n == 1 ? 'braucht 1 Modul' : 'braucht $n Module', n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
); );
String flowListNeedsModulesTooltip(String caps) => _t( String flowListNeedsModulesTooltip(String caps) => _t(
'This flow needs capabilities that are not installed:\n$caps\n' 'This flow needs capabilities that are not installed:\n$caps\n'
@ -291,6 +294,13 @@ class FlowEditorStrings {
); );
String get flowListInstallMissing => _t('Install', 'Installieren'); String get flowListInstallMissing => _t('Install', 'Installieren');
// Flow-list filter (first iteration: plain substring match).
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
String listFilterNoMatch(String query) => _t(
'No flow matches "$query".',
'Kein Flow passt zu „$query".',
);
// Quick-fix button labels. // Quick-fix button labels.
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren'); String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
String fixAddSource(String cap) => String fixAddSource(String cap) =>
@ -379,7 +389,7 @@ class AnalyzerStrings {
static String _enUnknownCapNotInStore(String cap) => static String _enUnknownCapNotInStore(String cap) =>
'Unknown capability "$cap". ' 'Unknown capability "$cap". '
'Not in the store — install locally with ' 'Not in the store — install locally with '
'`fai install --link <path>` or check the spelling.'; '`chain install --link <path>` or check the spelling.';
static String _enUnknownType(String kind, String value, String validList) => static String _enUnknownType(String kind, String value, String validList) =>
'Unknown $kind type "$value". Use one of: $validList.'; 'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input'; static String _enInputKind() => 'input';

View file

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