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
## 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
- Run tab file inputs accept a host-injected native file picker

View file

@ -1252,11 +1252,6 @@ class _FlowFile {
/// content contains this exact string.
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
/// capability NAMES (without `@version`) its steps reference.
class _FlowMeta {
@ -1266,15 +1261,16 @@ class _FlowMeta {
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
/// Capabilities this flow needs that are neither installed nor
/// built in. [availableNames] is the set of installed
/// capability NAMES (the part before `@`).
List<String> missingCaps(Set<String> availableNames) => requiredCaps
.where(
(c) =>
!availableNames.contains(c) && !_builtinCapabilities.contains(c),
)
.toList();
/// Capabilities this flow needs that the hub does not provide.
/// [availableNames] is the set of capability NAMES (the part
/// before `@`) from the host's live capability list — which
/// already includes the hub's builtins (e.g. system.approval),
/// so there is no client-side builtin list to drift out of
/// sync with the hub (the old hardcoded {'debug.echo'} made
/// the bundled hello flow look runnable on hubs that don't
/// have the module).
List<String> missingCaps(Set<String> availableNames) =>
requiredCaps.where((c) => !availableNames.contains(c)).toList();
}
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
@ -1345,7 +1341,7 @@ String _formatBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
class _FileList extends StatelessWidget {
class _FileList extends StatefulWidget {
final Future<List<_FlowFile>> filesFuture;
final String? activeName;
final FlowEditorStrings strings;
@ -1377,9 +1373,20 @@ class _FileList extends StatelessWidget {
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
final strings = widget.strings;
return Container(
color: theme.colorScheme.surface,
child: Column(
@ -1405,7 +1412,7 @@ class _FileList extends StatelessWidget {
),
),
IconButton(
onPressed: onRefresh,
onPressed: widget.onRefresh,
tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16),
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)),
],
),
@ -1425,8 +1469,9 @@ class _FileList extends StatelessWidget {
}
Widget _buildBody(BuildContext context, ThemeData theme) {
final strings = widget.strings;
return FutureBuilder<List<_FlowFile>>(
future: filesFuture,
future: widget.filesFuture,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
@ -1437,8 +1482,8 @@ class _FileList extends StatelessWidget {
child: ChainErrorBox(error: snap.error, isError: true),
);
}
final files = snap.data ?? <_FlowFile>[];
if (files.isEmpty) {
final all = snap.data ?? <_FlowFile>[];
if (all.isEmpty) {
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
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(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
itemCount: files.length,
itemBuilder: (_, i) {
final f = files[i];
final isActive = f.name == activeName;
final missing = f.meta.missingCaps(installedNames);
final isActive = f.name == widget.activeName;
final missing = f.meta.missingCaps(widget.installedNames);
return InkWell(
onTap: () => onOpen(f),
onTap: () => widget.onOpen(f),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
@ -1535,9 +1598,10 @@ class _FileList extends StatelessWidget {
_MissingModulesBadge(
missing: missing,
strings: strings,
onInstall: onInstallMissing == null
onInstall: widget.onInstallMissing == null
? null
: () => onInstallMissing!(missing),
: () =>
widget.onInstallMissing!(missing),
),
],
),
@ -1550,7 +1614,7 @@ class _FileList extends StatelessWidget {
// the install badge is the actionable path then.
if (missing.isEmpty)
IconButton(
onPressed: () => onStart(f),
onPressed: () => widget.onStart(f),
tooltip: strings.listStartTooltip,
icon: Icon(
Icons.play_arrow_rounded,
@ -1617,11 +1681,17 @@ class _ExampleBadge extends StatelessWidget {
}
}
/// Warning-toned "needs N modules" chip on flows whose steps
/// reference capabilities that aren't installed. Tooltip lists
/// the exact missing ids. When an install handler is wired, the
/// chip is tappable and one-click installs all missing caps via
/// the host's existing install path.
/// "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;
@ -1635,50 +1705,67 @@ class _MissingModulesBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Amber warning tone consistent with the diagnostic strip's
// warning colour, readable on both light + dark surfaces.
const warn = Color(0xFFEF6C00);
final label = '${strings.flowListNeedsModules(missing.length)}'
'${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}';
final chip = Container(
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: warn.withValues(alpha: 0.12),
color: theme.colorScheme.surfaceContainerHighest,
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(
mainAxisSize: MainAxisSize.min,
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),
// Flexible + ellipsis: the chip sits in the narrow flow
// list, where the full label otherwise overflows by a few
// pixels (the tooltip still carries the complete text).
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
Text(
strings.flowListNeedsModules(missing.length),
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,
fontWeight: FontWeight.w600,
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 emptyBody => _t(
'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 =>
_t('No saved flows', 'Keine gespeicherten Flows');
String get listEmptyBody => _t(
'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 =>
_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 graphEmptyBody => _t(
'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.
String get propPanelTitle => _t('Step details', 'Schritt-Details');
String get propNoSelection => _t(
'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 propCapability => _t('Capability', 'Capability');
@ -142,11 +144,12 @@ class FlowEditorStrings {
String get runStart => _t('Start run', 'Lauf starten');
String get runUnsavedBanner => _t(
'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(
'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 runChooseFile => _t('Choose file…', 'Datei wählen…');
@ -253,15 +256,15 @@ class FlowEditorStrings {
);
String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
'Unknown capability "$cap". Did you mean "$suggestion"?',
'Unbekannte Capability "$cap". Meintest du "$suggestion"?',
'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?',
);
String analyzerUnknownCapNotInStore(String cap) => _t(
'Unknown capability "$cap". '
'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". '
'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 analyzerOutputKind() => _t('output', 'Ausgabe');
@ -280,8 +283,8 @@ class FlowEditorStrings {
'Mitgelieferter Beispiel-Flow.',
);
String flowListNeedsModules(int n) => _t(
n == 1 ? 'needs 1 module' : 'needs $n modules',
n == 1 ? 'braucht 1 Modul' : 'braucht $n Module',
n == 1 ? '1 module missing' : '$n modules missing',
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
);
String flowListNeedsModulesTooltip(String caps) => _t(
'This flow needs capabilities that are not installed:\n$caps\n'
@ -291,6 +294,13 @@ class FlowEditorStrings {
);
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.
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
String fixAddSource(String cap) =>
@ -379,7 +389,7 @@ class AnalyzerStrings {
static String _enUnknownCapNotInStore(String cap) =>
'Unknown capability "$cap". '
'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) =>
'Unknown $kind type "$value". Use one of: $validList.';
static String _enInputKind() => 'input';

View file

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