feat(ui): Flows page (5th destination) + keyboard shortcuts

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 23:35:48 +02:00
parent 2f32023322
commit 753db4fc60
5 changed files with 508 additions and 24 deletions

View file

@ -106,6 +106,49 @@ class HubService {
); );
} }
/// Saved flows known to the hub.
Future<List<SavedFlow>> 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<Map<String, String>> runSavedFlow({
required String name,
required Map<String, String> 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 '<bytes: ${p.bytes.data.length} bytes, ${p.bytes.mimeType}>';
}
if (p.hasFile()) {
return '<file: ${p.file.uri}>';
}
return '<unknown payload variant>';
}
Future<List<AuditEvent>> recentEvents({ Future<List<AuditEvent>> recentEvents({
int limit = 50, int limit = 50,
List<String> types = const [], List<String> 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 { class ModuleDetail {
final String name; final String name;
final String version; final String version;

View file

@ -7,10 +7,13 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'data/hub.dart'; import 'data/hub.dart';
import 'pages/approvals.dart'; import 'pages/approvals.dart';
import 'pages/audit.dart'; import 'pages/audit.dart';
import 'pages/doctor.dart'; import 'pages/doctor.dart';
import 'pages/flows.dart';
import 'pages/modules.dart'; import 'pages/modules.dart';
import 'theme/theme.dart'; import 'theme/theme.dart';
import 'theme/tokens.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 /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.7.3'; const String kStudioVersion = '0.8.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -121,6 +124,12 @@ class _StudioShellState extends State<StudioShell> {
selectedIcon: Icons.extension, selectedIcon: Icons.extension,
page: ModulesPage(), page: ModulesPage(),
), ),
_NavPage(
label: 'Flows',
icon: Icons.account_tree_outlined,
selectedIcon: Icons.account_tree,
page: FlowsPage(),
),
_NavPage( _NavPage(
label: 'Audit', label: 'Audit',
icon: Icons.timeline_outlined, icon: Icons.timeline_outlined,
@ -160,7 +169,37 @@ class _StudioShellState extends State<StudioShell> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
// 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: <Type, Action<Intent>>{
_GoToPageIntent: CallbackAction<_GoToPageIntent>(
onInvoke: (intent) {
setState(() => _selectedIndex = intent.index);
return null;
},
),
_OpenSettingsIntent: CallbackAction<_OpenSettingsIntent>(
onInvoke: (_) {
FaiSettingsDialog.show(context);
return null;
},
),
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Row( body: Row(
children: [ children: [
_Sidebar( _Sidebar(
@ -197,10 +236,23 @@ class _StudioShellState extends State<StudioShell> {
), ),
], ],
), ),
),
),
),
); );
} }
} }
/// 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 { class _Sidebar extends StatelessWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onSelect; final ValueChanged<int> onSelect;

376
lib/pages/flows.dart Normal file
View file

@ -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<FlowsPage> createState() => _FlowsPageState();
}
class _FlowsPageState extends State<FlowsPage> {
late Future<List<SavedFlow>> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.listFlows();
}
void _refresh() => setState(() {
_future = HubService.instance.listFlows();
});
Future<void> _runFlow(SavedFlow flow) async {
final inputs = await _FlowInputDialog.show(context, flow);
if (inputs == null) return;
if (!mounted) return;
showDialog<void>(
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<List<SavedFlow>>(
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 <name> <flow.yaml>` 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<Map<String, String>?> show(
BuildContext context,
SavedFlow flow,
) {
return showDialog<Map<String, String>>(
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<String, String> _parsePairs() {
final out = <String, String>{};
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<String, String> inputs;
const _FlowRunDialog({required this.flow, required this.inputs});
@override
State<_FlowRunDialog> createState() => _FlowRunDialogState();
}
class _FlowRunDialogState extends State<_FlowRunDialog> {
late final Future<Map<String, String>> _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<Map<String, String>>(
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'),
),
],
);
}
}

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.7.3 version: 0.8.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -6,7 +6,7 @@ import 'package:fai_studio/data/hub.dart';
import 'package:fai_studio/main.dart'; import 'package:fai_studio/main.dart';
void main() { void main() {
testWidgets('Studio shell shows the four destinations', testWidgets('Studio shell shows the five destinations',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const StudioApp(initialThemeMode: ThemeModeValue.system), const StudioApp(initialThemeMode: ThemeModeValue.system),
@ -15,7 +15,8 @@ void main() {
expect(find.text('F∆I Studio'), findsOneWidget); expect(find.text('F∆I Studio'), findsOneWidget);
expect(find.text('Doctor'), findsWidgets); expect(find.text('Doctor'), findsWidgets);
expect(find.text('Modules'), findsWidgets); expect(find.text('Modules'), findsWidgets);
expect(find.text('Audit'), findsWidgets); expect(find.text('Flows'), findsOneWidget);
expect(find.text('Approvals'), findsWidgets); expect(find.text('Audit'), findsOneWidget);
expect(find.text('Approvals'), findsOneWidget);
}); });
} }