diff --git a/CHANGELOG.md b/CHANGELOG.md index 85798e9..c452fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ lockstep. ## Unreleased +### Changed (0.81.0) + +- **Approvals explain themselves.** The pending approval card now + leads with a one-line intro strip (what this inbox is; that + Approve resumes the flow and Reject stops it with a reason) and + carries a proper ORIGIN block: flow, step, **run id** (previously + dropped at the Dart mapping layer), project, and requested-at — + so a reviewer can see where a request comes from and why. The run + id is copyable; the same facts appear in the history dialog. + Approve/Reject buttons gained tooltips. Fixed the approvals doc + drift — the guide showed `title`/`details`/`reviewer`, which the + engine never accepted; it now documents `prompt`/`show`/ + `timeout_seconds`. Guard: `approvals_origin_test` renders the card + through the hermetic fake hub and pins every origin fact. +- **Update the hub straight from the runs page.** The "this view + needs a newer hub version" state now leads with an in-place + *Update hub now* button (the same `chain update apply` path as the + Diagnose page) instead of only deep-linking to Diagnose; the + Diagnose link stays as the secondary escape hatch, and a missing + local CLI falls back to it. Guard: two new `RunsLoadErrorView` + widget tests. + ### Added (0.80.0) - **Ollama joins the host services.** Ollama-backed store entries diff --git a/assets/docs/approvals.md b/assets/docs/approvals.md index 8a96cf6..e0ee502 100644 --- a/assets/docs/approvals.md +++ b/assets/docs/approvals.md @@ -15,7 +15,9 @@ Add an approval step before: - anything the operator's compliance owner asked you to gate The approval is a recorded event — every approval shows up in -the audit log with the reviewer, decision, and reason. +the audit log with the reviewer, decision, and reason. The +`prompt` is the sentence the reviewer sees; `show` selects the +data put in front of them (a prior step's output). ## How a flow declares one @@ -27,9 +29,8 @@ steps: - id: review use: system.approval@^0 with: - title: "Review the classifier output" - details: "$classify.response" - reviewer: "$inputs.reviewer" + prompt: "Review the classifier output" + show: "$classify.response" timeout_seconds: 600 - id: write @@ -45,8 +46,9 @@ configured `timeout_seconds` elapses. ## How an operator answers one -The Approvals page lists every pending approval. Click an -entry, read the title + details, decide: +The Approvals page lists every pending approval. Each card +shows its origin (flow, step, run, project, requested-at), the +`prompt`, and the `show` data. Read them, then decide: - **Approve** — the flow resumes from this step with the approval's `payload` as the step output. @@ -63,7 +65,7 @@ that reflects how long a reviewer can realistically take. Every approval write hits the audit log: -- approval requested (with the title + details snapshot) +- approval requested (with the prompt + show-data snapshot) - reviewer-decision recorded (with reason on reject) - payload + signature on accept diff --git a/assets/docs/approvals_de.md b/assets/docs/approvals_de.md index af97496..a29afc8 100644 --- a/assets/docs/approvals_de.md +++ b/assets/docs/approvals_de.md @@ -18,7 +18,9 @@ Setze eine Freigabe vor: Die Freigabe ist ein protokolliertes Ereignis — jeder Freigabe-Vorgang landet im Audit-Log mit Reviewer, -Entscheidung und Begründung. +Entscheidung und Begründung. Das `prompt` ist der Satz, den der +Reviewer sieht; `show` wählt die Daten, die ihm vorgelegt +werden (der Output eines vorherigen Steps). ## So deklariert ein Flow eine Freigabe @@ -30,9 +32,8 @@ steps: - id: review use: system.approval@^0 with: - title: "Klassifikations-Output prüfen" - details: "$classify.response" - reviewer: "$inputs.reviewer" + prompt: "Klassifikations-Output prüfen" + show: "$classify.response" timeout_seconds: 600 - id: write @@ -49,8 +50,10 @@ zugehörige Freigabe-Eintrag eine `decision` erhält ## So beantwortet der Operator eine Freigabe -Die Freigaben-Seite listet jede ausstehende Freigabe. Klick -auf einen Eintrag, Titel + Details lesen, entscheiden: +Die Freigaben-Seite listet jede ausstehende Freigabe. Jede +Karte zeigt ihre Herkunft (Flow, Step, Lauf, Projekt, +Anforderungszeit), das `prompt` und die `show`-Daten. Lesen, +dann entscheiden: - **Freigeben** — der Flow läuft ab diesem Step weiter, das `payload` der Freigabe wird zum Step-Output. @@ -69,7 +72,7 @@ realistisch brauchen darf. Jeder Schreibvorgang einer Freigabe landet im Audit-Log: -- Freigabe angefordert (mit Titel + Details-Snapshot) +- Freigabe angefordert (mit Prompt + show-Daten-Snapshot) - Reviewer-Entscheidung aufgezeichnet (mit Begründung bei Ablehnung) - Payload + Signatur bei Freigabe diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index bcbb3ff..0e5af17 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -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)'; diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a53c766..997a590 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -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 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 cancelDetachedRun(String id) => - _client.cancelInvocation(id); + Future 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 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 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, }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6826847..76c16d8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7ff858d..53576c9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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}.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index caff8a4..a33e3e3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ce214cf..e6427ea 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6bc4405..c229ab6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 49e1b2a..f07057f 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -133,7 +133,6 @@ class _ApprovalsPageState extends State { } } - Future _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, diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 8f0ec5c..d841ad2 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -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 { RunsLoadIssue _issue = RunsLoadIssue.other; bool _loaded = false; Timer? _poll; + bool _updatingHub = false; final Set _cancelling = {}; @override @@ -214,6 +269,54 @@ class _RunsPageState extends State { } } + /// Apply the hub update in place from the "hub too old" state. + /// Uses the same `chain update apply --channel ` 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 _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 { onOpenGuide: () => showFaiDoc(context, 'runs'), onOpenDoctor: () => StudioShellState.of(context)?.navigateTo('doctor'), + onUpdateHub: _updateHub, + updating: _updatingHub, ) : _runs.isEmpty ? ChainEmptyState( diff --git a/pubspec.yaml b/pubspec.yaml index 7149725..b79dbb0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.80.0 +version: 0.81.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/approvals_origin_test.dart b/test/approvals_origin_test.dart new file mode 100644 index 0000000..d974b31 --- /dev/null +++ b/test/approvals_origin_test.dart @@ -0,0 +1,90 @@ +// Approvals origin block — a reviewer must be able to see WHERE a +// pending approval comes from (which flow, step, run, project, and +// when it was requested) and WHAT approve / reject will do, without +// leaving the card. The run id in particular used to be dropped at +// the Dart mapping layer; this pins that it reaches the card, next +// to the rest of the origin facts and the plain-language intro. +// +// Runs against the scriptable FakeHubService — never a real hub. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart'; + +import 'support/fake_hub.dart'; + +/// Every rendered Text + SelectableText joined — the run id is a +/// SelectableText (copyable), so a Text-only sweep would miss it. +String _allText(WidgetTester tester) { + final buf = StringBuffer(); + for (final w in tester.widgetList(find.byType(Text))) { + buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? ''); + } + for (final w in tester.widgetList( + find.byType(SelectableText), + )) { + buf.writeln(w.data ?? ''); + } + return buf.toString(); +} + +void main() { + testWidgets('a pending approval card shows its full origin and an ' + 'intro explaining approve / reject', (tester) async { + final fake = installFakeHub(); + fake.approvals = [ + ApprovalRecord( + id: 'apr-1', + flowName: 'classify-and-file', + stepId: 'review', + prompt: 'Bitte die Klassifikation prüfen', + payloadPreview: '{"label":"Rechnung"}', + createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), + expiresAt: null, + status: 'pending', + decidedAt: null, + decidedBy: '', + reason: '', + project: 'lbs', + flowExecution: 'run-abc123', + ), + ]; + + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + const StudioApp( + initialThemeMode: ThemeModeValue.dark, + initialLocale: Locale('de'), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals'))); + for (var i = 0; i < 12; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + + final text = _allText(tester); + // The human question (headline). + expect(text, contains('Bitte die Klassifikation prüfen')); + // The origin block — every fact a reviewer needs to place it. + expect(text, contains('HERKUNFT')); + expect(text, contains('Flow: classify-and-file')); + expect(text, contains('Schritt: review')); + expect(text, contains('Projekt: lbs')); + expect(text, contains('Angefordert:')); + // The run id — the field that used to be dropped at the mapping. + expect(text, contains('Lauf: run-abc123')); + // The intro strip says what this inbox is and what the actions do. + expect(text, contains('pausierte Flows')); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + }); +} diff --git a/test/runs_error_classification_test.dart b/test/runs_error_classification_test.dart index 0249aab..dadfd7d 100644 --- a/test/runs_error_classification_test.dart +++ b/test/runs_error_classification_test.dart @@ -135,6 +135,53 @@ void main() { expect(doctorOpened, isTrue); }); + testWidgets('too-old hub leads with an in-place update button, ' + 'doctor demoted to secondary', (tester) async { + var updateTriggered = false; + await tester.pumpWidget( + _host( + RunsLoadErrorView( + error: const _FakeGrpcError(12, 'unimplemented'), + issue: RunsLoadIssue.unsupported, + onOpenGuide: () {}, + onOpenDoctor: () {}, + onUpdateHub: () => updateTriggered = true, + ), + ), + ); + await tester.pumpAndSettle(); + // Primary CTA is the fix, not the detour. + expect(find.text('Hub jetzt aktualisieren'), findsOneWidget); + expect(find.text('Diagnose öffnen'), findsOneWidget); + await tester.tap(find.text('Hub jetzt aktualisieren')); + expect(updateTriggered, isTrue); + }); + + testWidgets('while updating, the button shows progress and is disabled', ( + tester, + ) async { + var updateTriggered = false; + await tester.pumpWidget( + _host( + RunsLoadErrorView( + error: const _FakeGrpcError(12, 'unimplemented'), + issue: RunsLoadIssue.unsupported, + onOpenGuide: () {}, + onOpenDoctor: () {}, + onUpdateHub: () => updateTriggered = true, + updating: true, + ), + ), + ); + // The spinner animates forever, so pump one frame rather than + // pumpAndSettle (which would time out waiting for it to stop). + await tester.pump(); + expect(find.text('Hub wird aktualisiert…'), findsOneWidget); + // Disabled while in flight — a tap must not re-fire. + await tester.tap(find.byType(FilledButton), warnIfMissed: false); + expect(updateTriggered, isFalse); + }); + testWidgets('a genuinely unreachable hub still says unreachable', ( tester, ) async { diff --git a/test/support/fake_hub.dart b/test/support/fake_hub.dart index 219d308..542638a 100644 --- a/test/support/fake_hub.dart +++ b/test/support/fake_hub.dart @@ -65,6 +65,11 @@ class FakeHubService extends Fake implements HubService { bool detachedEnabled = false; bool probeServing = true; + /// Approvals returned by [listApprovalsRecords], filtered by the + /// requested statuses so the pending / history tabs each see the + /// right slice. Empty by default (healthy-empty inbox). + List approvals = const []; + void failWith(Object error, {Set? only}) { if (only == null) { failAll = error; @@ -209,7 +214,12 @@ class FakeHubService extends Fake implements HubService { List statuses = const [], int limit = 200, String project = '', - }) => _async('listApprovalsRecords', () => const []); + }) => _async( + 'listApprovalsRecords', + () => statuses.isEmpty + ? approvals + : approvals.where((a) => statuses.contains(a.status)).toList(), + ); @override Future<({List runs, bool enabled})> listDetachedRuns({