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

@ -4,7 +4,7 @@
/// Studio's own build version. Bump on every UI release so the
/// running app self-identifies.
const String kStudioVersion = '0.80.0';
const String kStudioVersion = '0.81.0';
const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -79,34 +79,42 @@ class HubService {
/// null when it cannot be determined (then we keep the default).
static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() {
try {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'];
final home =
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null || home.isEmpty) return null;
final sep = Platform.pathSeparator;
final base = '$home$sep.chain';
final ccFile = File('$base${sep}current-channel');
final channel =
ccFile.existsSync() ? ccFile.readAsStringSync().trim() : 'local';
final channel = ccFile.existsSync()
? ccFile.readAsStringSync().trim()
: 'local';
if (channel.isEmpty) return null;
// Prefer the actual bound endpoint the daemon wrote.
final epFile = File('$base${sep}run$sep$channel.endpoint');
if (epFile.existsSync()) {
final raw =
epFile.readAsStringSync().trim().replaceFirst(RegExp(r'^\w+://'), '');
final raw = epFile.readAsStringSync().trim().replaceFirst(
RegExp(r'^\w+://'),
'',
);
final i = raw.lastIndexOf(':');
if (i > 0) {
final host = raw.substring(0, i);
final port = int.tryParse(raw.substring(i + 1));
if (port != null) {
return (channel: channel, endpoint: HubEndpoint(host: host, port: port));
return (
channel: channel,
endpoint: HubEndpoint(host: host, port: port),
);
}
}
}
// Fall back to the channel's well-known default port.
return (
channel: channel,
endpoint:
HubEndpoint(host: '127.0.0.1', port: _channelPorts[channel] ?? 50051),
endpoint: HubEndpoint(
host: '127.0.0.1',
port: _channelPorts[channel] ?? 50051,
),
);
} catch (_) {
return null;
@ -332,13 +340,12 @@ class HubService {
required String url,
String bearerEnv = '',
String pinnedPubkeyPem = '',
}) =>
_client.addStore(
name: name,
url: url,
bearerEnv: bearerEnv,
pinnedPubkeyPem: pinnedPubkeyPem,
);
}) => _client.addStore(
name: name,
url: url,
bearerEnv: bearerEnv,
pinnedPubkeyPem: pinnedPubkeyPem,
);
/// Drop a configured store by name (the bundled seed cannot be removed).
Future<RemoveStoreResponse> removeStore(String name) =>
@ -1156,6 +1163,7 @@ class HubService {
decidedBy: e.decidedBy,
reason: e.reason,
project: e.project,
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
),
)
.toList();
@ -1184,8 +1192,7 @@ class HubService {
/// Cancel a running/pending detached invocation. Returns true when
/// it was signalled, false if already finished or unknown.
Future<bool> cancelDetachedRun(String id) =>
_client.cancelInvocation(id);
Future<bool> cancelDetachedRun(String id) => _client.cancelInvocation(id);
/// Federation satellites currently connected to this hub
/// (primary side). Empty when none are connected.
@ -1241,9 +1248,7 @@ class HubService {
/// error the hint simply stays away then.
Future<UpdateStatus?> checkHubUpdate() async {
try {
final r = await _client.checkUpdate().timeout(
const Duration(seconds: 3),
);
final r = await _client.checkUpdate().timeout(const Duration(seconds: 3));
if (!r.updateAvailable) return null;
return UpdateStatus(
channel: r.channel,
@ -1264,9 +1269,9 @@ class HubService {
Future<String> hubVersion() async {
try {
final r = await _client.checkUpdate().timeout(
const Duration(seconds: 2),
onTimeout: () => CheckUpdateResponse(),
);
const Duration(seconds: 2),
onTimeout: () => CheckUpdateResponse(),
);
return r.localVersion;
} catch (_) {
return '';
@ -1326,8 +1331,7 @@ class HubService {
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
services: services
.map(
(s) =>
ServiceEntry(
(s) => ServiceEntry(
name: s.name,
endpoint: s.endpoint,
tags: s.tags,
@ -1775,6 +1779,11 @@ class ApprovalRecord {
/// Project slug the requesting run was stamped with.
final String project;
/// The flow-execution (run) id this approval belongs to. Ties the
/// pause back to the concrete run and its event timeline. Null on
/// pre-0.21 hubs that did not carry the field over the wire.
final String? flowExecution;
const ApprovalRecord({
required this.id,
required this.flowName,
@ -1788,6 +1797,7 @@ class ApprovalRecord {
required this.decidedBy,
required this.reason,
this.project = '',
this.flowExecution,
});
}

View file

@ -1101,6 +1101,13 @@
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
"approvalsIntroHelp": "Hier warten pausierte Flows auf Ihre Entscheidung. Jede Karte zeigt, welcher Flow an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt den Flow fort, Ablehnen stoppt ihn mit Ihrer Begründung.",
"approvalsOriginLabel": "HERKUNFT",
"approvalsOriginProject": "Projekt",
"approvalsOriginRequested": "Angefordert",
"approvalsOriginRun": "Lauf",
"approvalsApproveTooltip": "Setzt den Flow an diesem Schritt fort",
"approvalsRejectTooltip": "Stoppt den Flow — mit Ihrer Begründung",
"approvalsApproveButton": "Freigeben",
"approvalsRejectButton": "Ablehnen",
"approvalsRejectDialogTitle": "Freigabe ablehnen",
@ -1822,6 +1829,8 @@
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
"runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.",
"runsHubTooOldButton": "Diagnose öffnen",
"runsHubUpdateButton": "Hub jetzt aktualisieren",
"runsHubUpdateStarted": "Hub wird aktualisiert…",
"runsLoadFailedTitle": "Läufe konnten nicht geladen werden",
"runsCancelButton": "Abbrechen",
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",

View file

@ -1119,6 +1119,13 @@
"approvalsNoDataConfirmAction": "Approve anyway",
"approvalsRequestFallback": "Approval required for this step",
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
"approvalsIntroHelp": "Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.",
"approvalsOriginLabel": "ORIGIN",
"approvalsOriginProject": "Project",
"approvalsOriginRequested": "Requested",
"approvalsOriginRun": "Run",
"approvalsApproveTooltip": "Resumes the flow at this step",
"approvalsRejectTooltip": "Stops the flow — with your reason",
"@approvalsFlowStepMeta": {
"placeholders": {
"flow": { "type": "String" },
@ -1861,6 +1868,8 @@
"runsHubTooOldTitle": "This view needs a newer hub version",
"runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.",
"runsHubTooOldButton": "Open Doctor",
"runsHubUpdateButton": "Update hub now",
"runsHubUpdateStarted": "Updating hub…",
"runsLoadFailedTitle": "Runs could not be loaded",
"runsCancelButton": "Cancel",
"runsCancelSignalled": "Cancel signalled for {flow}.",

View file

@ -3554,6 +3554,48 @@ abstract class AppLocalizations {
/// **'Flow: {flow} · Step: {step}'**
String approvalsFlowStepMeta(String flow, String step);
/// No description provided for @approvalsIntroHelp.
///
/// In en, this message translates to:
/// **'Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.'**
String get approvalsIntroHelp;
/// No description provided for @approvalsOriginLabel.
///
/// In en, this message translates to:
/// **'ORIGIN'**
String get approvalsOriginLabel;
/// No description provided for @approvalsOriginProject.
///
/// In en, this message translates to:
/// **'Project'**
String get approvalsOriginProject;
/// No description provided for @approvalsOriginRequested.
///
/// In en, this message translates to:
/// **'Requested'**
String get approvalsOriginRequested;
/// No description provided for @approvalsOriginRun.
///
/// In en, this message translates to:
/// **'Run'**
String get approvalsOriginRun;
/// No description provided for @approvalsApproveTooltip.
///
/// In en, this message translates to:
/// **'Resumes the flow at this step'**
String get approvalsApproveTooltip;
/// No description provided for @approvalsRejectTooltip.
///
/// In en, this message translates to:
/// **'Stops the flow — with your reason'**
String get approvalsRejectTooltip;
/// No description provided for @approvalsApproveButton.
///
/// In en, this message translates to:
@ -5683,6 +5725,18 @@ abstract class AppLocalizations {
/// **'Open Doctor'**
String get runsHubTooOldButton;
/// No description provided for @runsHubUpdateButton.
///
/// In en, this message translates to:
/// **'Update hub now'**
String get runsHubUpdateButton;
/// No description provided for @runsHubUpdateStarted.
///
/// In en, this message translates to:
/// **'Updating hub…'**
String get runsHubUpdateStarted;
/// No description provided for @runsLoadFailedTitle.
///
/// In en, this message translates to:

View file

@ -2058,6 +2058,28 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Flow: $flow · Schritt: $step';
}
@override
String get approvalsIntroHelp =>
'Hier warten pausierte Flows auf Ihre Entscheidung. Jede Karte zeigt, welcher Flow an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt den Flow fort, Ablehnen stoppt ihn mit Ihrer Begründung.';
@override
String get approvalsOriginLabel => 'HERKUNFT';
@override
String get approvalsOriginProject => 'Projekt';
@override
String get approvalsOriginRequested => 'Angefordert';
@override
String get approvalsOriginRun => 'Lauf';
@override
String get approvalsApproveTooltip => 'Setzt den Flow an diesem Schritt fort';
@override
String get approvalsRejectTooltip => 'Stoppt den Flow — mit Ihrer Begründung';
@override
String get approvalsApproveButton => 'Freigeben';
@ -3366,6 +3388,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get runsHubTooOldButton => 'Diagnose öffnen';
@override
String get runsHubUpdateButton => 'Hub jetzt aktualisieren';
@override
String get runsHubUpdateStarted => 'Hub wird aktualisiert…';
@override
String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden';

View file

@ -2062,6 +2062,28 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Flow: $flow · Step: $step';
}
@override
String get approvalsIntroHelp =>
'Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.';
@override
String get approvalsOriginLabel => 'ORIGIN';
@override
String get approvalsOriginProject => 'Project';
@override
String get approvalsOriginRequested => 'Requested';
@override
String get approvalsOriginRun => 'Run';
@override
String get approvalsApproveTooltip => 'Resumes the flow at this step';
@override
String get approvalsRejectTooltip => 'Stops the flow — with your reason';
@override
String get approvalsApproveButton => 'Approve';
@ -3357,6 +3379,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get runsHubTooOldButton => 'Open Doctor';
@override
String get runsHubUpdateButton => 'Update hub now';
@override
String get runsHubUpdateStarted => 'Updating hub…';
@override
String get runsLoadFailedTitle => 'Runs could not be loaded';

View file

@ -133,7 +133,6 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
}
}
Future<void> _reject(ApprovalRecord a) async {
final l = AppLocalizations.of(context)!;
final reason = await _promptReason(context);
@ -380,46 +379,66 @@ class _PendingList extends StatelessWidget {
final selected = pending
.where((a) => selectedIds.contains(a.id))
.toList();
return Stack(
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListView.separated(
padding: EdgeInsets.fromLTRB(
// A calm one-line explainer so a first-time reviewer knows
// what this inbox is and what Approve / Reject actually do,
// without opening the doc sheet.
Padding(
padding: const EdgeInsets.fromLTRB(
ChainSpace.xl,
ChainSpace.lg,
ChainSpace.xl,
ChainSpace.xl,
// Keep the last card clear of the floating
// batch action bar.
selected.isEmpty ? ChainSpace.xl : 96.0,
0,
),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
final isSelected = selectedIds.contains(a.id);
return _ApprovalCard(
approval: a,
selected: isSelected,
onToggleSelected: () => onToggle(a.id),
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
child: ChainInlineHelp(text: l.approvalsIntroHelp),
),
if (selected.isNotEmpty)
Positioned(
left: ChainSpace.xl,
right: ChainSpace.xl,
bottom: ChainSpace.lg,
child: _BatchActionBar(
selectedCount: selected.length,
totalCount: pending.length,
inFlight: batchInFlight,
onSelectAll: () => onSelectAll(pending),
onClear: onClearSelection,
onApprove: () => onBatchApprove(selected),
onReject: () => onBatchReject(selected),
),
Expanded(
child: Stack(
children: [
ListView.separated(
padding: EdgeInsets.fromLTRB(
ChainSpace.xl,
ChainSpace.md,
ChainSpace.xl,
// Keep the last card clear of the floating
// batch action bar.
selected.isEmpty ? ChainSpace.xl : 96.0,
),
itemCount: pending.length,
separatorBuilder: (_, _) =>
const SizedBox(height: ChainSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
final isSelected = selectedIds.contains(a.id);
return _ApprovalCard(
approval: a,
selected: isSelected,
onToggleSelected: () => onToggle(a.id),
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
),
if (selected.isNotEmpty)
Positioned(
left: ChainSpace.xl,
right: ChainSpace.xl,
bottom: ChainSpace.lg,
child: _BatchActionBar(
selectedCount: selected.length,
totalCount: pending.length,
inFlight: batchInFlight,
onSelectAll: () => onSelectAll(pending),
onClear: onClearSelection,
onApprove: () => onBatchApprove(selected),
onReject: () => onBatchReject(selected),
),
),
],
),
),
],
);
},
@ -582,6 +601,22 @@ class _ApprovalCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final metaStyle = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
// One origin fact per line: small leading icon, the value beside
// it. Keeps the "where does this come from" block scannable.
Widget originLine(IconData icon, Widget child) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 13, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(child: child),
],
),
);
return ChainCard(
accentTop: true,
child: Column(
@ -623,26 +658,54 @@ class _ApprovalCard extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
// Technical context, demoted to a small metadata line.
Row(
children: [
Icon(
Icons.account_tree_outlined,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(height: ChainSpace.md),
// Origin block the "which flow, which step, which run, in
// which project, requested when" a reviewer needs to place
// the request in context. The run id is copyable so it can
// be cross-referenced against the event timeline on the
// Audit page.
Text(
l.approvalsOriginLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
originLine(
Icons.account_tree_outlined,
Text(
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
style: metaStyle,
),
),
if (approval.project.isNotEmpty)
originLine(
Icons.folder_outlined,
Text(
'${l.approvalsOriginProject}: ${approval.project}',
style: metaStyle,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
originLine(
Icons.schedule,
Text(
'${l.approvalsOriginRequested}: '
'${_formatTimestamp(approval.createdAt.toLocal())}',
style: metaStyle,
),
),
if (approval.flowExecution != null)
originLine(
Icons.tag_outlined,
SelectableText(
'${l.approvalsOriginRun}: ${approval.flowExecution}',
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: ChainSpace.md),
Padding(
padding: const EdgeInsets.only(bottom: 4),
@ -720,22 +783,28 @@ class _ApprovalCard extends StatelessWidget {
),
),
const Spacer(),
OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close, size: 16),
label: Text(l.approvalsRejectButton),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.5),
Tooltip(
message: l.approvalsRejectTooltip,
child: OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close, size: 16),
label: Text(l.approvalsRejectButton),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.5),
),
),
),
),
const SizedBox(width: ChainSpace.sm),
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check, size: 16),
label: Text(l.approvalsApproveButton),
Tooltip(
message: l.approvalsApproveTooltip,
child: FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check, size: 16),
label: Text(l.approvalsApproveButton),
),
),
],
),
@ -893,6 +962,18 @@ class _HistoryDialog extends StatelessWidget {
AppLocalizations.of(context)!.approvalsDialogCreated,
_formatTimestamp(record.createdAt.toLocal()),
),
if (record.project.isNotEmpty)
_kv(
theme,
AppLocalizations.of(context)!.approvalsOriginProject,
record.project,
),
if (record.flowExecution != null)
_kv(
theme,
AppLocalizations.of(context)!.approvalsOriginRun,
record.flowExecution!,
),
if (record.reason.isNotEmpty)
_kv(
theme,

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(