The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.
Also in this rebuild:
* Stopped sealed areas ask before starting ("Start area X?") —
a context switch must never boot a hub daemon as a click
side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
reveal client identities) and links to the Settings toggle
(Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
use; sealed names honour the privacy setting — while hidden,
the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
cause ("No runs in project X" + show-all action) instead of
claiming the feature is off.
Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
517 lines
18 KiB
Dart
517 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
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;
|
|
import '../theme/tokens.dart';
|
|
import '../widgets/widgets.dart';
|
|
import 'welcome.dart' show showFaiDoc;
|
|
|
|
/// Detached-runs monitor (T3 parity): the invocations submitted with
|
|
/// `detach: true`, with a cancel button while they are still running.
|
|
/// Workspace-scoped like Audit and Approvals. Detached invocations are
|
|
/// opt-in (`detached.enabled`), so an empty list is the normal case
|
|
/// for most operators — the empty state says so plainly.
|
|
class RunsPage extends StatefulWidget {
|
|
const RunsPage({super.key});
|
|
|
|
@override
|
|
State<RunsPage> createState() => _RunsPageState();
|
|
}
|
|
|
|
/// Why the runs monitor could not load its list. The page used to
|
|
/// fold every failure into "hub not reachable", which contradicted
|
|
/// the sidebar's green "connected" dot whenever the hub answered
|
|
/// with an RPC-level error (usertest finding: a 0.21 hub without
|
|
/// the ListInvocations RPC). Top-level so the unit test drives the
|
|
/// classification directly.
|
|
enum RunsLoadIssue {
|
|
/// The hub itself cannot be reached (socket-level failure or
|
|
/// gRPC UNAVAILABLE / DEADLINE_EXCEEDED).
|
|
unreachable,
|
|
|
|
/// The hub answered, but its version predates the
|
|
/// ListInvocations RPC (gRPC UNIMPLEMENTED) — connected, just
|
|
/// too old for this view.
|
|
unsupported,
|
|
|
|
/// The hub answered and refused because detached invocations
|
|
/// are switched off (gRPC FAILED_PRECONDITION from the detached
|
|
/// gate) — show the regular feature-off empty state, not a
|
|
/// connection error.
|
|
featureDisabled,
|
|
|
|
/// Anything else — show the friendly error with copyable detail.
|
|
other,
|
|
}
|
|
|
|
/// Classify a [HubService.listDetachedRuns] failure into the view
|
|
/// state the page should render. The runs-specific part is the
|
|
/// detached feature gate; everything else delegates to the shared
|
|
/// page-load classification.
|
|
RunsLoadIssue classifyRunsLoadError(Object error) {
|
|
if (grpcCodeOf(error) == 9) {
|
|
// FAILED_PRECONDITION — the hub's detached gate.
|
|
final msg = (grpcMessageOf(error) ?? '').toLowerCase();
|
|
return msg.contains('detached')
|
|
? RunsLoadIssue.featureDisabled
|
|
: RunsLoadIssue.other;
|
|
}
|
|
return switch (classifyHubLoadError(error)) {
|
|
HubLoadIssue.unreachable => RunsLoadIssue.unreachable,
|
|
HubLoadIssue.unsupported => RunsLoadIssue.unsupported,
|
|
HubLoadIssue.other => RunsLoadIssue.other,
|
|
};
|
|
}
|
|
|
|
/// The error rendition of the runs monitor — public + callback-driven
|
|
/// so the widget test can pump each variant without a live hub.
|
|
class RunsLoadErrorView extends StatelessWidget {
|
|
final Object error;
|
|
final RunsLoadIssue issue;
|
|
|
|
/// Opens the runs guide (the feature-off empty state's CTA).
|
|
final VoidCallback onOpenGuide;
|
|
|
|
/// 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
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
switch (issue) {
|
|
case RunsLoadIssue.featureDisabled:
|
|
// Same story as the regular feature-off empty state: the
|
|
// hub is fine, the operator just has not enabled the
|
|
// feature — never claim "not reachable" here.
|
|
return ChainEmptyState(
|
|
icon: Icons.rocket_launch_outlined,
|
|
title: l.runsEmptyTitle,
|
|
hint: l.runsEmptyHint,
|
|
action: OutlinedButton.icon(
|
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
|
label: Text(l.runsEmptyGuideButton),
|
|
onPressed: onOpenGuide,
|
|
),
|
|
);
|
|
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: 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:
|
|
return ChainEmptyState(
|
|
icon: Icons.cloud_off_outlined,
|
|
iconColor: theme.colorScheme.error,
|
|
title: l.hubUnreachable,
|
|
hint: l.hubUnreachableHint,
|
|
);
|
|
case RunsLoadIssue.other:
|
|
return ChainEmptyState(
|
|
icon: Icons.error_outline,
|
|
iconColor: theme.colorScheme.error,
|
|
title: l.runsLoadFailedTitle,
|
|
action: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: ChainErrorBox(error: error, isError: true),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _RunsPageState extends State<RunsPage> {
|
|
List<DetachedRun> _runs = const [];
|
|
bool _detachedEnabled = false;
|
|
Object? _error;
|
|
RunsLoadIssue _issue = RunsLoadIssue.other;
|
|
bool _loaded = false;
|
|
Timer? _poll;
|
|
bool _updatingHub = false;
|
|
final Set<String> _cancelling = <String>{};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
Workspace.instance.addListener(_refresh);
|
|
Workspace.instance.ensureLoaded();
|
|
_refresh();
|
|
// A run's phase changes without user action, so poll — same 2 s
|
|
// tick the Audit page uses.
|
|
_poll = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
Workspace.instance.removeListener(_refresh);
|
|
_poll?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
final ws = Workspace.instance;
|
|
// Paused during a sealed-area switch: the hub client may already
|
|
// point at the other hub while this page still renders the old
|
|
// context — fetching now would show data under the wrong marking.
|
|
// The end-of-switch notify re-runs this listener with fresh data.
|
|
if (ws.switching) return;
|
|
final epoch = ws.contextEpoch;
|
|
try {
|
|
final snapshot = await HubService.instance.listDetachedRuns(
|
|
project: Workspace.instance.activeSlug,
|
|
);
|
|
if (!mounted || epoch != ws.contextEpoch) return;
|
|
setState(() {
|
|
_runs = snapshot.runs;
|
|
_detachedEnabled = snapshot.enabled;
|
|
_error = null;
|
|
_loaded = true;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted || epoch != ws.contextEpoch) return;
|
|
setState(() {
|
|
_error = e;
|
|
_issue = classifyRunsLoadError(e);
|
|
_loaded = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Display name of the filtered project ("General" localized).
|
|
String _activeProjectLabel(AppLocalizations l) {
|
|
final ws = Workspace.instance;
|
|
final p = ws.active;
|
|
if (p == null) return ws.activeSlug;
|
|
return p.slug == 'general' ? l.workspaceDefaultProject : p.name;
|
|
}
|
|
|
|
Future<void> _cancel(DetachedRun run) async {
|
|
final l = AppLocalizations.of(context)!;
|
|
setState(() => _cancelling.add(run.id));
|
|
try {
|
|
final ok = await HubService.instance.cancelDetachedRun(run.id);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
ok ? l.runsCancelSignalled(run.flowName) : l.runsCancelTooLate,
|
|
),
|
|
),
|
|
);
|
|
await _refresh();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showChainErrorSnack(context, 'runs.cancel', e);
|
|
} finally {
|
|
if (mounted) setState(() => _cancelling.remove(run.id));
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
appBar: AppBar(
|
|
title: Text(l.runsTitle),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline, size: 18),
|
|
tooltip: l.helpTooltip,
|
|
onPressed: () => showFaiDoc(context, 'runs'),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 18),
|
|
tooltip: l.runsReloadTooltip,
|
|
onPressed: _refresh,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
],
|
|
),
|
|
body: !_loaded
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _error != null && _runs.isEmpty
|
|
? RunsLoadErrorView(
|
|
error: _error!,
|
|
issue: _issue,
|
|
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
|
onOpenDoctor: () =>
|
|
StudioShellState.of(context)?.navigateTo('doctor'),
|
|
onUpdateHub: _updateHub,
|
|
updating: _updatingHub,
|
|
)
|
|
: _runs.isEmpty
|
|
// With an active project filter the honest cause is the
|
|
// filter, not the feature — name the project and offer the
|
|
// way out instead of the enable-the-feature guide
|
|
// (persona finding: the old hint claimed a wrong cause).
|
|
? (_detachedEnabled && !Workspace.instance.isAll
|
|
? ChainEmptyState(
|
|
icon: Icons.rocket_launch_outlined,
|
|
title: l.runsEmptyFilteredTitle(_activeProjectLabel(l)),
|
|
hint: l.runsEmptyFilteredHint,
|
|
action: OutlinedButton.icon(
|
|
icon: const Icon(Icons.grid_view_outlined, size: 16),
|
|
label: Text(l.runsEmptyShowAll),
|
|
onPressed: () => Workspace.instance.setActive(''),
|
|
),
|
|
)
|
|
: ChainEmptyState(
|
|
icon: Icons.rocket_launch_outlined,
|
|
title: l.runsEmptyTitle,
|
|
// The hub reports whether the operator enabled the
|
|
// feature — never claim "switched off" while it is
|
|
// on and there simply are no runs yet (usertest
|
|
// finding).
|
|
hint: _detachedEnabled
|
|
? l.runsEmptyEnabledHint
|
|
: l.runsEmptyHint,
|
|
// The guide carries the plain-language explanation
|
|
// plus the exact operator steps (config snippet) —
|
|
// a click target instead of a raw config key in
|
|
// the hint.
|
|
action: OutlinedButton.icon(
|
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
|
label: Text(l.runsEmptyGuideButton),
|
|
onPressed: () => showFaiDoc(context, 'runs'),
|
|
),
|
|
))
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.all(ChainSpace.lg),
|
|
itemCount: _runs.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.sm),
|
|
itemBuilder: (context, i) => _RunRow(
|
|
run: _runs[i],
|
|
cancelling: _cancelling.contains(_runs[i].id),
|
|
onCancel: () => _cancel(_runs[i]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RunRow extends StatelessWidget {
|
|
final DetachedRun run;
|
|
final bool cancelling;
|
|
final VoidCallback onCancel;
|
|
|
|
const _RunRow({
|
|
required this.run,
|
|
required this.cancelling,
|
|
required this.onCancel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final (tone, label) = _phaseChip(l);
|
|
return ChainCard(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
run.flowName.isEmpty ? run.id : run.flowName,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
ChainPill(label: label, tone: tone),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_subtitle(l),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (run.error.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
SelectableText(
|
|
run.error,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (run.isCancellable)
|
|
cancelling
|
|
? const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
: OutlinedButton.icon(
|
|
onPressed: onCancel,
|
|
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
|
label: Text(l.runsCancelButton),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _subtitle(AppLocalizations l) {
|
|
final parts = <String>[];
|
|
if (run.currentStep.isNotEmpty) {
|
|
parts.add(l.runsCurrentStep(run.currentStep));
|
|
}
|
|
if (run.project.isNotEmpty) parts.add(run.project);
|
|
parts.add(run.id);
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
(ChainPillTone, String) _phaseChip(AppLocalizations l) {
|
|
switch (run.phase) {
|
|
case DetachedPhase.pending:
|
|
return (ChainPillTone.neutral, l.runsPhasePending);
|
|
case DetachedPhase.running:
|
|
return (ChainPillTone.accent, l.runsPhaseRunning);
|
|
case DetachedPhase.succeeded:
|
|
return (ChainPillTone.success, l.runsPhaseSucceeded);
|
|
case DetachedPhase.failed:
|
|
return (ChainPillTone.danger, l.runsPhaseFailed);
|
|
case DetachedPhase.cancelled:
|
|
return (ChainPillTone.warning, l.runsPhaseCancelled);
|
|
case DetachedPhase.unknown:
|
|
return (ChainPillTone.neutral, l.runsPhaseUnknown);
|
|
}
|
|
}
|
|
}
|