From 753db4fc60df1fc9f50b440046dfbabafbb6d46a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 5 May 2026 23:35:48 +0200 Subject: [PATCH] feat(ui): Flows page (5th destination) + keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-DX additions: 1. **Flows** is the new third destination in the sidebar. Lists saved flows from the hub via the new HubAdmin ListFlows RPC. Each row has a Run button that opens an input dialog (key=value pairs, one per line, all values sent as text payloads — the binary-input case stays in `fai run` CLI). Flow runs in a non-dismissable progress dialog; output is shown per-key with monospace selectable text. Errors render with the exception detail for diagnosis. 2. **Keyboard shortcuts** at the shell level: - Cmd+1 / Cmd+2 / Cmd+3 / Cmd+4 / Cmd+5 jump to the matching destination (Doctor / Modules / Flows / Audit / Approvals). - Cmd+, opens the Settings dialog (macOS convention). Implemented via Flutter's Shortcuts/Actions/Intent triple so the bindings are discoverable in IDE devtools and composable with platform-specific overrides later. Bumps fai_studio 0.7.3 -> 0.8.0. Signed-off-by: flemming-it --- lib/data/hub.dart | 55 ++++++ lib/main.dart | 92 ++++++++--- lib/pages/flows.dart | 376 ++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- test/widget_test.dart | 7 +- 5 files changed, 508 insertions(+), 24 deletions(-) create mode 100644 lib/pages/flows.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 66287e0..1264a81 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -106,6 +106,49 @@ class HubService { ); } + /// Saved flows known to the hub. + Future> listFlows() async { + final flows = await _client.listFlows(); + return flows + .map( + (f) => SavedFlow( + name: f.name, + path: f.path, + sizeBytes: f.sizeBytes.toInt(), + ), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + /// Run a saved flow with the supplied text inputs. Returns + /// the named outputs as a map of UI-friendly strings. + Future> runSavedFlow({ + required String name, + required Map textInputs, + }) async { + final r = await _client.runSavedFlow( + name: name, + textInputs: textInputs, + ); + return { + for (final entry in r.outputs.entries) + entry.key: _payloadToText(entry.value), + }; + } + + String _payloadToText(Payload p) { + if (p.hasText()) return p.text; + if (p.hasJson()) return p.json.toString(); + if (p.hasBytes()) { + return ''; + } + if (p.hasFile()) { + return ''; + } + return ''; + } + Future> recentEvents({ int limit = 50, List types = const [], @@ -264,6 +307,18 @@ class ModuleSummary { }); } +class SavedFlow { + final String name; + final String path; + final int sizeBytes; + + const SavedFlow({ + required this.name, + required this.path, + required this.sizeBytes, + }); +} + class ModuleDetail { final String name; final String version; diff --git a/lib/main.dart b/lib/main.dart index a0af48f..42cc9c8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,10 +7,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'data/hub.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; +import 'pages/flows.dart'; import 'pages/modules.dart'; import 'theme/theme.dart'; import 'theme/tokens.dart'; @@ -19,7 +22,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.7.3'; +const String kStudioVersion = '0.8.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -121,6 +124,12 @@ class _StudioShellState extends State { selectedIcon: Icons.extension, page: ModulesPage(), ), + _NavPage( + label: 'Flows', + icon: Icons.account_tree_outlined, + selectedIcon: Icons.account_tree, + page: FlowsPage(), + ), _NavPage( label: 'Audit', icon: Icons.timeline_outlined, @@ -160,22 +169,52 @@ class _StudioShellState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - body: Row( - children: [ - _Sidebar( - selectedIndex: _selectedIndex, - onSelect: (i) => setState(() => _selectedIndex = i), - pages: _pages, - connected: _connected, - endpointLabel: HubService.instance.endpointLabel, + return Shortcuts( + shortcuts: { + // Cmd+1..5 jumps to the matching destination. Numbered + // 1-based to match the visual order in the sidebar. + for (var i = 0; i < _pages.length; i++) + SingleActivator( + LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i), + meta: true, + ): _GoToPageIntent(i), + // Cmd+, opens the settings dialog (macOS convention). + const SingleActivator(LogicalKeyboardKey.comma, meta: true): + const _OpenSettingsIntent(), + }, + child: Actions( + actions: >{ + _GoToPageIntent: CallbackAction<_GoToPageIntent>( + onInvoke: (intent) { + setState(() => _selectedIndex = intent.index); + return null; + }, ), - Container( - width: 1, - color: theme.colorScheme.outlineVariant, + _OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>( + onInvoke: (_) { + FaiSettingsDialog.show(context); + return null; + }, ), - Expanded( - child: AnimatedSwitcher( + }, + child: Focus( + autofocus: true, + child: Scaffold( + body: Row( + children: [ + _Sidebar( + selectedIndex: _selectedIndex, + onSelect: (i) => setState(() => _selectedIndex = i), + pages: _pages, + connected: _connected, + endpointLabel: HubService.instance.endpointLabel, + ), + Container( + width: 1, + color: theme.colorScheme.outlineVariant, + ), + Expanded( + child: AnimatedSwitcher( duration: FaiMotion.base, switchInCurve: FaiMotion.easing, switchOutCurve: FaiMotion.easing, @@ -189,18 +228,31 @@ class _StudioShellState extends State { child: child, ), ), - child: KeyedSubtree( - key: ValueKey(_selectedIndex), - child: _pages[_selectedIndex].page, - ), + child: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: _pages[_selectedIndex].page, + ), + ), + ), + ], ), ), - ], + ), ), ); } } +/// Intents driven by the Shortcuts/Actions pair on the shell. +class _GoToPageIntent extends Intent { + final int index; + const _GoToPageIntent(this.index); +} + +class _OpenSettingsIntent extends Intent { + const _OpenSettingsIntent(); +} + class _Sidebar extends StatelessWidget { final int selectedIndex; final ValueChanged onSelect; diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart new file mode 100644 index 0000000..caef12f --- /dev/null +++ b/lib/pages/flows.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; + +class FlowsPage extends StatefulWidget { + const FlowsPage({super.key}); + + @override + State createState() => _FlowsPageState(); +} + +class _FlowsPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = HubService.instance.listFlows(); + } + + void _refresh() => setState(() { + _future = HubService.instance.listFlows(); + }); + + Future _runFlow(SavedFlow flow) async { + final inputs = await _FlowInputDialog.show(context, flow); + if (inputs == null) return; + if (!mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _FlowRunDialog(flow: flow, inputs: inputs), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: AppBar( + title: const Text('Flows'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: 'Reload', + onPressed: _refresh, + ), + const SizedBox(width: FaiSpace.sm), + ], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: Theme.of(context).colorScheme.error, + title: 'Hub unreachable', + hint: 'Start the hub with `fai serve`.', + action: FilledButton.tonal( + onPressed: _refresh, + child: const Text('Retry'), + ), + ); + } + final flows = snapshot.data ?? []; + if (flows.isEmpty) { + return const FaiEmptyState( + icon: Icons.account_tree_outlined, + title: 'No saved flows', + hint: + 'Save a flow with `fai admin flows save ` and it shows up here.', + ); + } + return ListView.separated( + padding: const EdgeInsets.all(FaiSpace.xl), + itemCount: flows.length, + separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), + itemBuilder: (context, i) => + _FlowCard(flow: flows[i], onRun: () => _runFlow(flows[i])), + ); + }, + ), + ); + } +} + +class _FlowCard extends StatelessWidget { + final SavedFlow flow; + final VoidCallback onRun; + + const _FlowCard({required this.flow, required this.onRun}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FaiCard( + child: Row( + children: [ + Icon( + Icons.account_tree_outlined, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: FaiSpace.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + flow.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + flow.path, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + FaiPill( + label: '${flow.sizeBytes} B', + tone: FaiPillTone.neutral, + monospace: true, + ), + const SizedBox(width: FaiSpace.md), + FilledButton.icon( + onPressed: onRun, + icon: const Icon(Icons.play_arrow, size: 16), + label: const Text('Run'), + ), + ], + ), + ); + } +} + +class _FlowInputDialog extends StatefulWidget { + final SavedFlow flow; + + const _FlowInputDialog({required this.flow}); + + static Future?> show( + BuildContext context, + SavedFlow flow, + ) { + return showDialog>( + context: context, + builder: (_) => _FlowInputDialog(flow: flow), + ); + } + + @override + State<_FlowInputDialog> createState() => _FlowInputDialogState(); +} + +class _FlowInputDialogState extends State<_FlowInputDialog> { + // The hub does not yet expose the flow's input schema over + // gRPC, so this dialog accepts free-form key=value pairs. + // Operator pastes pairs separated by newlines; we split on + // the first `=` per line. + final _ctrl = TextEditingController(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Map _parsePairs() { + final out = {}; + for (final raw in _ctrl.text.split('\n')) { + final line = raw.trim(); + if (line.isEmpty) continue; + final eq = line.indexOf('='); + if (eq < 0) continue; + out[line.substring(0, eq).trim()] = line.substring(eq + 1).trim(); + } + return out; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text('Run ${widget.flow.name}'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Inputs as key=value, one per line. All values are sent as text payloads. For binary inputs use the `fai run` CLI.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + Expanded( + child: TextField( + controller: _ctrl, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: FaiTheme.mono(size: 12), + decoration: const InputDecoration( + hintText: + 'name=World\ntarget_language=English\nsummary_style=three bullet points', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton.icon( + icon: const Icon(Icons.play_arrow, size: 16), + label: const Text('Run'), + onPressed: () => Navigator.pop(context, _parsePairs()), + ), + ], + ); + } +} + +class _FlowRunDialog extends StatefulWidget { + final SavedFlow flow; + final Map inputs; + + const _FlowRunDialog({required this.flow, required this.inputs}); + + @override + State<_FlowRunDialog> createState() => _FlowRunDialogState(); +} + +class _FlowRunDialogState extends State<_FlowRunDialog> { + late final Future> _future; + + @override + void initState() { + super.initState(); + _future = HubService.instance.runSavedFlow( + name: widget.flow.name, + textInputs: widget.inputs, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text('Running ${widget.flow.name}'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(FaiRadius.md), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480), + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: FaiSpace.md), + const CircularProgressIndicator(), + const SizedBox(height: FaiSpace.md), + Text( + 'Flow running…', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + if (snapshot.hasError) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 32, + ), + const SizedBox(height: FaiSpace.md), + SelectableText( + snapshot.error.toString(), + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.error, + ), + ), + ], + ), + ); + } + final outputs = snapshot.data ?? const {}; + if (outputs.isEmpty) { + return Text( + 'Flow completed with no declared outputs.', + style: theme.textTheme.bodyMedium, + ); + } + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final entry in outputs.entries) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + entry.key.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: SelectableText( + entry.value, + style: FaiTheme.mono(size: 11), + ), + ), + const SizedBox(height: FaiSpace.md), + ], + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2d7b23c..c199c96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.7.3 +version: 0.8.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/widget_test.dart b/test/widget_test.dart index a8b1655..252d4f3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,7 +6,7 @@ import 'package:fai_studio/data/hub.dart'; import 'package:fai_studio/main.dart'; void main() { - testWidgets('Studio shell shows the four destinations', + testWidgets('Studio shell shows the five destinations', (tester) async { await tester.pumpWidget( const StudioApp(initialThemeMode: ThemeModeValue.system), @@ -15,7 +15,8 @@ void main() { expect(find.text('F∆I Studio'), findsOneWidget); expect(find.text('Doctor'), findsWidgets); expect(find.text('Modules'), findsWidgets); - expect(find.text('Audit'), findsWidgets); - expect(find.text('Approvals'), findsWidgets); + expect(find.text('Flows'), findsOneWidget); + expect(find.text('Audit'), findsOneWidget); + expect(find.text('Approvals'), findsOneWidget); }); }