feat(approvals,runs): explain approvals in place + one-click hub update (0.81.0)

Approvals: the pending card now shows its full origin — flow, step,
run id (previously dropped at the Dart mapping layer), project, and
requested-at — under an ORIGIN heading, led by a one-line intro strip
that says what the inbox is and what Approve/Reject do. Approve/Reject
buttons carry tooltips; the history dialog gains project + run id.
Fixes the approvals doc drift (title/details/reviewer ->
prompt/show/timeout_seconds). Guard: approvals_origin_test renders the
card via the hermetic fake hub and pins every origin fact.

Runs: the "hub too old" state now leads with an in-place update button
(same `chain update apply` path as the Diagnose page), the Diagnose
deeplink demoted to secondary, with a CLI-absent fallback. Guard: two
new RunsLoadErrorView widget tests.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-26 15:50:41 +02:00
parent 14f824b8ef
commit 28f6fe1a9a
16 changed files with 613 additions and 115 deletions

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/friendly_error.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
@ -78,16 +79,28 @@ class RunsLoadErrorView extends StatelessWidget {
/// Opens the runs guide (the feature-off empty state's CTA).
final VoidCallback onOpenGuide;
/// Opens the doctor page (the "hub too old" state's CTA, where
/// the update banner lives). Null hides the button.
/// Opens the doctor page (the "hub too old" state's secondary CTA,
/// and the fallback when the update cannot run from here). Null
/// hides the button.
final VoidCallback? onOpenDoctor;
/// Applies the hub update in place (the "hub too old" state's
/// primary CTA). Null falls back to the doctor deeplink only
/// keeps the widget test's no-hub path unchanged.
final VoidCallback? onUpdateHub;
/// Whether an update is currently being applied drives the
/// spinner on the primary button.
final bool updating;
const RunsLoadErrorView({
super.key,
required this.error,
required this.issue,
required this.onOpenGuide,
this.onOpenDoctor,
this.onUpdateHub,
this.updating = false,
});
@override
@ -110,16 +123,57 @@ class RunsLoadErrorView extends StatelessWidget {
),
);
case RunsLoadIssue.unsupported:
// The whole point of this state is "your hub is too old"
// so lead with the fix, not with a detour. The primary
// button updates the hub in place (same `chain update apply`
// path as the Diagnose page); "Diagnose öffnen" stays as the
// secondary escape hatch for when there is no local CLI or
// the release host is unreachable.
return ChainEmptyState(
icon: Icons.system_update_alt_outlined,
title: l.runsHubTooOldTitle,
hint: l.runsHubTooOldHint,
action: onOpenDoctor == null
? null
: OutlinedButton.icon(
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: Text(l.runsHubTooOldButton),
onPressed: onOpenDoctor,
action: onUpdateHub == null
? (onOpenDoctor == null
? null
: OutlinedButton.icon(
icon: const Icon(
Icons.health_and_safety_outlined,
size: 16,
),
label: Text(l.runsHubTooOldButton),
onPressed: onOpenDoctor,
))
: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: updating ? null : onUpdateHub,
icon: updating
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt, size: 16),
label: Text(
updating
? l.runsHubUpdateStarted
: l.runsHubUpdateButton,
),
),
if (onOpenDoctor != null) ...[
const SizedBox(height: ChainSpace.sm),
TextButton.icon(
icon: const Icon(
Icons.health_and_safety_outlined,
size: 16,
),
label: Text(l.runsHubTooOldButton),
onPressed: updating ? null : onOpenDoctor,
),
],
],
),
);
case RunsLoadIssue.unreachable:
@ -150,6 +204,7 @@ class _RunsPageState extends State<RunsPage> {
RunsLoadIssue _issue = RunsLoadIssue.other;
bool _loaded = false;
Timer? _poll;
bool _updatingHub = false;
final Set<String> _cancelling = <String>{};
@override
@ -214,6 +269,54 @@ class _RunsPageState extends State<RunsPage> {
}
}
/// Apply the hub update in place from the "hub too old" state.
/// Uses the same `chain update apply --channel <c>` path as the
/// Diagnose page. When there is no local CLI, or the release host
/// offers no update, we hand off to the Diagnose page, which
/// already renders the offline / locate-binary recovery cleanly
/// instead of guessing here.
Future<void> _updateHub() async {
final shell = StudioShellState.of(context);
if (!SystemActions.chainBinaryExists()) {
shell?.navigateTo('doctor');
return;
}
setState(() => _updatingHub = true);
try {
final status = await HubService.instance.checkHubUpdate();
if (!mounted) return;
if (status == null) {
// No update on offer, or the release host is unreachable
// the Diagnose page states the exact reason.
setState(() => _updatingHub = false);
shell?.navigateTo('doctor');
return;
}
final r = await SystemActions.chainUpdateApply(status.channel);
if (!mounted) return;
setState(() => _updatingHub = false);
if (r.ok) {
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.doctorApplyDone)));
// The CLI restarts the daemon; the 2 s poll picks up the
// now-supported RPC on its own, but nudge it immediately.
await _refresh();
} else {
showChainErrorSnack(
context,
'runs.update',
(r.stderr.isEmpty ? r.stdout : r.stderr).trim(),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _updatingHub = false);
showChainErrorSnack(context, 'runs.update', e);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -247,6 +350,8 @@ class _RunsPageState extends State<RunsPage> {
onOpenGuide: () => showFaiDoc(context, 'runs'),
onOpenDoctor: () =>
StudioShellState.of(context)?.navigateTo('doctor'),
onUpdateHub: _updateHub,
updating: _updatingHub,
)
: _runs.isEmpty
? ChainEmptyState(