feat(setup): first-run gate, hub-first preview, honest wording — setup before the app
Some checks are pending
Security / Security check (push) Waiting to run

Stefan's live findings, all four addressed at the root:

- 'In 3 Fragen loslegen' read like ad copy → the entry is now plainly
  'Einrichtung starten' / 'Start setup'.
- The setup button sat permanently on the Welcome page of a running
  app ('setup after the app runs is backwards' — reported twice). A
  fresh install now starts INSIDE the setup: SetupGateScreen hosts
  the wizard embedded as the page (new embedded/onFinished modes on
  GuidedSetupDialog), with an explicit 'Später einrichten' skip.
  Welcome loses the setup button entirely and stays a calm intro.
- Re-running the setup later lives in Settings → General ('Run setup
  again…'), the single post-first-run home.
- 'You must grant access first and only then see what will be done':
  the preview used to spawn the chain CLI, whose first run could pop
  the macOS permission prompt BEFORE the plan was ever shown. The
  preview now calls the new PlanSetup RPC over the live hub
  connection (no subprocess, nothing granted); the CLI remains only
  a fallback when no hub is reachable — and applying stays the
  explicit, separate step.

Widget tests: gate hosts the wizard + skip/cancel leave it; CLI-path
tests drive the fallback through the new hub-preview test seam.
Suite 76 green, analyze clean.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-17 00:59:22 +02:00
parent 06f023aada
commit 065939be74
13 changed files with 377 additions and 62 deletions

View file

@ -15,6 +15,7 @@ import '../pages/welcome.dart' show showFaiDoc;
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'chain_error_box.dart';
import 'guided_setup_dialog.dart';
import 'chain_pill.dart';
import 'chain_system_ai_editor.dart';
import 'hub_auth_policy_panel.dart';
@ -572,6 +573,26 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
],
const SizedBox(height: ChainSpace.xl),
const _DefaultScopePanel(),
const SizedBox(height: ChainSpace.xl),
// Re-run the guided setup. This is the ONLY place it lives
// after first run the first-run gate owns the fresh-install
// case, and Welcome stays a calm introduction instead of a
// permanent setup surface.
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: () => GuidedSetupDialog.show(context),
icon: const Icon(Icons.auto_fix_high, size: 18),
label: Text(l.settingsRunSetup),
),
),
const SizedBox(height: 4),
Text(
l.settingsRunSetupHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
];
}

View file

@ -109,12 +109,29 @@ class GuidedSetupDialog extends StatefulWidget {
@visibleForTesting
final bool debugSkipAiProbe;
/// Test seam: replaces the hub-first plan preview (a live gRPC
/// call) so widget tests can drive the CLI fallback or return a
/// canned plan without a hub.
@visibleForTesting
static Future<Map<String, dynamic>> Function()? debugPlanViaHubOverride;
/// Render as page content instead of an [AlertDialog] used by
/// the first-run gate, where the wizard IS the screen.
final bool embedded;
/// Called instead of popping a dialog route when the wizard is
/// [embedded] (the gate owns what comes next). Also invoked after
/// a completed setup so the gate can enter the app.
final VoidCallback? onFinished;
const GuidedSetupDialog({
super.key,
this.shell,
this.debugPlan,
this.debugApplied = false,
this.debugSkipAiProbe = false,
this.embedded = false,
this.onFinished,
});
static Future<void> show(BuildContext context) {
@ -309,6 +326,33 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_busy = true;
_plan = null;
});
// Preview order matters for trust: try the LIVE HUB first a
// pure PlanSetup RPC over the existing connection, no subprocess.
// The CLI fallback (hub down, first run without a daemon) spawns
// `chain`, which on macOS can be the app's very first file-system
// touch and pop a permission prompt; that must never happen
// BEFORE the operator has seen the plan when a hub is available.
try {
final plan = GuidedSetupDialog.debugPlanViaHubOverride != null
? await GuidedSetupDialog.debugPlanViaHubOverride!()
: await HubService.instance.planSetup(
scenario: _scenario,
intent: _intent,
target: _target,
requireApproval: _requireApproval,
dataMustStayLocal: _dataLocal,
allowUnsignedModules: _allowUnsigned,
);
if (!mounted) return;
setState(() {
_busy = false;
_plan = plan;
_step = _totalSteps; // review
});
return;
} catch (_) {
// Hub unreachable / RPC unavailable fall through to the CLI.
}
final path = _writeAnswers();
final r = await SystemActions.chainInit(['--answers', path, '--plan-json']);
if (!mounted) return;
@ -536,6 +580,18 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
}
}
/// Leave the wizard: pop the dialog route, or embedded full
/// screen in the first-run gate, where there is no route to pop
/// hand control back to the gate.
void _close() {
final onFinished = widget.onFinished;
if (onFinished != null) {
onFinished();
} else {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
@ -545,17 +601,43 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
: _showReflection
? l.setupReflectionTitle
: _stepTitle(l);
final body = SingleChildScrollView(
child: reviewing
? _reviewStep(l)
: _showReflection
? _reflectionView(l)
: _answerStep(l),
);
if (widget.embedded) {
// First-run gate: same content as the dialog, hosted as a
// full page instead of a modal over an app that isn't set
// up yet ("setup after the app runs" reads backwards).
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.headlineSmall),
const SizedBox(height: ChainSpace.md),
Flexible(child: body),
const SizedBox(height: ChainSpace.md),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final a in _actions(l)) ...[
const SizedBox(width: ChainSpace.sm),
a,
],
],
),
],
);
}
return AlertDialog(
title: Text(title),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: reviewing
? _reviewStep(l)
: _showReflection
? _reflectionView(l)
: _answerStep(l),
),
child: body,
),
actions: _actions(l),
);
@ -901,7 +983,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_actionRow(
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_close();
widget.shell?.navigateTo('flows');
},
icon: const Icon(Icons.account_tree_outlined, size: 18),
@ -1027,7 +1109,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
if (_applied) {
return [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: _close,
child: Text(l.guidedSetupClose),
),
];
@ -1053,7 +1135,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
return [
TextButton(
onPressed: _step == 0
? () => Navigator.of(context).pop()
? _close
: () => setState(() => _step -= 1),
child: Text(_step == 0 ? l.guidedSetupCancel : l.guidedSetupBack),
),