From 28f6fe1a9a754e49c813af97b0b3a03a3b847bec Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 26 Jul 2026 15:50:41 +0200 Subject: [PATCH 01/10] feat(approvals,runs): explain approvals in place + one-click hub update (0.81.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 22 +++ assets/docs/approvals.md | 16 +- assets/docs/approvals_de.md | 17 +- lib/data/about_info.dart | 2 +- lib/data/hub.dart | 62 ++++--- lib/l10n/app_de.arb | 9 + lib/l10n/app_en.arb | 9 + lib/l10n/app_localizations.dart | 54 ++++++ lib/l10n/app_localizations_de.dart | 28 +++ lib/l10n/app_localizations_en.dart | 28 +++ lib/pages/approvals.dart | 209 ++++++++++++++++------- lib/pages/runs.dart | 121 ++++++++++++- pubspec.yaml | 2 +- test/approvals_origin_test.dart | 90 ++++++++++ test/runs_error_classification_test.dart | 47 +++++ test/support/fake_hub.dart | 12 +- 16 files changed, 613 insertions(+), 115 deletions(-) create mode 100644 test/approvals_origin_test.dart 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({ From f7d7427d915fa7fc87772d58e29446e549c3858a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 26 Jul 2026 16:05:55 +0200 Subject: [PATCH 02/10] fix(approvals): usertest-panel hardening (0.81.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel findings against the reworked approvals page, fixed in place: - Never fabricate the request time: ApprovalRecord.createdAt is nullable now; a missing created_at omits the line instead of rendering DateTime.now() (which drifted on refresh). Guard: approvals_origin_test pins the omit-on-null invariant. - Copyable errors on approve/reject/batch via showChainErrorSnack (the hard project rule) — batch surfaces the first real cause. - Reject requires a reason: ChainInlineHelp strip + confirm disabled while empty, no more silent close-and-nothing-happens. - Batch approve applies the same no-data confirmation as the single path, naming how many selected requests carry no show: data. - Plainer language: glossary "Vorgang (Flow)", history label FRAGE (was PROMPT), no-data hint drops developer jargon. - One-click copy of the run id; history payload pretty-prints like the card. Signed-off-by: flemming-it --- CHANGELOG.md | 25 ++++ lib/data/hub.dart | 14 +- lib/l10n/app_de.arb | 15 +- lib/l10n/app_en.arb | 15 +- lib/l10n/app_localizations.dart | 50 ++++++- lib/l10n/app_localizations_de.dart | 42 +++++- lib/l10n/app_localizations_en.dart | 41 +++++- lib/pages/approvals.dart | 216 +++++++++++++++++++++-------- test/approvals_origin_test.dart | 115 +++++++++------ 9 files changed, 411 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c452fa7..600c887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,31 @@ lockstep. local CLI falls back to it. Guard: two new `RunsLoadErrorView` widget tests. +### Fixed (0.81.0) + +Approvals usertest-panel hardening: + +- **Never fake the request time.** A missing/unparseable `created_at` + now leaves the "Angefordert" line off the card instead of showing + `now` (which even drifted on every refresh). `ApprovalRecord.createdAt` + is nullable; guard in `approvals_origin_test`. +- **Copyable errors on every path.** Approve/Reject and both batch + actions now route failures through `showChainErrorSnack` instead of + a bare `SnackBar(Text(e))` — the batch path surfaces the first real + cause, not just a count. +- **Reject needs a reason.** The reject dialog leads with a + `ChainInlineHelp` strip and disables the confirm button while the + reason is empty, instead of closing silently with nothing happening. +- **Batch approve respects the no-data guard.** A bulk approve that + includes requests without `show:` data now asks for the same + conscious confirmation the single approve already required, naming + how many are data-less. +- **Plainer language.** The intro and origin glossary "Flow" as + "Vorgang (Flow)", the history detail labels the question "FRAGE" + (not "PROMPT"), and the no-data hint drops developer jargon. +- **Copy the run id in one click**, and the history payload renders + with the same JSON pretty-printing as the card. + ### Added (0.80.0) - **Ollama joins the host services.** Ollama-backed store entries diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 997a590..9f94b31 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -1152,7 +1152,13 @@ class HubService { stepId: e.stepId, prompt: e.prompt, payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, - createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), + // Never fabricate the request time: a missing / unparseable + // created_at stays null so the card can omit the line + // instead of showing "now" (which would even change on + // every refresh) as if it were the truth. + createdAt: e.createdAt.isEmpty + ? null + : DateTime.tryParse(e.createdAt), expiresAt: e.expiresAt.isEmpty ? null : DateTime.tryParse(e.expiresAt), @@ -1762,7 +1768,11 @@ class ApprovalRecord { final String stepId; final String prompt; final String? payloadPreview; - final DateTime createdAt; + + /// When the approval was requested. Null when the hub sent no + /// parseable timestamp — the card omits the line rather than + /// inventing one. + final DateTime? createdAt; final DateTime? expiresAt; /// One of: pending / approved / rejected / expired. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 76c16d8..13bc40f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1095,13 +1095,20 @@ "approvalsPillRejected": "abgelehnt", "approvalsPillExpired": "abgelaufen", "approvalsPayloadPreview": "ZU PRÜFENDE DATEN", - "approvalsNoPayload": "Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.", + "approvalsNoPayload": "Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)", "approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?", "approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.", "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.", + "approvalsIntroHelp": "Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.", + "approvalsRejectDialogHelp": "Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.", + "approvalsRejectReasonHelper": "Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.", + "approvalsBatchNoDataTitle": "Ohne Prüfdaten freigeben?", + "approvalsBatchNoDataBody": "{count, plural, =1{Von {total} ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.} other{Von {total} ausgewählten Freigaben haben {count} keine Prüfdaten. Sie geben sie ohne Detailansicht frei.}}", + "approvalsCopyRun": "Lauf-Kennung kopieren", + "approvalsRunCopied": "Lauf-Kennung kopiert", + "approvalsUnknownReviewer": "(unbekannt)", "approvalsOriginLabel": "HERKUNFT", "approvalsOriginProject": "Projekt", "approvalsOriginRequested": "Angefordert", @@ -1111,7 +1118,7 @@ "approvalsApproveButton": "Freigeben", "approvalsRejectButton": "Ablehnen", "approvalsRejectDialogTitle": "Freigabe ablehnen", - "approvalsRejectReasonLabel": "Begründung (im Audit-Log festgehalten)", + "approvalsRejectReasonLabel": "Begründung", "approvalsApprovedToast": "freigegeben · {flow} › {step}", "@approvalsApprovedToast": { "placeholders": { @@ -1155,7 +1162,7 @@ "approvalsDialogDecided": "entschieden", "approvalsDialogCreated": "erstellt", "approvalsDialogReason": "Begründung", - "approvalsDialogPrompt": "PROMPT", + "approvalsDialogPrompt": "FRAGE", "approvalsExpiresIn": "{minutes}m", "@approvalsExpiresIn": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 53576c9..bbee0d6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1113,13 +1113,20 @@ "approvalsPillRejected": "rejected", "approvalsPillExpired": "expired", "approvalsPayloadPreview": "DATA TO REVIEW", - "approvalsNoPayload": "No data was attached for review. The flow's approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.", + "approvalsNoPayload": "No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step's \"show:\" field controls what appears here.)", "approvalsNoDataConfirmTitle": "Approve without review data?", "approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.", "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.", + "approvalsIntroHelp": "Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.", + "approvalsRejectDialogHelp": "Rejecting stops the process at this step and is recorded with your reason in the audit trail.", + "approvalsRejectReasonHelper": "Required — recorded in the audit log.", + "approvalsBatchNoDataTitle": "Approve without review data?", + "approvalsBatchNoDataBody": "{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}", + "approvalsCopyRun": "Copy run id", + "approvalsRunCopied": "Run id copied", + "approvalsUnknownReviewer": "(unknown)", "approvalsOriginLabel": "ORIGIN", "approvalsOriginProject": "Project", "approvalsOriginRequested": "Requested", @@ -1135,7 +1142,7 @@ "approvalsApproveButton": "Approve", "approvalsRejectButton": "Reject", "approvalsRejectDialogTitle": "Reject approval", - "approvalsRejectReasonLabel": "Reason (recorded in audit log)", + "approvalsRejectReasonLabel": "Reason", "approvalsApprovedToast": "approved · {flow} › {step}", "@approvalsApprovedToast": { "placeholders": { @@ -1179,7 +1186,7 @@ "approvalsDialogDecided": "decided", "approvalsDialogCreated": "created", "approvalsDialogReason": "reason", - "approvalsDialogPrompt": "PROMPT", + "approvalsDialogPrompt": "QUESTION", "approvalsExpiresIn": "{minutes}m", "@approvalsExpiresIn": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a33e3e3..285e659 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3521,7 +3521,7 @@ abstract class AppLocalizations { /// No description provided for @approvalsNoPayload. /// /// In en, this message translates to: - /// **'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'** + /// **'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)'** String get approvalsNoPayload; /// No description provided for @approvalsNoDataConfirmTitle. @@ -3557,9 +3557,51 @@ abstract class AppLocalizations { /// 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.'** + /// **'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'** String get approvalsIntroHelp; + /// No description provided for @approvalsRejectDialogHelp. + /// + /// In en, this message translates to: + /// **'Rejecting stops the process at this step and is recorded with your reason in the audit trail.'** + String get approvalsRejectDialogHelp; + + /// No description provided for @approvalsRejectReasonHelper. + /// + /// In en, this message translates to: + /// **'Required — recorded in the audit log.'** + String get approvalsRejectReasonHelper; + + /// No description provided for @approvalsBatchNoDataTitle. + /// + /// In en, this message translates to: + /// **'Approve without review data?'** + String get approvalsBatchNoDataTitle; + + /// No description provided for @approvalsBatchNoDataBody. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}'** + String approvalsBatchNoDataBody(num count, Object total); + + /// No description provided for @approvalsCopyRun. + /// + /// In en, this message translates to: + /// **'Copy run id'** + String get approvalsCopyRun; + + /// No description provided for @approvalsRunCopied. + /// + /// In en, this message translates to: + /// **'Run id copied'** + String get approvalsRunCopied; + + /// No description provided for @approvalsUnknownReviewer. + /// + /// In en, this message translates to: + /// **'(unknown)'** + String get approvalsUnknownReviewer; + /// No description provided for @approvalsOriginLabel. /// /// In en, this message translates to: @@ -3617,7 +3659,7 @@ abstract class AppLocalizations { /// No description provided for @approvalsRejectReasonLabel. /// /// In en, this message translates to: - /// **'Reason (recorded in audit log)'** + /// **'Reason'** String get approvalsRejectReasonLabel; /// No description provided for @approvalsApprovedToast. @@ -3677,7 +3719,7 @@ abstract class AppLocalizations { /// No description provided for @approvalsDialogPrompt. /// /// In en, this message translates to: - /// **'PROMPT'** + /// **'QUESTION'** String get approvalsDialogPrompt; /// No description provided for @approvalsExpiresIn. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e6427ea..a26d558 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2037,7 +2037,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get approvalsNoPayload => - 'Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.'; + 'Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)'; @override String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?'; @@ -2060,7 +2060,40 @@ class AppLocalizationsDe extends AppLocalizations { @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.'; + 'Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.'; + + @override + String get approvalsRejectDialogHelp => + 'Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.'; + + @override + String get approvalsRejectReasonHelper => + 'Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.'; + + @override + String get approvalsBatchNoDataTitle => 'Ohne Prüfdaten freigeben?'; + + @override + String approvalsBatchNoDataBody(num count, Object total) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Von $total ausgewählten Freigaben haben $count keine Prüfdaten. Sie geben sie ohne Detailansicht frei.', + one: + 'Von $total ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.', + ); + return '$_temp0'; + } + + @override + String get approvalsCopyRun => 'Lauf-Kennung kopieren'; + + @override + String get approvalsRunCopied => 'Lauf-Kennung kopiert'; + + @override + String get approvalsUnknownReviewer => '(unbekannt)'; @override String get approvalsOriginLabel => 'HERKUNFT'; @@ -2090,8 +2123,7 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsRejectDialogTitle => 'Freigabe ablehnen'; @override - String get approvalsRejectReasonLabel => - 'Begründung (im Audit-Log festgehalten)'; + String get approvalsRejectReasonLabel => 'Begründung'; @override String approvalsApprovedToast(String flow, String step) { @@ -2130,7 +2162,7 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsDialogReason => 'Begründung'; @override - String get approvalsDialogPrompt => 'PROMPT'; + String get approvalsDialogPrompt => 'FRAGE'; @override String approvalsExpiresIn(int minutes) { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c229ab6..bf6c5d9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2042,7 +2042,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get approvalsNoPayload => - 'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'; + 'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)'; @override String get approvalsNoDataConfirmTitle => 'Approve without review data?'; @@ -2064,7 +2064,40 @@ class AppLocalizationsEn extends AppLocalizations { @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.'; + 'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'; + + @override + String get approvalsRejectDialogHelp => + 'Rejecting stops the process at this step and is recorded with your reason in the audit trail.'; + + @override + String get approvalsRejectReasonHelper => + 'Required — recorded in the audit log.'; + + @override + String get approvalsBatchNoDataTitle => 'Approve without review data?'; + + @override + String approvalsBatchNoDataBody(num count, Object total) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count of $total selected approvals have no review data. You are approving them without a detail view.', + one: + '1 of $total selected approvals has no review data. You are approving it without a detail view.', + ); + return '$_temp0'; + } + + @override + String get approvalsCopyRun => 'Copy run id'; + + @override + String get approvalsRunCopied => 'Run id copied'; + + @override + String get approvalsUnknownReviewer => '(unknown)'; @override String get approvalsOriginLabel => 'ORIGIN'; @@ -2094,7 +2127,7 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsRejectDialogTitle => 'Reject approval'; @override - String get approvalsRejectReasonLabel => 'Reason (recorded in audit log)'; + String get approvalsRejectReasonLabel => 'Reason'; @override String approvalsApprovedToast(String flow, String step) { @@ -2133,7 +2166,7 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsDialogReason => 'reason'; @override - String get approvalsDialogPrompt => 'PROMPT'; + String get approvalsDialogPrompt => 'QUESTION'; @override String approvalsExpiresIn(int minutes) { diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index f07057f..4d05883 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../data/error_presentation.dart'; import '../data/hub.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; @@ -129,7 +131,9 @@ class _ApprovalsPageState extends State { _toast(l.approvalsApprovedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { - _toast(l.approvalsApproveFailed(e.toString())); + if (!mounted) return; + // Copyable error — never a bare SnackBar(Text(e)). + showChainErrorSnack(context, 'approvals.approve', e); } } @@ -142,7 +146,8 @@ class _ApprovalsPageState extends State { _toast(l.approvalsRejectedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { - _toast(l.approvalsRejectFailed(e.toString())); + if (!mounted) return; + showChainErrorSnack(context, 'approvals.reject', e); } } @@ -169,25 +174,54 @@ class _ApprovalsPageState extends State { /// drains as items succeed. Future _batchApprove(List picked) async { final l = AppLocalizations.of(context)!; + // Same conscious-confirmation guard as the single approve, but for + // the whole batch: a one-click bulk approve must not silently + // sweep through the data-less requests the single path stops on. + final noData = picked.where((a) => a.payloadPreview == null).length; + if (noData > 0) { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.approvalsBatchNoDataTitle), + content: Text(l.approvalsBatchNoDataBody(noData, picked.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l.approvalsNoDataConfirmAction), + ), + ], + ), + ); + if (confirmed != true) return; + } + if (!mounted) return; setState(() => _batchInFlight = true); var ok = 0; var failed = 0; + Object? firstError; for (final a in picked) { try { await HubService.instance.approve(a.id, _reviewer); ok += 1; _selectedIds.remove(a.id); - } catch (_) { + } catch (e) { failed += 1; + firstError ??= e; } } if (!mounted) return; setState(() => _batchInFlight = false); - _toast( - failed == 0 - ? l.approvalsBatchApproveDoneToast(ok) - : l.approvalsBatchPartialFailure(ok, failed), - ); + if (failed == 0) { + _toast(l.approvalsBatchApproveDoneToast(ok)); + } else { + // Partial failure: surface the copyable cause, not just a count. + // The failed items stay selected so they remain visible. + showChainErrorSnack(context, 'approvals.batchApprove', firstError!); + } _refresh(); } @@ -198,22 +232,24 @@ class _ApprovalsPageState extends State { setState(() => _batchInFlight = true); var ok = 0; var failed = 0; + Object? firstError; for (final a in picked) { try { await HubService.instance.reject(a.id, _reviewer, reason); ok += 1; _selectedIds.remove(a.id); - } catch (_) { + } catch (e) { failed += 1; + firstError ??= e; } } if (!mounted) return; setState(() => _batchInFlight = false); - _toast( - failed == 0 - ? l.approvalsBatchRejectDoneToast(ok) - : l.approvalsBatchPartialFailure(ok, failed), - ); + if (failed == 0) { + _toast(l.approvalsBatchRejectDoneToast(ok)); + } else { + showChainErrorSnack(context, 'approvals.batchReject', firstError!); + } _refresh(); } @@ -229,26 +265,49 @@ class _ApprovalsPageState extends State { context: context, builder: (ctx) => AlertDialog( title: Text(l.approvalsRejectDialogTitle), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration( - labelText: l.approvalsRejectReasonLabel, - border: const OutlineInputBorder(), - ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Explain the consequence at the point of input, and make + // the reason a real required field: the button stays + // disabled while it is empty instead of the dialog closing + // silently and nothing happening (usertest finding). + ChainInlineHelp(text: l.approvalsRejectDialogHelp), + const SizedBox(height: ChainSpace.md), + TextField( + controller: controller, + autofocus: true, + minLines: 1, + maxLines: 3, + decoration: InputDecoration( + labelText: l.approvalsRejectReasonLabel, + helperText: l.approvalsRejectReasonHelper, + border: const OutlineInputBorder(), + ), + ), + ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), child: Text(l.buttonCancel), ), - FilledButton( - onPressed: () => Navigator.pop(ctx, controller.text), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(ctx).colorScheme.error, - foregroundColor: Theme.of(ctx).colorScheme.onError, - ), - child: Text(l.approvalsRejectButton), + ValueListenableBuilder( + valueListenable: controller, + builder: (ctx, value, _) { + final empty = value.text.trim().isEmpty; + return FilledButton( + onPressed: empty + ? null + : () => Navigator.pop(ctx, controller.text.trim()), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), + child: Text(l.approvalsRejectButton), + ); + }, ), ], ), @@ -687,23 +746,58 @@ class _ApprovalCard extends StatelessWidget { style: metaStyle, ), ), - originLine( - Icons.schedule, - Text( - '${l.approvalsOriginRequested}: ' - '${_formatTimestamp(approval.createdAt.toLocal())}', - style: metaStyle, + if (approval.createdAt != null) + 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, - ), + Row( + children: [ + Expanded( + child: SelectableText( + '${l.approvalsOriginRun}: ${approval.flowExecution}', + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + // One-click copy so the run id can be pasted into the + // Audit page's search to reach the event timeline + // (a filtered deeplink is a larger shell change — see + // the backlog). + Tooltip( + message: l.approvalsCopyRun, + child: InkResponse( + radius: 16, + onTap: () async { + await Clipboard.setData( + ClipboardData(text: approval.flowExecution!), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.approvalsRunCopied)), + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.content_copy, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], ), ), const SizedBox(height: ChainSpace.md), @@ -733,7 +827,7 @@ class _ApprovalCard extends StatelessWidget { ), child: SingleChildScrollView( child: SelectableText( - _prettyPreview(approval.payloadPreview!), + _prettyJson(approval.payloadPreview!), style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurface, @@ -813,16 +907,6 @@ class _ApprovalCard extends StatelessWidget { ); } - String _prettyPreview(String raw) { - if (raw.isEmpty) return raw; - try { - final dynamic parsed = const JsonDecoder().convert(raw); - return const JsonEncoder.withIndent(' ').convert(parsed); - } catch (_) { - return raw; - } - } - String _expiresInLabel(BuildContext context, DateTime t) { final l = AppLocalizations.of(context)!; final remaining = t.difference(DateTime.now()); @@ -955,13 +1039,14 @@ class _HistoryDialog extends StatelessWidget { theme, AppLocalizations.of(context)!.approvalsDialogDecided, '${_formatTimestamp(record.decidedAt!.toLocal())} ' - '· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}', + '· ${record.decidedBy.isEmpty ? AppLocalizations.of(context)!.approvalsUnknownReviewer : record.decidedBy}', + ), + if (record.createdAt != null) + _kv( + theme, + AppLocalizations.of(context)!.approvalsDialogCreated, + _formatTimestamp(record.createdAt!.toLocal()), ), - _kv( - theme, - AppLocalizations.of(context)!.approvalsDialogCreated, - _formatTimestamp(record.createdAt.toLocal()), - ), if (record.project.isNotEmpty) _kv( theme, @@ -1015,7 +1100,7 @@ class _HistoryDialog extends StatelessWidget { border: Border.all(color: theme.colorScheme.outlineVariant), ), child: SelectableText( - record.payloadPreview!, + _prettyJson(record.payloadPreview!), style: ChainTheme.mono(size: 11), ), ), @@ -1064,6 +1149,19 @@ class _HistoryDialog extends StatelessWidget { } } +/// Pretty-print a JSON payload preview; returns the raw string +/// unchanged when it is empty or not valid JSON. Shared by the +/// pending card + the history dialog so both render identically. +String _prettyJson(String raw) { + if (raw.isEmpty) return raw; + try { + final dynamic parsed = const JsonDecoder().convert(raw); + return const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) { + return raw; + } +} + /// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by /// the history row + dialog. Always renders in the operator's /// local time zone — ISO timestamps from the wire still appear diff --git a/test/approvals_origin_test.dart b/test/approvals_origin_test.dart index d974b31..c38d171 100644 --- a/test/approvals_origin_test.dart +++ b/test/approvals_origin_test.dart @@ -5,6 +5,10 @@ // the Dart mapping layer; this pins that it reaches the card, next // to the rest of the origin facts and the plain-language intro. // +// It also pins the honesty invariant from the usertest: a missing +// created_at must NOT be rendered as a fabricated "now" — the line +// is simply omitted, exactly like the run id on a legacy hub. +// // Runs against the scriptable FakeHubService — never a real hub. import 'package:flutter/material.dart'; @@ -31,46 +35,57 @@ String _allText(WidgetTester tester) { return buf.toString(); } +ApprovalRecord _pending({DateTime? createdAt, String? runId = 'run-abc123'}) { + return ApprovalRecord( + id: 'apr-1', + flowName: 'classify-and-file', + stepId: 'review', + prompt: 'Bitte die Klassifikation prüfen', + payloadPreview: '{"label":"Rechnung"}', + createdAt: createdAt, + expiresAt: null, + status: 'pending', + decidedAt: null, + decidedBy: '', + reason: '', + project: 'lbs', + flowExecution: runId, + ); +} + +Future _renderApprovals( + WidgetTester tester, + List approvals, +) async { + final fake = installFakeHub(); + fake.approvals = approvals; + 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); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + return text; +} + 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); + final text = await _renderApprovals(tester, [ + _pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0)), + ]); // The human question (headline). expect(text, contains('Bitte die Klassifikation prüfen')); // The origin block — every fact a reviewer needs to place it. @@ -82,9 +97,29 @@ void main() { // 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')); + expect(text, contains('pausierte')); + }); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(minutes: 1)); + testWidgets('a missing requested-at time is omitted, never faked as ' + '"now"', (tester) async { + // created_at unknown: the honest behaviour is to drop the line, + // not to invent a timestamp (which would even drift on refresh). + final text = await _renderApprovals(tester, [_pending(createdAt: null)]); + // The rest of the origin still renders. + expect(text, contains('HERKUNFT')); + expect(text, contains('Flow: classify-and-file')); + expect(text, contains('Lauf: run-abc123')); + // But no requested-at line at all. + expect(text, isNot(contains('Angefordert:'))); + }); + + testWidgets('a legacy approval without a run id omits the run line', ( + tester, + ) async { + final text = await _renderApprovals(tester, [ + _pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), runId: null), + ]); + expect(text, contains('HERKUNFT')); + expect(text, isNot(contains('Lauf:'))); }); } From c6a0b1a2678932f56f247a009b9202df04e4c35f Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 28 Jul 2026 00:40:09 +0200 Subject: [PATCH 03/10] chore: Pfade auf ~/Developer umgestellt Signed-off-by: flemming-it --- macos/Runner/Configs/Debug.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig index 41efa7a..a3bc089 100644 --- a/macos/Runner/Configs/Debug.xcconfig +++ b/macos/Runner/Configs/Debug.xcconfig @@ -1,8 +1,8 @@ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" -// Disable codesigning for local debug builds. The Repositories.nosync/ -// path on macOS sprouts extended attributes that codesign rejects with +// Disable codesigning for local debug builds. The repository path on +// macOS sprouts extended attributes that codesign rejects with // "resource fork, Finder information, or similar detritus not allowed". // For development we don't need a signed binary. CODE_SIGNING_ALLOWED = NO From 415f8a7ddbe661902265969b93ddcc64cbcb008f Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 3 Aug 2026 23:49:48 +0200 Subject: [PATCH 04/10] fix(audit): wrap overflow-menu labels instead of clipping them A popup menu is width-capped at ~280px, so the full-sentence German entries in the audit overflow menu ran past the edge. The clipped tail of the reset entry was exactly the "(nur local/dev)" scope that keeps it from reading as "delete evidence", and the label's centre landed outside the hit box. Guard in responsive_test: the page-level overflow sweep cannot see popup menus (their width does not follow the window), which is why this went unnoticed. Signed-off-by: flemming-it --- CHANGELOG.md | 6 +++++ lib/pages/audit.dart | 45 ++++++++++++++++++++------------- test/responsive_test.dart | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 600c887..e3e1eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ Approvals usertest-panel hardening: (not "PROMPT"), and the no-data hint drops developer jargon. - **Copy the run id in one click**, and the history payload renders with the same JSON pretty-printing as the card. +- **Audit overflow-menu entries no longer run off the menu.** A + popup menu is width-capped, so the full-sentence German labels + were clipped — on the reset entry exactly the "(nur local/dev)" + that keeps it from reading as "delete evidence". Labels wrap now; + guard in `responsive_test` (the page sweep cannot see popup menus, + which is why it went unnoticed). ### Added (0.80.0) diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index f8394ff..6dda034 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -326,35 +326,33 @@ class _AuditPageState extends State { 'clear' => _onClearPressed(), _ => null, }, + // Labels wrap instead of running past the menu edge: a + // popup menu is at most 280px wide, and these entries + // spell out what they do ("Entwicklungs-Reset: Protokoll + // löschen … (nur local/dev)"). Unwrapped, the tail was + // clipped — and the clipped part is exactly the + // "nur local/dev" that keeps the entry from reading as + // "delete evidence". itemBuilder: (ctx) => [ PopupMenuItem( value: 'export', - child: Row( - children: [ - const Icon(Icons.download_outlined, size: 16), - const SizedBox(width: ChainSpace.sm), - Text(AppLocalizations.of(ctx)!.auditExportAction), - ], + child: _menuLabel( + Icons.download_outlined, + AppLocalizations.of(ctx)!.auditExportAction, ), ), PopupMenuItem( value: 'export-all', - child: Row( - children: [ - const Icon(Icons.archive_outlined, size: 16), - const SizedBox(width: ChainSpace.sm), - Text(AppLocalizations.of(ctx)!.auditExportAllAction), - ], + child: _menuLabel( + Icons.archive_outlined, + AppLocalizations.of(ctx)!.auditExportAllAction, ), ), PopupMenuItem( value: 'clear', - child: Row( - children: [ - const Icon(Icons.delete_sweep_outlined, size: 16), - const SizedBox(width: ChainSpace.sm), - Text(AppLocalizations.of(ctx)!.auditDevResetAction), - ], + child: _menuLabel( + Icons.delete_sweep_outlined, + AppLocalizations.of(ctx)!.auditDevResetAction, ), ), ], @@ -456,6 +454,17 @@ class _AuditPageState extends State { return parts.join(''); } + /// Icon + label for an overflow-menu entry. [Flexible] is the + /// point: the menu is width-capped, so a long label has to wrap + /// rather than run off the edge. + Widget _menuLabel(IconData icon, String label) => Row( + children: [ + Icon(icon, size: 16), + const SizedBox(width: ChainSpace.sm), + Flexible(child: Text(label)), + ], + ); + Color _toneFor(String type, ThemeData theme) { if (type.endsWith('.failed')) return theme.colorScheme.error; if (type.endsWith('.completed')) return theme.colorScheme.primary; diff --git a/test/responsive_test.dart b/test/responsive_test.dart index c80cba3..99286b6 100644 --- a/test/responsive_test.dart +++ b/test/responsive_test.dart @@ -69,4 +69,57 @@ void main() { } }); } + + // Popup menus have a hard width cap (~280px) that no window size + // relaxes, so the page sweep above cannot see them overflow. The + // audit overflow menu spells its entries out in full German + // sentences — unwrapped, the tail was clipped, and on the reset + // entry the clipped tail was the "(nur local/dev)" that keeps it + // from reading as "delete evidence". + testWidgets('audit overflow-menu entries wrap instead of overflowing', ( + tester, + ) async { + installFakeHub(); + tester.view.physicalSize = const Size(1280, 800); + 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-audit'))); + await tester.pump(const Duration(milliseconds: 400)); + + await tester.tap(find.byTooltip('Weitere Aktionen')); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect( + tester.takeException(), + isNull, + reason: 'the audit overflow menu threw during layout', + ); + + // Every entry stays inside the menu it belongs to — a label + // that overruns is not just ugly, it is unreadable and unhittable. + final menu = tester.getRect(find.byType(PopupMenuItem).first); + for (final label in find.byType(PopupMenuItem).evaluate()) { + final text = find.descendant( + of: find.byWidget(label.widget), + matching: find.byType(Text), + ); + expect( + tester.getRect(text).right, + lessThanOrEqualTo(menu.right + 0.5), + reason: 'menu label runs past the menu edge', + ); + } + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + }); } From ebc668d28d3e655511660097a809fe16905df1eb Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 3 Aug 2026 23:50:34 +0200 Subject: [PATCH 05/10] feat(approvals,audit): record the reviewer as the unchecked claim it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub copies the reviewer string a client sends straight into decided_by (DecideApproval, ClearEventLog); nothing on the wire ties it to the authenticated caller. Studio filled it from the OS account, so an export read like non-repudiation while being an arbitrary client claim — the legal finding of the 2026-07-26 usertest panel. The real fix is hub-side (derive decided_by from CALLER_IDENTITY); that contract is written down in docs/reviewer-identity.md and needs a hub release. Until then Studio does the one thing it can do honestly and marks its own claim as a claim, inside the record: - data/reviewer_identity.dart is the single place that produces and reads the value; wire() is idempotent, so page and HubService may both normalise. Every write path funnels through HubService, so no surface can send a bare handle. - The inbox states before the decision who will be recorded, what that attribution is worth on this hub (from AuthStatus), and the literal string that lands in decided_by. An unreadable auth policy stays unreadable — never optimistic. - Reading back: a marked value shows its plain name plus an unchecked flag; an unmarked one (legacy row, CLI decision, or a future hub-derived identity) is not classified either way. - The audit wipe seeds the same kind of marked attribution into its chain.reset marker. When the hub starts deriving the value it overwrites the field and the prefix disappears by itself — no Studio release needed. Guards: reviewer_identity_test (the value) and approvals_reviewer_identity_test (every surface that writes or renders it, against the hermetic fake hub). Visual proof for both themes via the dialog-shot harness. Signed-off-by: flemming-it --- CHANGELOG.md | 18 ++ docs/reviewer-identity.md | 89 +++++++ integration_test/dialog_shots_test.dart | 64 ++++- lib/data/hub.dart | 28 +- lib/data/reviewer_identity.dart | 139 ++++++++++ lib/l10n/app_de.arb | 8 + lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 48 ++++ lib/l10n/app_localizations_de.dart | 31 +++ lib/l10n/app_localizations_en.dart | 31 +++ lib/pages/approvals.dart | 240 +++++++++++++++-- lib/pages/audit.dart | 24 +- test/approvals_reviewer_identity_test.dart | 288 +++++++++++++++++++++ test/reviewer_identity_test.dart | 105 ++++++++ test/support/fake_hub.dart | 49 ++++ 15 files changed, 1129 insertions(+), 41 deletions(-) create mode 100644 docs/reviewer-identity.md create mode 100644 lib/data/reviewer_identity.dart create mode 100644 test/approvals_reviewer_identity_test.dart create mode 100644 test/reviewer_identity_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e1eb3..8e414ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,24 @@ Approvals usertest-panel hardening: (not "PROMPT"), and the no-data hint drops developer jargon. - **Copy the run id in one click**, and the history payload renders with the same JSON pretty-printing as the card. +- **A reviewer name no longer poses as a proven identity.** The hub + stores whatever `reviewer` string a client sends as `decided_by`, + so Studio's `$USER@studio` read like non-repudiation while being + an unchecked claim (legal finding of the 2026-07-26 panel). Every + value Studio writes — approvals and the `chain.reset` marker of + the audit wipe — now carries an `unverified:` marker inside the + record, so an export, a CLI reader or a database dump sees the + trust level without knowing how the deciding Studio was set up. + The inbox states before the decision who will be recorded and + what that is worth on this hub (derived from the hub's auth + policy; "unreadable" stays unreadable, never optimistic). Reading + back, a marked value shows its plain name with an "unchecked" + flag, an unmarked one (legacy row, CLI decision) is not + classified either way. The real fix is hub-side — derive + `decided_by` from the authenticated caller; the contract is in + `docs/reviewer-identity.md`, and once the hub does it the marker + disappears without a Studio release. Guards: + `reviewer_identity_test` + `approvals_reviewer_identity_test`. - **Audit overflow-menu entries no longer run off the menu.** A popup menu is width-capped, so the full-sentence German labels were clipped — on the reset entry exactly the "(nur local/dev)" diff --git a/docs/reviewer-identity.md b/docs/reviewer-identity.md new file mode 100644 index 0000000..7ccd509 --- /dev/null +++ b/docs/reviewer-identity.md @@ -0,0 +1,89 @@ +# Reviewer identity — what `decided_by` is worth + +## The problem + +The hub copies the `reviewer` string a client sends straight into +`decided_by`: + +- `DecideApproval` — who approved or rejected a paused flow step +- `ClearEventLog` — who wiped the audit log, recorded in the seeded + `chain.reset` marker + +Nothing on the wire ties that string to the authenticated caller. +Studio fills it from the OS account (`$USER@studio`); any other +client can send any string at all, including someone else's name. + +The legal review of the approvals page (usertest panel, 2026-07-26) +graded this HIGH: a value that reads like an identity but is an +unchecked client claim lends the audit trail a non-repudiation it +does not have. An export, a court-facing report or an auditor +reading the database sees `stefan@studio` and has no way to tell +whether the hub checked anything. + +This is not a leak and not remote-exploitable on its own — the +finding is about what the record *proves*, not about access. + +## What Studio does today (0.81.0) + +Studio marks its own claim as a claim, **inside the recorded +value**: + +``` +decided_by = "unverified:stefan@studio" +``` + +- `lib/data/reviewer_identity.dart` is the single place that + produces and reads that value. `ReviewerIdentity.wire()` is + idempotent, so both the page and `HubService` may normalise. +- Every write path funnels through `HubService.approve` / + `reject` / `clearEventLog`, so no surface can send a bare handle. +- The approvals inbox states before the decision who will be + recorded, what the attribution is worth on *this* hub (derived + from `AuthStatus.anonymous_allowed`), and the literal string that + will be stored. +- Reading back: a marked value is shown by its name with an + "unchecked" flag; an **unmarked** value (legacy row, a CLI + decision, or a future hub-derived identity) gets no badge at all + — Studio does not know its provenance and must not classify it. + +Guards: `test/reviewer_identity_test.dart` (the value itself) and +`test/approvals_reviewer_identity_test.dart` (every surface that +writes or renders it, against the hermetic fake hub). + +This is honest, but it is a *label*, not a fix. It does not stop a +client from sending `unverified:someone.else@studio`. + +## The actual fix — hub side + +`decided_by` must be derived server-side from the verified caller +and the client-supplied `reviewer` ignored: + +1. In the `DecideApproval` and `ClearEventLog` handlers, take the + identity from the same source `_caller` comes from + (`CALLER_IDENTITY`) rather than from the request message. + - static validator: the configured token's name + - `jwt-rs256` validator: the JWT subject + - anonymous call: no identity — record `anonymous` explicitly + (never a client-supplied name), or refuse the decision on + channels where the operator requires attribution. +2. Keep accepting the request field for one release (ignored) so + older Studios and CLIs keep working; log a deprecation when it + is present and differs from the derived value. +3. Once the hub derives the value, it overwrites the field and the + `unverified:` prefix disappears from new records by itself — no + Studio release is needed to stop labelling. Studio's reader + already treats an unmarked value as "provenance unknown", so a + hub-derived value renders cleanly. +4. Old rows keep the prefix. That is correct: they *were* unchecked. + +Open product decision for step 1: whether an anonymous hub may +decide approvals at all, or whether approvals require an +authenticated caller. Studio surfaces the anonymous case today; it +does not block it. + +## Related + +- `lib/data/reviewer_identity.dart` — the Studio-side contract +- Backlog T014, point 1 (usertest 2026-07-26, legal persona) +- Points 2–7 of that finding (hash-chain-backed history, deeplink, + sealed slugs, UTC offsets, auto-refresh) are untouched by this. diff --git a/integration_test/dialog_shots_test.dart b/integration_test/dialog_shots_test.dart index 3fa17a0..870ad16 100644 --- a/integration_test/dialog_shots_test.dart +++ b/integration_test/dialog_shots_test.dart @@ -19,8 +19,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_studio_flow_editor/src/l10n.dart'; import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart'; -import 'package:chain_studio/data/hub.dart' show StoreItem; +import 'package:chain_studio/data/hub.dart' + show ApprovalRecord, StoreItem, ThemeModeValue; +import 'package:chain_studio/data/reviewer_identity.dart'; import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/main.dart'; import 'package:chain_studio/pages/federation.dart'; import 'package:chain_studio/widgets/chain_install_confirm.dart'; @@ -200,4 +203,63 @@ void main() { await _shot(tester, '$name-dark'); }); } + + // Reviewer identity on the approvals inbox — the strip that names + // who the audit trail will record and says, in plain words, what + // that attribution is worth on this hub. Captured in both themes + // because the "not verified" pill and the literal recorded value + // have to stay legible in each (release gate, project CLAUDE.md). + for (final (themeName, mode) in [ + ('light', ThemeModeValue.light), + ('dark', ThemeModeValue.dark), + ]) { + testWidgets('approvals reviewer identity — $themeName', (tester) async { + SharedPreferences.setMockInitialValues({}); + ReviewerIdentity.debugHandle = 'stefan@studio'; + addTearDown(() => ReviewerIdentity.debugHandle = null); + final fake = installFakeHub(); + fake.approvals = [ + ApprovalRecord( + id: 'apr-1', + flowName: 'rechnung-klassifizieren', + stepId: 'pruefen', + prompt: 'Bitte die Klassifikation dieser Rechnung bestätigen.', + payloadPreview: '{"label":"Rechnung","betrag":"128,40 EUR"}', + createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), + expiresAt: null, + status: 'pending', + decidedAt: null, + decidedBy: '', + reason: '', + project: 'buergeramt', + flowExecution: 'run-abc123', + ), + ]; + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + // The real app shell, not a bare MaterialApp: the strip has to + // be judged inside the Chain theme it ships in. + await tester.pumpWidget( + RepaintBoundary( + key: _shotKey, + child: StudioApp(initialThemeMode: mode, 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)); + } + await _shot(tester, 'approvals-reviewer-identity-$themeName'); + expect(find.text('Sie entscheiden als'), findsOneWidget); + expect( + find.text('Wird gespeichert als: unverified:stefan@studio'), + findsOneWidget, + ); + // Long-lived timers: unmount before the harness tears down. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + }); + } } diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 9f94b31..93f253f 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -16,6 +16,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_localizations.dart'; import 'flow_output.dart'; import 'hub_auth_token.dart'; +import 'reviewer_identity.dart'; export 'flow_output.dart'; class HubService { @@ -649,12 +650,18 @@ class HubService { /// `production`; the gRPC error surfaces as an exception so /// the caller can show the operator why it was blocked. /// Returns `(purged, channel)` so the UI can confirm what - /// just happened. + /// just happened. [reviewer] is the operator's typed handle and + /// goes out through [ReviewerIdentity.wire] for the same reason + /// approvals do: the marker the hub seeds is read by auditors and + /// must not claim a verified identity Studio cannot supply. Future<({int purged, String channel})> clearEventLog({ required String reviewer, required String reason, }) async { - final r = await _client.clearEventLog(reviewer: reviewer, reason: reason); + final r = await _client.clearEventLog( + reviewer: ReviewerIdentity.wire(reviewer), + reason: reason, + ); return (purged: r.purged.toInt(), channel: r.channel); } @@ -1175,11 +1182,22 @@ class HubService { .toList(); } + /// Decide an approval. [reviewer] is the operator-facing handle; + /// it leaves Studio through [ReviewerIdentity.wire], which labels + /// it as the unchecked client claim it is — the hub stores the + /// string verbatim, so the record has to carry its own trust + /// level (see `lib/data/reviewer_identity.dart`). Every decide + /// path (approvals page, batch actions, the flow-editor run + /// driver) funnels through here so none of them can bypass that. Future approve(String id, String reviewer) => - _client.approve(approvalId: id, reviewer: reviewer); + _client.approve(approvalId: id, reviewer: ReviewerIdentity.wire(reviewer)); - Future reject(String id, String reviewer, String reason) => - _client.reject(approvalId: id, reviewer: reviewer, reason: reason); + Future reject(String id, String reviewer, String reason) => _client + .reject( + approvalId: id, + reviewer: ReviewerIdentity.wire(reviewer), + reason: reason, + ); /// Detached-runs monitor snapshot: every tracked invocation /// (newest-first, optionally scoped to one [project]) plus whether diff --git a/lib/data/reviewer_identity.dart b/lib/data/reviewer_identity.dart new file mode 100644 index 0000000..a50f2f8 --- /dev/null +++ b/lib/data/reviewer_identity.dart @@ -0,0 +1,139 @@ +// Who decided? — the single place Studio answers that question. +// +// The hub copies the `reviewer` string a client sends straight into +// `decided_by` (`DecideApproval` / `ClearEventLog`); nothing on the +// wire ties that string to the authenticated caller. The legal +// review of the approvals page (usertest 2026-07-26) called it out: +// a value that reads like an identity but is an unchecked client +// string lends the audit trail a non-repudiation it does not have. +// +// The real fix is hub-side — derive `decided_by` from the verified +// caller (`CALLER_IDENTITY`, the same source `_caller` comes from) +// and ignore what the client claims. That contract is written down +// in `docs/reviewer-identity.md`; it needs a hub release. +// +// Until then Studio does the one thing it *can* do honestly: it +// marks its own claim as a claim, inside the record. Every reviewer +// string Studio sends carries the `unverified:` prefix, so an +// export, an SQL reader or another client sees the trust level +// without having to know how the deciding Studio was configured. +// When the hub starts deriving the value it overwrites the field +// and the prefix disappears by itself — no Studio release needed +// to stop lying. + +import 'dart:io'; + +import 'package:meta/meta.dart'; + +/// Marks a reviewer attribution as a client-side claim. Part of the +/// recorded value, not just a UI decoration — see the file header. +const String kUnverifiedReviewerPrefix = 'unverified:'; + +/// How much a reviewer attribution Studio is about to write is +/// worth, derived from the hub's authentication policy. +enum ReviewerAssurance { + /// The hub accepts calls without credentials: a decision cannot + /// be tied to anyone at all. + anonymousHub, + + /// The hub authenticates the connection (static token or JWT), + /// but still records the reviewer name the client supplies. + accessControlled, + + /// Studio could not read the hub's auth policy — `AuthStatus` is + /// admin-scoped, so a plain reviewer token gets PermissionDenied, + /// and hubs older than the RPC answer UNIMPLEMENTED. + unknown, +} + +/// Map the hub's `anonymous_allowed` flag onto an assurance level. +/// `null` = policy unreadable (denied / unsupported / offline). +ReviewerAssurance reviewerAssuranceFor(bool? anonymousAllowed) => + switch (anonymousAllowed) { + true => ReviewerAssurance.anonymousHub, + false => ReviewerAssurance.accessControlled, + null => ReviewerAssurance.unknown, + }; + +/// What a stored `decided_by` value is worth when read back. +enum RecordedReviewerTrust { + /// Written by a Studio that labelled its own claim (`unverified:`). + selfDeclared, + + /// No trust marker: a legacy row, a CLI decision, or a future + /// hub-derived identity. Studio does not know which — and must + /// not present it as proven either way. + unknown, +} + +/// A `decided_by` value split into what it says and what it is worth. +@immutable +class RecordedReviewer { + /// The name without the trust marker — what a human should read. + final String handle; + final RecordedReviewerTrust trust; + + const RecordedReviewer({required this.handle, required this.trust}); + + bool get isSelfDeclared => trust == RecordedReviewerTrust.selfDeclared; +} + +/// Resolves the reviewer identity Studio submits and reads recorded +/// ones back. Never contacts the hub — the assurance level comes +/// from the caller (see [reviewerAssuranceFor]). +class ReviewerIdentity { + ReviewerIdentity._(); + + static String? _debugHandle; + + /// Test seam: pins [localHandle] so suites do not depend on the + /// OS account of whoever runs them. `null` restores the default. + @visibleForTesting + static set debugHandle(String? value) => _debugHandle = value; + + /// Human-readable handle of the operator sitting in front of + /// Studio. The OS account is the closest stable label available + /// locally — a label, never a proof, which is exactly why it + /// leaves the machine through [wire]. + static String get localHandle { + final pinned = _debugHandle; + if (pinned != null) return pinned; + final user = + Platform.environment['USER'] ?? + Platform.environment['USERNAME'] ?? + 'studio'; + return '$user@studio'; + } + + /// The value that goes on the wire for [handle] (default: + /// [localHandle]). Idempotent, so a value that already carries the + /// marker — or one round-tripped through the UI — is not prefixed + /// twice. An empty/blank handle falls back to [localHandle]: the + /// hub rejects an empty reviewer, and a blank one would be a worse + /// record than a labelled guess. + static String wire([String? handle]) { + final trimmed = (handle ?? localHandle).trim(); + final name = trimmed.isEmpty ? localHandle : trimmed; + if (name.startsWith(kUnverifiedReviewerPrefix)) return name; + return '$kUnverifiedReviewerPrefix$name'; + } + + /// Split a recorded `decided_by` into name + trust. + static RecordedReviewer parse(String recorded) { + final value = recorded.trim(); + if (!value.startsWith(kUnverifiedReviewerPrefix)) { + return RecordedReviewer( + handle: value, + trust: RecordedReviewerTrust.unknown, + ); + } + final handle = value.substring(kUnverifiedReviewerPrefix.length).trim(); + return RecordedReviewer( + // A marker with nothing behind it still says something true + // ("someone claimed nothing"); show the marker rather than an + // empty cell. + handle: handle.isEmpty ? value : handle, + trust: RecordedReviewerTrust.selfDeclared, + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 13bc40f..e40fab3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -674,6 +674,7 @@ "auditClearDialogTitle": "Audit-Log löschen?", "auditClearDialogBody": "Löscht jedes Audit-Event auf dem aktiven Kanal und seedet einen neuen chain.reset-Marker mit Prüfer und Begründung. Auf beta / production verweigert. Nicht umkehrbar.", "auditClearReviewerLabel": "Prüfer", + "auditClearReviewerHelper": "Wird als ungeprüfte Angabe festgehalten (Präfix „unverified:“) — der Hub übernimmt den Namen unverändert.", "auditClearReasonLabel": "Begründung (im chain.reset-Marker festgehalten)", "auditClearReasonHelper": "Erforderlich — Auditoren werden das lesen.", "auditClearLogButton": "Log löschen", @@ -1102,6 +1103,13 @@ "approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich", "approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}", "approvalsIntroHelp": "Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.", + "approvalsReviewerLabel": "Sie entscheiden als", + "approvalsReviewerUnverifiedPill": "nicht überprüft", + "approvalsReviewerRecordedAs": "Wird gespeichert als: {value}", + "approvalsReviewerNoteAnonymous": "Dieser Hub nimmt Aufrufe ohne Zugangsdaten an — eine Entscheidung lässt sich damit niemandem nachweisen. Studio kennzeichnet den Namen deshalb im Eintrag selbst als ungeprüfte Angabe.", + "approvalsReviewerNoteAuthenticated": "Der Hub prüft Ihren Zugang, übernimmt den Namen aber unverändert von Studio. Bis der Hub die Identität aus dem geprüften Zugang selbst ableitet, kennzeichnet Studio den Namen im Eintrag als ungeprüfte Angabe.", + "approvalsReviewerNoteUnknown": "Studio konnte die Zugangs-Einstellungen dieses Hubs nicht lesen (dafür braucht es Admin-Rechte). Der Name wird unverändert übernommen und deshalb im Eintrag als ungeprüfte Angabe gekennzeichnet.", + "approvalsReviewerUnverifiedTooltip": "Ungeprüfte Angabe: Dieser Name stammt vom entscheidenden Studio, nicht vom Hub.", "approvalsRejectDialogHelp": "Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.", "approvalsRejectReasonHelper": "Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.", "approvalsBatchNoDataTitle": "Ohne Prüfdaten freigeben?", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bbee0d6..51012a2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -692,6 +692,7 @@ "auditClearDialogTitle": "Clear audit log?", "auditClearDialogBody": "Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.", "auditClearReviewerLabel": "Reviewer", + "auditClearReviewerHelper": "Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.", "auditClearReasonLabel": "Reason (recorded in chain.reset marker)", "auditClearReasonHelper": "Required — auditors will read this.", "auditClearLogButton": "Clear log", @@ -1120,6 +1121,13 @@ "approvalsRequestFallback": "Approval required for this step", "approvalsFlowStepMeta": "Flow: {flow} · Step: {step}", "approvalsIntroHelp": "Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.", + "approvalsReviewerLabel": "You are deciding as", + "approvalsReviewerUnverifiedPill": "not verified", + "approvalsReviewerRecordedAs": "Recorded as: {value}", + "approvalsReviewerNoteAnonymous": "This hub accepts calls without credentials, so a decision cannot be proven to be anyone's. Studio therefore marks the name inside the record itself as an unchecked claim.", + "approvalsReviewerNoteAuthenticated": "The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.", + "approvalsReviewerNoteUnknown": "Studio could not read this hub's access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.", + "approvalsReviewerUnverifiedTooltip": "Unchecked claim: this name comes from the deciding Studio, not from the hub.", "approvalsRejectDialogHelp": "Rejecting stops the process at this step and is recorded with your reason in the audit trail.", "approvalsRejectReasonHelper": "Required — recorded in the audit log.", "approvalsBatchNoDataTitle": "Approve without review data?", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 285e659..241658b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2384,6 +2384,12 @@ abstract class AppLocalizations { /// **'Reviewer'** String get auditClearReviewerLabel; + /// No description provided for @auditClearReviewerHelper. + /// + /// In en, this message translates to: + /// **'Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.'** + String get auditClearReviewerHelper; + /// No description provided for @auditClearReasonLabel. /// /// In en, this message translates to: @@ -3560,6 +3566,48 @@ abstract class AppLocalizations { /// **'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'** String get approvalsIntroHelp; + /// No description provided for @approvalsReviewerLabel. + /// + /// In en, this message translates to: + /// **'You are deciding as'** + String get approvalsReviewerLabel; + + /// No description provided for @approvalsReviewerUnverifiedPill. + /// + /// In en, this message translates to: + /// **'not verified'** + String get approvalsReviewerUnverifiedPill; + + /// No description provided for @approvalsReviewerRecordedAs. + /// + /// In en, this message translates to: + /// **'Recorded as: {value}'** + String approvalsReviewerRecordedAs(Object value); + + /// No description provided for @approvalsReviewerNoteAnonymous. + /// + /// In en, this message translates to: + /// **'This hub accepts calls without credentials, so a decision cannot be proven to be anyone\'s. Studio therefore marks the name inside the record itself as an unchecked claim.'** + String get approvalsReviewerNoteAnonymous; + + /// No description provided for @approvalsReviewerNoteAuthenticated. + /// + /// In en, this message translates to: + /// **'The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.'** + String get approvalsReviewerNoteAuthenticated; + + /// No description provided for @approvalsReviewerNoteUnknown. + /// + /// In en, this message translates to: + /// **'Studio could not read this hub\'s access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.'** + String get approvalsReviewerNoteUnknown; + + /// No description provided for @approvalsReviewerUnverifiedTooltip. + /// + /// In en, this message translates to: + /// **'Unchecked claim: this name comes from the deciding Studio, not from the hub.'** + String get approvalsReviewerUnverifiedTooltip; + /// No description provided for @approvalsRejectDialogHelp. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a26d558..a074c42 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1351,6 +1351,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get auditClearReviewerLabel => 'Prüfer'; + @override + String get auditClearReviewerHelper => + 'Wird als ungeprüfte Angabe festgehalten (Präfix „unverified:“) — der Hub übernimmt den Namen unverändert.'; + @override String get auditClearReasonLabel => 'Begründung (im chain.reset-Marker festgehalten)'; @@ -2062,6 +2066,33 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsIntroHelp => 'Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.'; + @override + String get approvalsReviewerLabel => 'Sie entscheiden als'; + + @override + String get approvalsReviewerUnverifiedPill => 'nicht überprüft'; + + @override + String approvalsReviewerRecordedAs(Object value) { + return 'Wird gespeichert als: $value'; + } + + @override + String get approvalsReviewerNoteAnonymous => + 'Dieser Hub nimmt Aufrufe ohne Zugangsdaten an — eine Entscheidung lässt sich damit niemandem nachweisen. Studio kennzeichnet den Namen deshalb im Eintrag selbst als ungeprüfte Angabe.'; + + @override + String get approvalsReviewerNoteAuthenticated => + 'Der Hub prüft Ihren Zugang, übernimmt den Namen aber unverändert von Studio. Bis der Hub die Identität aus dem geprüften Zugang selbst ableitet, kennzeichnet Studio den Namen im Eintrag als ungeprüfte Angabe.'; + + @override + String get approvalsReviewerNoteUnknown => + 'Studio konnte die Zugangs-Einstellungen dieses Hubs nicht lesen (dafür braucht es Admin-Rechte). Der Name wird unverändert übernommen und deshalb im Eintrag als ungeprüfte Angabe gekennzeichnet.'; + + @override + String get approvalsReviewerUnverifiedTooltip => + 'Ungeprüfte Angabe: Dieser Name stammt vom entscheidenden Studio, nicht vom Hub.'; + @override String get approvalsRejectDialogHelp => 'Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bf6c5d9..dd54266 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1364,6 +1364,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get auditClearReviewerLabel => 'Reviewer'; + @override + String get auditClearReviewerHelper => + 'Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.'; + @override String get auditClearReasonLabel => 'Reason (recorded in chain.reset marker)'; @@ -2066,6 +2070,33 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsIntroHelp => 'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'; + @override + String get approvalsReviewerLabel => 'You are deciding as'; + + @override + String get approvalsReviewerUnverifiedPill => 'not verified'; + + @override + String approvalsReviewerRecordedAs(Object value) { + return 'Recorded as: $value'; + } + + @override + String get approvalsReviewerNoteAnonymous => + 'This hub accepts calls without credentials, so a decision cannot be proven to be anyone\'s. Studio therefore marks the name inside the record itself as an unchecked claim.'; + + @override + String get approvalsReviewerNoteAuthenticated => + 'The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.'; + + @override + String get approvalsReviewerNoteUnknown => + 'Studio could not read this hub\'s access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.'; + + @override + String get approvalsReviewerUnverifiedTooltip => + 'Unchecked claim: this name comes from the deciding Studio, not from the hub.'; + @override String get approvalsRejectDialogHelp => 'Rejecting stops the process at this step and is recorded with your reason in the audit trail.'; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 4d05883..7b5e28f 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -1,11 +1,11 @@ import 'dart:convert'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../data/error_presentation.dart'; import '../data/hub.dart'; +import '../data/reviewer_identity.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -52,18 +52,26 @@ class _ApprovalsPageState extends State { /// and falls back to per-row Approve / Reject buttons. final Set _selectedIds = {}; bool _batchInFlight = false; - // Reviewer identity recorded in the audit log. Defaults to - // the OS user (closest stable identity Studio has without an - // auth backend); operators can override it per session. - late final String _reviewer = _defaultReviewer(); - static String _defaultReviewer() { - final user = - Platform.environment['USER'] ?? - Platform.environment['USERNAME'] ?? - 'studio'; - return '$user@studio'; - } + /// Operator-facing handle of whoever is deciding here. The OS + /// account is a label, not an identity — [HubService.approve] + /// marks it as an unchecked claim on the wire, and the strip + /// above the inbox says so in plain words. See + /// `lib/data/reviewer_identity.dart`. + final String _reviewer = ReviewerIdentity.localHandle; + + /// What actually goes into `decided_by` — the handle plus the + /// marker saying it is an unchecked client claim. Computed here + /// (not only inside [HubService], which normalises again as a + /// backstop) because the strip above the inbox shows the operator + /// this very string before they decide. + String get _reviewerWire => ReviewerIdentity.wire(_reviewer); + + /// What that attribution is worth on THIS hub. Starts unknown and + /// stays unknown when the policy cannot be read (AuthStatus is + /// admin-scoped, and old hubs answer UNIMPLEMENTED) — the strip + /// has an honest line for each case. + ReviewerAssurance _assurance = ReviewerAssurance.unknown; @override void initState() { @@ -71,6 +79,21 @@ class _ApprovalsPageState extends State { Workspace.instance.addListener(_refresh); Workspace.instance.ensureLoaded(); _refresh(); + _loadAssurance(); + } + + Future _loadAssurance() async { + ReviewerAssurance resolved; + try { + final policy = await HubService.instance.authStatus(); + resolved = reviewerAssuranceFor(policy.anonymousAllowed); + } catch (_) { + // Denied / unsupported / hub gone: not knowing is a state of + // its own, never an excuse to imply the attribution is sound. + resolved = ReviewerAssurance.unknown; + } + if (!mounted) return; + setState(() => _assurance = resolved); } @override @@ -127,7 +150,7 @@ class _ApprovalsPageState extends State { } if (!mounted) return; try { - await HubService.instance.approve(a.id, _reviewer); + await HubService.instance.approve(a.id, _reviewerWire); _toast(l.approvalsApprovedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { @@ -142,7 +165,7 @@ class _ApprovalsPageState extends State { final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; try { - await HubService.instance.reject(a.id, _reviewer, reason); + await HubService.instance.reject(a.id, _reviewerWire, reason); _toast(l.approvalsRejectedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { @@ -205,7 +228,7 @@ class _ApprovalsPageState extends State { Object? firstError; for (final a in picked) { try { - await HubService.instance.approve(a.id, _reviewer); + await HubService.instance.approve(a.id, _reviewerWire); ok += 1; _selectedIds.remove(a.id); } catch (e) { @@ -235,7 +258,7 @@ class _ApprovalsPageState extends State { Object? firstError; for (final a in picked) { try { - await HubService.instance.reject(a.id, _reviewer, reason); + await HubService.instance.reject(a.id, _reviewerWire, reason); ok += 1; _selectedIds.remove(a.id); } catch (e) { @@ -370,6 +393,8 @@ class _ApprovalsPageState extends State { children: [ _PendingList( future: _pendingFuture, + reviewer: _reviewer, + assurance: _assurance, selectedIds: _selectedIds, batchInFlight: _batchInFlight, onApprove: _approve, @@ -390,6 +415,8 @@ class _ApprovalsPageState extends State { class _PendingList extends StatelessWidget { final Future> future; + final String reviewer; + final ReviewerAssurance assurance; final Set selectedIds; final bool batchInFlight; final void Function(ApprovalRecord) onApprove; @@ -403,6 +430,8 @@ class _PendingList extends StatelessWidget { const _PendingList({ required this.future, + required this.reviewer, + required this.assurance, required this.selectedIds, required this.batchInFlight, required this.onApprove, @@ -453,6 +482,21 @@ class _PendingList extends StatelessWidget { ), child: ChainInlineHelp(text: l.approvalsIntroHelp), ), + // Who the audit trail will name, and what that name is + // worth — stated before the decision, not discovered + // afterwards by a lawyer reading the export. + Padding( + padding: const EdgeInsets.fromLTRB( + ChainSpace.xl, + ChainSpace.md, + ChainSpace.xl, + 0, + ), + child: _ReviewerIdentityStrip( + handle: reviewer, + assurance: assurance, + ), + ), Expanded( child: Stack( children: [ @@ -505,6 +549,108 @@ class _PendingList extends StatelessWidget { } } +/// "You are deciding as …" — names the attribution the audit trail +/// will carry and, in one plain sentence, what it is worth on this +/// hub. Studio cannot prove the identity (the hub stores whatever a +/// client sends as `decided_by`), so the honest move is to say that +/// out loud instead of letting the name pass for proof. +class _ReviewerIdentityStrip extends StatelessWidget { + final String handle; + final ReviewerAssurance assurance; + + const _ReviewerIdentityStrip({ + required this.handle, + required this.assurance, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final note = switch (assurance) { + ReviewerAssurance.anonymousHub => l.approvalsReviewerNoteAnonymous, + ReviewerAssurance.accessControlled => + l.approvalsReviewerNoteAuthenticated, + ReviewerAssurance.unknown => l.approvalsReviewerNoteUnknown, + }; + return Container( + padding: const EdgeInsets.all(ChainSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(ChainRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.badge_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Wrap, not Row: a long handle plus the pill must + // reflow instead of overflowing on narrow windows. + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: ChainSpace.sm, + runSpacing: 4, + children: [ + Text( + l.approvalsReviewerLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + SelectableText( + handle, + maxLines: 1, + style: ChainTheme.mono( + size: 12, + color: theme.colorScheme.onSurface, + ), + ), + ChainPill( + label: l.approvalsReviewerUnverifiedPill, + tone: ChainPillTone.warning, + icon: Icons.gpp_maybe_outlined, + ), + ], + ), + const SizedBox(height: 4), + Text( + note, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 4), + // The literal value that lands in `decided_by`, so + // nobody is surprised by the prefix when they read + // the log, the CLI output or a DB export. + SelectableText( + l.approvalsReviewerRecordedAs( + ReviewerIdentity.wire(handle), + ), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + /// Floating action bar that surfaces when the operator /// multi-selects pending approvals. Lets them approve or /// reject the whole picked set in one round-trip per item; @@ -967,13 +1113,7 @@ class _HistoryRow extends StatelessWidget { ), ), const SizedBox(width: ChainSpace.md), - if (record.decidedBy.isNotEmpty) - Text( - record.decidedBy, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), + if (record.decidedBy.isNotEmpty) _DecidedBy(record.decidedBy), ], ), ), @@ -1002,6 +1142,58 @@ class _HistoryRow extends StatelessWidget { } } +/// Renders a stored `decided_by`. A value Studio wrote carries the +/// `unverified:` marker — show the readable name and keep the marker +/// visible as a warning glyph rather than dropping either. A value +/// without the marker (legacy row, a CLI decision) gets no badge at +/// all: Studio does not know where it came from and must not imply +/// it was checked. +class _DecidedBy extends StatelessWidget { + final String recorded; + const _DecidedBy(this.recorded); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final parsed = ReviewerIdentity.parse(recorded); + final name = Text( + parsed.handle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ); + if (!parsed.isSelfDeclared) return name; + return Tooltip( + message: l.approvalsReviewerUnverifiedTooltip, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.gpp_maybe_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Flexible(child: name), + ], + ), + ); + } +} + +/// "who decided" for the detail dialog's one-line summary: the +/// readable name, and — when Studio wrote it — the plain-words +/// hint that the attribution is an unchecked claim. +String _decidedByLabel(BuildContext context, String recorded) { + final l = AppLocalizations.of(context)!; + if (recorded.isEmpty) return l.approvalsUnknownReviewer; + final parsed = ReviewerIdentity.parse(recorded); + if (!parsed.isSelfDeclared) return parsed.handle; + return '${parsed.handle} (${l.approvalsReviewerUnverifiedPill})'; +} + class _HistoryDialog extends StatelessWidget { final ApprovalRecord record; const _HistoryDialog({required this.record}); @@ -1039,7 +1231,7 @@ class _HistoryDialog extends StatelessWidget { theme, AppLocalizations.of(context)!.approvalsDialogDecided, '${_formatTimestamp(record.decidedAt!.toLocal())} ' - '· ${record.decidedBy.isEmpty ? AppLocalizations.of(context)!.approvalsUnknownReviewer : record.decidedBy}', + '· ${_decidedByLabel(context, record.decidedBy)}', ), if (record.createdAt != null) _kv( diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 6dda034..1dd0012 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; import '../data/friendly_error.dart'; import '../data/hub.dart'; +import '../data/reviewer_identity.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -1258,7 +1259,9 @@ class _ExplanationPanel extends StatelessWidget { } } -/// Outcome of the clear-audit confirmation dialog. +/// Outcome of the clear-audit confirmation dialog. [reviewer] is +/// already the wire value — the marked, unchecked claim that lands +/// in the `chain.reset` marker (see `data/reviewer_identity.dart`). class _ClearOutcome { final String reviewer; final String reason; @@ -1266,10 +1269,11 @@ class _ClearOutcome { } /// Two-field confirmation dialog for "clear the audit log". -/// Reviewer defaults to the OS user (closest stable identity -/// without an auth backend); reason has no default so the -/// operator has to type *something* — the chain.reset marker -/// must carry context. +/// Reviewer prefills with the local handle (the OS account is a +/// label, not an identity — [HubService.clearEventLog] marks it as +/// an unchecked claim on the wire, and the field's helper says so); +/// reason has no default so the operator has to type *something* — +/// the chain.reset marker must carry context. class _ClearAuditDialog extends StatefulWidget { const _ClearAuditDialog(); @@ -1291,11 +1295,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { @override void initState() { super.initState(); - final user = - Platform.environment['USER'] ?? - Platform.environment['USERNAME'] ?? - 'operator'; - _reviewer = TextEditingController(text: '$user@studio'); + _reviewer = TextEditingController(text: ReviewerIdentity.localHandle); _reason = TextEditingController(); } @@ -1329,6 +1329,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { controller: _reviewer, decoration: InputDecoration( labelText: l.auditClearReviewerLabel, + helperText: l.auditClearReviewerHelper, + helperMaxLines: 3, border: const OutlineInputBorder(), isDense: true, ), @@ -1365,7 +1367,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { : () => Navigator.pop( context, _ClearOutcome( - reviewer: _reviewer.text.trim(), + reviewer: ReviewerIdentity.wire(_reviewer.text), reason: _reason.text.trim(), ), ), diff --git a/test/approvals_reviewer_identity_test.dart b/test/approvals_reviewer_identity_test.dart new file mode 100644 index 0000000..58e77bf --- /dev/null +++ b/test/approvals_reviewer_identity_test.dart @@ -0,0 +1,288 @@ +// Reviewer identity on the surfaces that write it — approvals page +// and the audit-log wipe. +// +// Guard for the legal finding of the 2026-07-26 usertest: the hub +// stores whatever reviewer string a client sends as `decided_by`, +// so Studio must (a) never send a bare handle that reads like a +// proven identity, (b) tell the reviewer BEFORE the decision what +// the attribution is worth on this hub, and (c) present a stored +// value by what it actually proves. +// +// 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/data/reviewer_identity.dart'; +import 'package:chain_studio/main.dart'; + +import 'support/fake_hub.dart'; + +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(); +} + +ApprovalRecord _pending() => 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', +); + +ApprovalRecord _decided(String decidedBy) => ApprovalRecord( + id: 'apr-2', + flowName: 'classify-and-file', + stepId: 'review', + prompt: 'Bitte die Klassifikation prüfen', + payloadPreview: null, + createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), + expiresAt: null, + status: 'approved', + decidedAt: DateTime.utc(2026, 7, 26, 12, 5, 0), + decidedBy: decidedBy, + reason: '', + project: 'lbs', + flowExecution: 'run-abc123', +); + +/// Fixed frame budget instead of `pumpAndSettle`: the page keeps a +/// progress indicator alive on the hidden tab, so settling never +/// completes (same reason the origin suite pumps explicitly). +Future _frames(WidgetTester tester, [int count = 12]) async { + for (var i = 0; i < count; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } +} + +/// Boot Studio on the approvals page against [fake]. +Future _openApprovals(WidgetTester tester, FakeHubService fake) async { + 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)); + } +} + +Future _teardownFrames(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); +} + +void main() { + setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio'); + tearDown(() => ReviewerIdentity.debugHandle = null); + + testWidgets('the inbox names who will be recorded, flags the claim as ' + 'unchecked and shows the literal stored value', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_pending()]; + await _openApprovals(tester, fake); + final text = _allText(tester); + + expect(text, contains('Sie entscheiden als')); + expect(text, contains('stefan@studio')); + expect(text, contains('nicht überprüft')); + // No surprise later: the exact string that lands in decided_by. + expect(text, contains('unverified:stefan@studio')); + await _teardownFrames(tester); + }); + + testWidgets('an anonymous hub says a decision cannot be pinned on ' + 'anyone', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_pending()]; + // Default fake policy: static validator, anonymous allowed. + await _openApprovals(tester, fake); + expect(_allText(tester), contains('ohne Zugangsdaten')); + await _teardownFrames(tester); + }); + + testWidgets('an auth-enabled hub says the access is checked but the ' + 'name is not', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_pending()]; + fake.authPolicy = const HubAuthPolicy( + validator: 'static', + anonymousAllowed: false, + tokens: [], + ); + await _openApprovals(tester, fake); + expect(_allText(tester), contains('prüft Ihren Zugang')); + await _teardownFrames(tester); + }); + + testWidgets('an unreadable auth policy stays honest instead of ' + 'guessing', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_pending()]; + // AuthStatus is admin-scoped: a plain reviewer token is denied. + fake.authPolicy = null; + await _openApprovals(tester, fake); + expect(_allText(tester), contains('Admin-Rechte')); + // Still flagged — not knowing never upgrades the attribution. + expect(_allText(tester), contains('nicht überprüft')); + await _teardownFrames(tester); + }); + + testWidgets('approving sends the marked attribution, never a bare ' + 'handle', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_pending()]; + await _openApprovals(tester, fake); + + await tester.tap(find.widgetWithText(FilledButton, 'Freigeben').first); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + + expect(fake.decisions, hasLength(1)); + expect(fake.decisions.single.id, 'apr-1'); + expect(fake.decisions.single.reviewer, 'unverified:stefan@studio'); + expect( + fake.decisions.single.reviewer, + startsWith(kUnverifiedReviewerPrefix), + reason: 'the decide path must not claim an identity Studio ' + 'cannot prove', + ); + await _teardownFrames(tester); + }); + + testWidgets('rejecting sends the marked attribution too', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_pending()]; + await _openApprovals(tester, fake); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Ablehnen').first); + await _frames(tester); + await tester.enterText(find.byType(TextField).last, 'Beleg fehlt'); + await _frames(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Ablehnen').last); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + + expect(fake.decisions, hasLength(1)); + expect(fake.decisions.single.reviewer, 'unverified:stefan@studio'); + expect(fake.decisions.single.reason, 'Beleg fehlt'); + await _teardownFrames(tester); + }); + + testWidgets('the history shows a marked value by its name and keeps ' + 'the "unchecked" flag', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_decided('unverified:anna@ops')]; + await _openApprovals(tester, fake); + await tester.tap(find.text('Verlauf')); + await _frames(tester); + + final text = _allText(tester); + expect(text, contains('anna@ops')); + // The raw marker is not what a human should read in the list. + expect(text, isNot(contains('unverified:anna@ops'))); + + // The detail dialog spells the trust level out in words. + await tester.tap(find.textContaining('classify-and-file').last); + await _frames(tester); + expect(_allText(tester), contains('anna@ops (nicht überprüft)')); + await _teardownFrames(tester); + }); + + testWidgets('the audit-log wipe records the same kind of marked ' + 'attribution in the chain.reset marker', (tester) async { + // Same bug class as approvals: the reset marker names a + // "Prüfer" the hub never checked, and auditors read exactly + // that marker to explain a chain discontinuity. + final fake = installFakeHub(); + 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-audit'))); + await _frames(tester); + + await tester.tap(find.byTooltip('Weitere Aktionen')); + await _frames(tester); + // Tap the entry, not its label: the overflow menu's labels are + // wider than the 256px menu, so the label's centre lies outside + // the hit box (a separate, unrelated layout defect on that + // menu). The entry itself is what the operator hits. + await tester.tap( + find.ancestor( + of: find.textContaining('Entwicklungs-Reset'), + matching: find.byType(PopupMenuItem), + ), + ); + await _frames(tester); + + // The field says what will be stored, before it is stored. + expect(_allText(tester), contains('unverified:')); + + await tester.enterText(find.byType(TextField).last, 'Testdaten verworfen'); + await _frames(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Log löschen')); + await _frames(tester); + + expect(fake.clears, hasLength(1)); + expect(fake.clears.single.reviewer, 'unverified:stefan@studio'); + expect(fake.clears.single.reason, 'Testdaten verworfen'); + await _teardownFrames(tester); + }); + + testWidgets('a legacy / CLI value without a marker is shown as-is and ' + 'never labelled either way', (tester) async { + final fake = installFakeHub(); + fake.approvals = [_decided('ops-lead')]; + await _openApprovals(tester, fake); + await tester.tap(find.text('Verlauf')); + await _frames(tester); + + await tester.tap(find.textContaining('classify-and-file').last); + await _frames(tester); + final text = _allText(tester); + expect(text, contains('ops-lead')); + expect( + text, + isNot(contains('ops-lead (nicht überprüft)')), + reason: 'Studio does not know where an unmarked value came ' + 'from and must not classify it', + ); + await _teardownFrames(tester); + }); +} diff --git a/test/reviewer_identity_test.dart b/test/reviewer_identity_test.dart new file mode 100644 index 0000000..e9a6872 --- /dev/null +++ b/test/reviewer_identity_test.dart @@ -0,0 +1,105 @@ +// Reviewer identity — the attribution Studio writes into the audit +// trail must never look better than it is. +// +// Background (usertest 2026-07-26, legal persona): the hub copies +// the client's `reviewer` string into `decided_by` unchecked, so a +// bare "stefan@studio" in an export reads like a proven identity +// while being an arbitrary client claim. Until the hub derives the +// value from the authenticated caller (contract: +// docs/reviewer-identity.md), every value Studio sends carries the +// `unverified:` marker — and everything Studio *reads* is presented +// by what it actually proves. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/reviewer_identity.dart'; + +void main() { + setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio'); + tearDown(() => ReviewerIdentity.debugHandle = null); + + group('what Studio sends', () { + test('every reviewer string leaves Studio marked as a claim', () { + expect(ReviewerIdentity.wire(), 'unverified:stefan@studio'); + expect(ReviewerIdentity.wire('anna@ops'), 'unverified:anna@ops'); + }); + + test('marking is idempotent — a value that already carries the ' + 'marker is not wrapped twice', () { + final once = ReviewerIdentity.wire('anna@ops'); + expect(ReviewerIdentity.wire(once), once); + // The page computes the wire value for display AND HubService + // normalises again as a backstop; that must stay harmless. + expect(ReviewerIdentity.wire(ReviewerIdentity.wire(once)), once); + }); + + test('a blank handle falls back to the local one instead of ' + 'sending an empty attribution', () { + expect(ReviewerIdentity.wire(' '), 'unverified:stefan@studio'); + expect(ReviewerIdentity.wire(''), 'unverified:stefan@studio'); + }); + + test('surrounding whitespace never reaches the record', () { + expect(ReviewerIdentity.wire(' anna@ops '), 'unverified:anna@ops'); + }); + + test('the local handle is a label, not an identity — but always ' + 'present', () { + ReviewerIdentity.debugHandle = null; + expect(ReviewerIdentity.localHandle, isNotEmpty); + expect( + ReviewerIdentity.wire(), + startsWith(kUnverifiedReviewerPrefix), + reason: 'no code path may send a bare handle', + ); + }); + }); + + group('what Studio reads back', () { + test('a marked value is shown by its name and flagged as a claim', () { + final parsed = ReviewerIdentity.parse('unverified:anna@ops'); + expect(parsed.handle, 'anna@ops'); + expect(parsed.trust, RecordedReviewerTrust.selfDeclared); + expect(parsed.isSelfDeclared, isTrue); + }); + + test('an unmarked value (legacy row, CLI decision) is never ' + 'presented as checked', () { + final parsed = ReviewerIdentity.parse('stefan@studio'); + expect(parsed.handle, 'stefan@studio'); + expect(parsed.trust, RecordedReviewerTrust.unknown); + expect( + parsed.isSelfDeclared, + isFalse, + reason: 'unknown provenance must not be labelled either way', + ); + }); + + test('a marker with nothing behind it keeps the marker visible ' + 'rather than rendering an empty reviewer', () { + final parsed = ReviewerIdentity.parse('unverified:'); + expect(parsed.handle, 'unverified:'); + expect(parsed.isSelfDeclared, isTrue); + }); + + test('round-trips what Studio wrote', () { + final parsed = ReviewerIdentity.parse(ReviewerIdentity.wire()); + expect(parsed.handle, 'stefan@studio'); + expect(parsed.isSelfDeclared, isTrue); + }); + }); + + group('assurance level per hub policy', () { + test('anonymous hub — nobody can be tied to a decision', () { + expect(reviewerAssuranceFor(true), ReviewerAssurance.anonymousHub); + }); + + test('auth-enabled hub — the access is checked, the name is not', () { + expect(reviewerAssuranceFor(false), ReviewerAssurance.accessControlled); + }); + + test('unreadable policy stays unknown — never optimistic', () { + expect(reviewerAssuranceFor(null), ReviewerAssurance.unknown); + }); + }); +} diff --git a/test/support/fake_hub.dart b/test/support/fake_hub.dart index 542638a..21b166d 100644 --- a/test/support/fake_hub.dart +++ b/test/support/fake_hub.dart @@ -230,6 +230,55 @@ class FakeHubService extends Fake implements HubService { Future> listSatellites() => _async('listSatellites', () => const []); + /// Auth policy the pages read (approvals derives from it what a + /// reviewer attribution is worth). Default: the local-dev hub — + /// static validator, anonymous calls allowed. Set to `null` to + /// script a hub whose policy Studio may not read; `authStatus` + /// then fails like a PermissionDenied. + HubAuthPolicy? authPolicy = const HubAuthPolicy( + validator: 'static', + anonymousAllowed: true, + tokens: [], + ); + + @override + Future authStatus() => _async('authStatus', () { + final policy = authPolicy; + if (policy == null) { + throw const FakeGrpcError(7, 'admin scope required'); + } + return policy; + }); + + /// Every decision that reached the wire, in order — the guard for + /// "Studio never claims an identity it cannot prove" inspects the + /// reviewer string exactly as the hub would store it. + final List<({String id, String reviewer, String? reason})> decisions = []; + + /// Reviewer + reason handed to the audit-log wipe, same purpose. + final List<({String reviewer, String reason})> clears = []; + + @override + Future<({int purged, String channel})> clearEventLog({ + required String reviewer, + required String reason, + }) => _async('clearEventLog', () { + clears.add((reviewer: reviewer, reason: reason)); + return (purged: 0, channel: 'local'); + }); + + @override + Future approve(String id, String reviewer) => _async( + 'approve', + () => decisions.add((id: id, reviewer: reviewer, reason: null)), + ); + + @override + Future reject(String id, String reviewer, String reason) => _async( + 'reject', + () => decisions.add((id: id, reviewer: reviewer, reason: reason)), + ); + @override Future systemAiStatus() => _async( 'systemAiStatus', From 906290f445a83f168102401607f41ff3cd2ac13d Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 27 Aug 2026 23:38:16 +0200 Subject: [PATCH 06/10] =?UTF-8?q?docs:=20handover=20=E2=80=94=20workspace-?= =?UTF-8?q?switcher=20rebuild=20package=20from=20persona=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: flemming-it --- HANDOVER-2026-08-27.md | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 HANDOVER-2026-08-27.md diff --git a/HANDOVER-2026-08-27.md b/HANDOVER-2026-08-27.md new file mode 100644 index 0000000..90228af --- /dev/null +++ b/HANDOVER-2026-08-27.md @@ -0,0 +1,68 @@ +# HANDOVER 2026-08-27 — Switcher-Paket (Bereichs-/Projekt-UX) + +Ziel der nächsten Session: das per Persona-Check (Noten 4/3/2) +definierte Umbaupaket am Stück bauen. Details + Belege: +Memory `workspace-switcher-persona-2026-08-27` lesen. + +## Reihenfolge (HOCH zuerst) + +1. **Umschalt-Race schließen** (Datenschützer, HOCH): In + `lib/data/workspace.dart` (`switchToSealed`, ~Z. 169–204) zeigt + der Client nach `HubService.reconnect` (Z. 188) schon auf den + Mandanten-Hub, bevor `_activeSealed` gesetzt/notifiziert ist — + 2-s-Poller (`runs.dart:218`, `audit.dart:94`) können sealed-Daten + UNMARKIERT rendern. Fix: expliziter `switching`-Zustand VOR dem + Reconnect (Identity-Bar optimistisch „Wechsle in Bereich …", + Poller pausieren), Freigabe erst nach `healthy()`. + Wächter-Test (state_matrix-Stil): „Client-Endpoint ≠ angezeigter + Kontext" darf nie vorkommen. +2. **Filter-Restaurierung** (Poweruser, HOCH): `workspace.dart:202` + löscht den Projekt-Slug beim Bereichseintritt ohne Prefs-Write; + `switchToShared` (Z. 210–222) restauriert nur `_sharedEndpoint`. + Analog den Slug merken + restaurieren. +3. **Globaler Anker**: Switcher EINMAL in die Shell (Sidebar links + oben, `main.dart`), die 4 Einzel-Einbindungen raus + (`flows.dart:227`, `runs.dart:329`, `audit.dart:295`, + `approvals.dart:376`). Kontext damit auf allen Seiten sichtbar. +4. **Bestätigungsdialog** vor Auto-Start gestoppter Bereiche + (`workspace.dart:169–179`): „Bereich ‚X' starten und verbinden?" + — laufende Bereiche weiter 1 Klick. +5. **Tooltip-Lüge fixen**: `app_de.arb:1836` / `app_en.arb:1875` + „filtert diese Ansicht" → wirkt global (Flows, Läufe, Audit, + Freigaben + Stempel neuer Läufe). +6. **Aggregat erklären**: Warum-Zeile an der „N abgeschottete + Bereiche"-Zeile (`chain_workspace_switcher.dart:334–390`) + + Link auf den Settings-Schalter. Aggregation BLEIBT (bewusste + Vertraulichkeitsentscheidung, vom Datenschützer bestätigt). +7. **Poweruser-Komfort**: Cmd+K-Palette kennt Projekte/Bereiche + (`chain_search_palette.dart`), Kürzel für den Switcher, Häkchen + am aktiven Eintrag, Zuletzt-benutzt; Läufe-Leerzustand bei + aktivem Filter ehrlich („Keine Läufe in Projekt X" statt + `runsEmptyHint`, `runs.dart:357–369`). +8. **Beispiel-Chip** (offen vom 23.08.): Hub liefert + `FlowSummary.sample` (Wire-Feld 6, gepusht in fai_chain + `4cd5122`); ERST Dart-Proto-Regen im Client-SDK + (`fai_chain_client_sdk_dart`), DANN Flows-Liste: „Beispiel"-Chip + + Beispiele eingeklappt + „Beispiele importieren"-Aktion im + leeren sealed-Zustand. Alte Hubs ohne Feld → false → keine Chips + (ehrlich). Skew-Gate (`scripts/skew-smoke.sh`) laufen lassen. + +## Prüfpflichten + +Studio-Regeln: Selbstverifikation per Screenshot (hell+dunkel), +l10n DE+EN, kopierbare Fehler, Inline-Hilfe für neue Flächen +(doc_help_wiring_test), Canon-Wächter. Hub-Seite ist KOMPLETT +(sealed seedet nicht mehr, Prune live auf den echten Bereichen +verifiziert, Backfill für alte Instanz-Configs in `chain project +start`). + +## Fallen + +- Kundenbereichs-Slugs nie in Commit-Messages — der + Security-Hook blockt (richtig so). +- `!`-Befehle des Nutzers haben 2-min-Timeout; lange Gates selbst + puschen. +- Scratchpad überlebt nicht: der hermetische OpenAI-Wire-Fake + (fake_vllm.py, chunked-Encoding-Handling!) ist weg — bei Bedarf + neu aus der Beschreibung in fai_chain-Commit `ad6c772` bzw. den + llm.rs-Unit-Tests ableiten. From afe782e826e3fc883537b81849b608602ca62804 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 27 Aug 2026 23:48:03 +0200 Subject: [PATCH 07/10] fix(workspace): close the sealed-switch privacy race, restore the parked filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the workspace persona review, both rated high: * Switch race: the hub client re-pointed at a sealed area's hub before the workspace announced the sealed context, so the 2 s page pollers (runs, audit) could fetch and render that hub's data without the sealed marking. Switches now run inside an explicit switching window: opened before anything touches the connection, announced optimistically in the identity bar ("switching…" + spinner, leave button hidden), pollers and the shell health tick pause inside it, and pages drop replies whose context epoch changed mid-flight. The sealed context is announced only after the new hub answered healthy. * Filter loss: entering a sealed area cleared the shared-hub project filter and returning restored only the endpoint. The filter is now parked on entry and restored on return; prefs keep the parked value throughout, so live state and prefs agree after the round trip (and after a mid-session relaunch). Guard: workspace_switch_race_test pins both invariants state-matrix-style against scripted hub + sealed-area fakes — reconnects may only happen inside an open switch window, pollers must stay silent inside it, and the filter must survive the round trip. SealedAreaService gained a debugSetInstance seam so the suite never scans a real ~/.chain. Signed-off-by: flemming-it --- lib/data/sealed_areas.dart | 18 +- lib/data/workspace.dart | 146 ++++++++--- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_de.dart | 4 + lib/l10n/app_localizations_en.dart | 4 + lib/main.dart | 4 + lib/pages/approvals.dart | 3 + lib/pages/audit.dart | 20 +- lib/pages/runs.dart | 11 +- lib/widgets/chain_sealed_identity_bar.dart | 34 ++- test/workspace_switch_race_test.dart | 280 +++++++++++++++++++++ 13 files changed, 480 insertions(+), 52 deletions(-) create mode 100644 test/workspace_switch_race_test.dart diff --git a/lib/data/sealed_areas.dart b/lib/data/sealed_areas.dart index cfc0d38..9a07f31 100644 --- a/lib/data/sealed_areas.dart +++ b/lib/data/sealed_areas.dart @@ -19,6 +19,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; + /// One sealed project instance as Studio needs it for the switcher /// and the connection switch. class SealedArea { @@ -63,7 +65,21 @@ class SealedArea { class SealedAreaService { SealedAreaService._(); - static final SealedAreaService instance = SealedAreaService._(); + static SealedAreaService instance = SealedAreaService._(); + + /// Test hook (HubService.debugSetInstance pattern): swap in a fake + /// so suites never scan the operator's real `~/.chain`. Pass null + /// to restore the real service. + @visibleForTesting + static void debugSetInstance(SealedAreaService? replacement) { + instance = replacement ?? SealedAreaService._(); + } + + /// Extension seam for the test fake — the real constructor is + /// library-private, and the fake must not inherit a live + /// filesystem scan by accident, so it overrides the members. + @visibleForTesting + SealedAreaService.forTest(); /// `~/.chain` root, or null when the home dir can't be determined. String? _chainHome() { diff --git a/lib/data/workspace.dart b/lib/data/workspace.dart index a726808..4ba608a 100644 --- a/lib/data/workspace.dart +++ b/lib/data/workspace.dart @@ -46,6 +46,28 @@ class Workspace extends ChangeNotifier { SealedArea? get activeSealed => _activeSealed; bool get inSealedArea => _activeSealed != null; + /// True while a sealed-area connection switch (either direction) + /// is in flight. Set BEFORE the hub client re-points and cleared + /// only after the new context is fully announced, so pages pause + /// their pollers and the identity bar can mark the transition — + /// otherwise a 2 s poll could render the other hub's data under + /// the old context's marking (privacy race). + bool _switching = false; + bool get switching => _switching; + + /// The area being entered while [switching]; null when the switch + /// returns to the shared hub. Lets the identity bar announce the + /// target optimistically ("switching to X…"). + SealedArea? _switchTarget; + SealedArea? get switchTarget => _switchTarget; + + /// Monotonic counter bumped each time a connection switch + /// completes. Pages capture it before an async fetch and drop the + /// reply when it changed — a response from the previous hub must + /// never render under the new context's marking. + int _contextEpoch = 0; + int get contextEpoch => _contextEpoch; + /// Active project slug for the shared-hub filter; empty = all /// projects. Meaningful only when [inSealedArea] is false. String _activeSlug = ''; @@ -119,6 +141,10 @@ class Workspace extends ChangeNotifier { _activeSlug = active; _sealed = sealed; _activeSealed = activeSealed; + _switching = false; + _switchTarget = null; + _sharedSlug = null; + _sharedEndpoint = null; _loaded = true; notifyListeners(); } @@ -163,53 +189,79 @@ class Workspace extends ChangeNotifier { // there — capture it before the first sealed switch. _sharedEndpoint ??= HubService.instance.currentEndpoint; - var started = false; - // Re-read the current running state (the list may be stale). - final live = await _reread(area.slug) ?? area; - if (!live.running) { - final r = await SystemActions.chainProjectStart(area.slug); - if (!r.ok) { + // Open the switch window BEFORE anything touches the connection. + _beginSwitch(area); + try { + var started = false; + // Re-read the current running state (the list may be stale). + final live = await _reread(area.slug) ?? area; + if (!live.running) { + final r = await SystemActions.chainProjectStart(area.slug); + if (!r.ok) { + return _switchResult( + ok: false, + error: r.stderr.isEmpty ? r.stdout : r.stderr, + ); + } + started = true; + // Give the daemon a moment to bind + write its endpoint file. + await Future.delayed(const Duration(milliseconds: 800)); + } + + // Prefer the endpoint the daemon actually wrote; fall back to + // the manifest port. + final ep = await SealedAreaService.instance.boundEndpoint(area.slug); + final endpoint = _parseEndpoint(ep) ?? + HubEndpoint(host: '127.0.0.1', port: area.port); + + await HubService.instance.reconnect(endpoint, persist: false); + final healthy = await HubService.instance.healthy(); + if (!healthy) { + // Roll back to the shared hub so Studio isn't stranded. The + // sealed context was never announced, so only the endpoint + // needs restoring — filter and marking are untouched. + await _reconnectShared(); return _switchResult( ok: false, - error: r.stderr.isEmpty ? r.stdout : r.stderr, + started: started, + error: 'Sealed area "${area.name}" did not respond on ' + '${endpoint.host}:${endpoint.port} after start.', ); } - started = true; - // Give the daemon a moment to bind + write its endpoint file. - await Future.delayed(const Duration(milliseconds: 800)); + + _activeSealed = area; + // Entering from the shared hub: park the filter so the round + // trip restores it. (Area→area keeps the first park.) + _sharedSlug ??= _activeSlug; + _activeSlug = ''; // the shared-hub filter does not apply here + await refresh(); + return _switchResult(started: started); + } finally { + _endSwitch(); } - - // Prefer the endpoint the daemon actually wrote; fall back to - // the manifest port. - final ep = await SealedAreaService.instance.boundEndpoint(area.slug); - final endpoint = _parseEndpoint(ep) ?? - HubEndpoint(host: '127.0.0.1', port: area.port); - - await HubService.instance.reconnect(endpoint, persist: false); - final healthy = await HubService.instance.healthy(); - if (!healthy) { - // Roll back to the shared hub so Studio isn't stranded. - await switchToShared(); - return _switchResult( - ok: false, - started: started, - error: 'Sealed area "${area.name}" did not respond on ' - '${endpoint.host}:${endpoint.port} after start.', - ); - } - - _activeSealed = area; - _activeSlug = ''; // the shared-hub filter does not apply here - await refresh(); - notifyListeners(); - return _switchResult(started: started); } /// Return to the shared hub from a sealed area. No-op when already - /// on the shared hub. + /// on the shared hub. Restores the project filter that was active + /// before the sealed round trip — it was parked, not dropped. Future switchToShared() async { if (_activeSealed == null) return; - _activeSealed = null; + _beginSwitch(null); + try { + await _reconnectShared(); + _activeSealed = null; + _activeSlug = _sharedSlug ?? ''; + _sharedSlug = null; + await refresh(); + } finally { + _endSwitch(); + } + } + + /// Point the hub client back at the shared hub (endpoint only — + /// no context bookkeeping). Shared by [switchToShared] and the + /// failed-switch rollback. + Future _reconnectShared() async { final shared = _sharedEndpoint; if (shared != null) { await HubService.instance.reconnect(shared, persist: false); @@ -217,12 +269,30 @@ class Workspace extends ChangeNotifier { // Fall back to re-discovering the installed channel. await HubService.instance.loadPersistedEndpoint(); } - await refresh(); + } + + void _beginSwitch(SealedArea? target) { + _switching = true; + _switchTarget = target; + notifyListeners(); + } + + void _endSwitch() { + _switching = false; + _switchTarget = null; + _contextEpoch++; notifyListeners(); } HubEndpoint? _sharedEndpoint; + /// The shared-hub project filter parked during a sealed session, + /// or null when none is parked. Prefs keep the parked value (they + /// always describe the SHARED hub's filter), so live state and + /// prefs agree again after the round trip — and a launch after a + /// kill inside a sealed area still restores the filter. + String? _sharedSlug; + Future _reread(String slug) async { try { final all = await SealedAreaService.instance.list(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e40fab3..c0b5d6f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1877,6 +1877,7 @@ "workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}", "@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}}, "sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette", + "sealedSwitchingBar": "Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss", "sealedLeave": "Verlassen", "setupStepOf": "Schritt {n} von {total}", "@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 51012a2..7376af6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1916,6 +1916,7 @@ "workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}", "@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}}, "sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain", + "sealedSwitchingBar": "Switching connection — data appears once the switch completes", "sealedLeave": "Leave", "setupStepOf": "Step {n} of {total}", "@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 241658b..453356a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5977,6 +5977,12 @@ abstract class AppLocalizations { /// **'Sealed area — isolated hub, own data and audit chain'** String get sealedIdentityBar; + /// No description provided for @sealedSwitchingBar. + /// + /// In en, this message translates to: + /// **'Switching connection — data appears once the switch completes'** + String get sealedSwitchingBar; + /// No description provided for @sealedLeave. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a074c42..e4f14ec 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3556,6 +3556,10 @@ class AppLocalizationsDe extends AppLocalizations { String get sealedIdentityBar => 'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette'; + @override + String get sealedSwitchingBar => + 'Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss'; + @override String get sealedLeave => 'Verlassen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dd54266..5e22e89 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3548,6 +3548,10 @@ class AppLocalizationsEn extends AppLocalizations { String get sealedIdentityBar => 'Sealed area — isolated hub, own data and audit chain'; + @override + String get sealedSwitchingBar => + 'Switching connection — data appears once the switch completes'; + @override String get sealedLeave => 'Leave'; diff --git a/lib/main.dart b/lib/main.dart index 5e1d1b7..9342523 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -553,6 +553,10 @@ class StudioShellState extends State { } Future _checkHealth({bool retriedAfterTokenReload = false}) async { + // Skip the tick while a sealed-area switch is in flight — the + // client points between contexts and a probe result (or the + // approvals badge) would be attributed to the wrong hub. + if (Workspace.instance.switching) return; final probe = debugProbeOverride != null ? await debugProbeOverride!() : await HubService.instance.probeHealth(); diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 7b5e28f..ca7da0d 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -104,6 +104,9 @@ class _ApprovalsPageState extends State { void _refresh() { if (!mounted) return; + // Mid-switch the client points between contexts — hold off; the + // end-of-switch notify lands here again with a settled client. + if (Workspace.instance.switching) return; final project = Workspace.instance.activeSlug; setState(() { _pendingFuture = HubService.instance.listApprovalsRecords( diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 1dd0012..b84a35a 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -109,6 +109,9 @@ class _AuditPageState extends State { /// query and the live stream have to be re-established. void _onWorkspaceChanged() { if (!mounted) return; + // Mid-switch the client points between contexts — hold off; the + // end-of-switch notify lands here again with a settled client. + if (Workspace.instance.switching) return; _refresh(); _subscribeLive(); } @@ -132,7 +135,12 @@ class _AuditPageState extends State { // suite caught this as a pending-timer flake). _reconnect?.cancel(); _reconnect = Timer(const Duration(seconds: 3), () { - if (mounted && _eventSub == null) _subscribeLive(); + // Not mid-switch: the end-of-switch notify resubscribes. + if (mounted && + _eventSub == null && + !Workspace.instance.switching) { + _subscribeLive(); + } }); } }, @@ -262,19 +270,25 @@ class _AuditPageState extends State { // copy-affordance). Future _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. + if (ws.switching) return; + final epoch = ws.contextEpoch; try { final events = await HubService.instance.recentEvents( limit: 100, project: Workspace.instance.activeSlug, ); - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _events = events; _error = null; _initialLoaded = true; }); } catch (e) { - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _error = e; _initialLoaded = true; diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index d841ad2..1961972 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -226,11 +226,18 @@ class _RunsPageState extends State { } Future _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) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _runs = snapshot.runs; _detachedEnabled = snapshot.enabled; @@ -238,7 +245,7 @@ class _RunsPageState extends State { _loaded = true; }); } catch (e) { - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _error = e; _issue = classifyRunsLoadError(e); diff --git a/lib/widgets/chain_sealed_identity_bar.dart b/lib/widgets/chain_sealed_identity_bar.dart index 75f9d59..61acab5 100644 --- a/lib/widgets/chain_sealed_identity_bar.dart +++ b/lib/widgets/chain_sealed_identity_bar.dart @@ -22,7 +22,15 @@ class ChainSealedIdentityBar extends StatelessWidget { return ListenableBuilder( listenable: Workspace.instance, builder: (context, _) { - final area = Workspace.instance.activeSealed; + final ws = Workspace.instance; + // While a switch is in flight the bar announces it BEFORE the + // client re-points (privacy race guard): entering shows the + // target area, leaving keeps the current area's marking — + // whatever is still on screen is that area's data. + final switching = ws.switching; + final area = switching + ? (ws.switchTarget ?? ws.activeSealed) + : ws.activeSealed; if (area == null) return const SizedBox.shrink(); final theme = Theme.of(context); final l = AppLocalizations.of(context)!; @@ -51,7 +59,7 @@ class ChainSealedIdentityBar extends StatelessWidget { const SizedBox(width: ChainSpace.md), Expanded( child: Text( - l.sealedIdentityBar, + switching ? l.sealedSwitchingBar : l.sealedIdentityBar, style: theme.textTheme.bodySmall?.copyWith( color: onAccent.withValues(alpha: 0.85), ), @@ -59,12 +67,22 @@ class ChainSealedIdentityBar extends StatelessWidget { ), ), const SizedBox(width: ChainSpace.sm), - TextButton.icon( - onPressed: () => Workspace.instance.switchToShared(), - style: TextButton.styleFrom(foregroundColor: onAccent), - icon: const Icon(Icons.logout, size: 15), - label: Text(l.sealedLeave), - ), + if (switching) + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: onAccent, + ), + ) + else + TextButton.icon( + onPressed: () => Workspace.instance.switchToShared(), + style: TextButton.styleFrom(foregroundColor: onAccent), + icon: const Icon(Icons.logout, size: 15), + label: Text(l.sealedLeave), + ), ], ), ), diff --git a/test/workspace_switch_race_test.dart b/test/workspace_switch_race_test.dart new file mode 100644 index 0000000..f583607 --- /dev/null +++ b/test/workspace_switch_race_test.dart @@ -0,0 +1,280 @@ +// Wächter for the sealed-switch privacy race (persona review +// 2026-08-27, HOCH): the hub client must never point at a different +// hub than the context the UI announces. The bug class: Studio's +// client re-pointed at a sealed hub BEFORE the workspace announced +// the sealed context, so a 2 s page poller could fetch and render +// confidential data without the sealed marking. +// +// Invariants pinned here: +// 1. reconnect() happens only inside an open switch window +// (Workspace.switching == true), and the sealed context is +// announced only AFTER the connection settled — never before, +// never skipped. +// 2. While the window is open, the polling pages issue no reads — +// sealed data can never render unmarked (RunsPage as the +// poll-pattern representative; Audit uses the same guard). +// 3. The shared-hub project filter survives a sealed round trip +// (park + restore — the second HOCH bug), and prefs agree with +// the live state afterwards. +// 4. A failed switch rolls back to the shared hub without ever +// announcing the sealed context and closes the window. +// +// Hermetic: scripted fakes for HubService AND SealedAreaService — +// the suite never scans the operator's real ~/.chain. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/data/sealed_areas.dart'; +import 'package:chain_studio/data/workspace.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/runs.dart'; + +import 'support/fake_hub.dart'; + +const _areaA = SealedArea( + slug: 'area-a', + name: 'Area A', + color: '#e0a458', + port: 51001, + running: true, +); + +const _areaB = SealedArea( + slug: 'area-b', + name: 'Area B', + color: '#7cb47c', + port: 51002, + running: true, +); + +class FakeSealedAreas extends SealedAreaService { + FakeSealedAreas(this.areas) : super.forTest(); + + List areas; + + @override + Future> list() async => areas; + + @override + Future boundEndpoint(String slug) async => null; +} + +class RecordingHub extends FakeHubService { + /// Every reconnect with the workspace state AT CALL TIME — the + /// race guard reads exactly these two flags. + final List<({int port, bool switchingAtCall, bool sealedAnnounced})> + reconnects = []; + + /// When set, healthy() blocks until completed — holds the switch + /// window open so the poller-pause test can tick timers inside it. + Completer? healthyGate; + bool healthyAnswer = true; + + int detachedReads = 0; + + @override + Future reconnect( + HubEndpoint endpoint, { + Object? authToken = const Object(), + bool persist = true, + }) async { + reconnects.add(( + port: endpoint.port, + switchingAtCall: Workspace.instance.switching, + sealedAnnounced: Workspace.instance.inSealedArea, + )); + } + + @override + Future healthy() async { + final gate = healthyGate; + if (gate != null) await gate.future; + return healthyAnswer; + } + + @override + Future<({List runs, bool enabled})> listDetachedRuns({ + String project = '', + }) { + detachedReads++; + return super.listDetachedRuns(project: project); + } +} + +RecordingHub _install({List areas = const [_areaA, _areaB]}) { + SharedPreferences.setMockInitialValues({}); + final hub = RecordingHub(); + HubService.debugSetInstance(hub); + SealedAreaService.debugSetInstance(FakeSealedAreas(areas)); + addTearDown(() { + HubService.debugSetInstance(null); + SealedAreaService.debugSetInstance(null); + Workspace.instance.debugSeed(projects: const [], active: ''); + }); + Workspace.instance.debugSeed( + projects: const [], + active: '', + sealed: areas, + ); + return hub; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the switch window opens before the client re-points and the ' + 'sealed context is announced only after it settled', () async { + final hub = _install(); + final ws = Workspace.instance; + + final r = await ws.switchToSealed(_areaA); + + expect(r.ok, isTrue); + expect(hub.reconnects, hasLength(1)); + final call = hub.reconnects.single; + expect(call.port, _areaA.port); + expect( + call.switchingAtCall, + isTrue, + reason: 'the client re-pointed OUTSIDE a switch window — pollers ' + 'were not paused and could render sealed data unmarked', + ); + expect( + call.sealedAnnounced, + isFalse, + reason: 'the sealed context must be announced only after the ' + 'connection settled, never before reconnect', + ); + expect(ws.switching, isFalse); + expect(ws.inSealedArea, isTrue); + expect(ws.activeSealed, _areaA); + }); + + test('leaving a sealed area also runs inside a switch window and ' + 'keeps the sealed marking until the connection settled', () async { + final hub = _install(); + final ws = Workspace.instance; + await ws.switchToSealed(_areaA); + hub.reconnects.clear(); + + await ws.switchToShared(); + + expect(hub.reconnects, hasLength(1)); + final call = hub.reconnects.single; + expect(call.switchingAtCall, isTrue); + expect( + call.sealedAnnounced, + isTrue, + reason: 'while returning, the data still on screen is the sealed ' + "area's — its marking must not drop before the switch settled", + ); + expect(ws.switching, isFalse); + expect(ws.inSealedArea, isFalse); + }); + + test('the project filter survives a sealed round trip, live and in ' + 'prefs', () async { + _install(); + final ws = Workspace.instance; + await ws.setActive('proj-a'); + + await ws.switchToSealed(_areaA); + expect(ws.activeSlug, isEmpty, + reason: 'the shared-hub filter does not apply inside an area'); + + await ws.switchToShared(); + expect(ws.activeSlug, 'proj-a'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('workspace.active'), 'proj-a', + reason: 'live state and prefs must agree after the round trip'); + }); + + test('area-to-area switching keeps the originally parked filter', + () async { + _install(); + final ws = Workspace.instance; + await ws.setActive('proj-a'); + + await ws.switchToSealed(_areaA); + await ws.switchToSealed(_areaB); + await ws.switchToShared(); + + expect(ws.activeSlug, 'proj-a'); + }); + + test('a failed switch rolls back without announcing the sealed ' + 'context and closes the window', () async { + final hub = _install(); + hub.healthyAnswer = false; + final ws = Workspace.instance; + await ws.setActive('proj-a'); + hub.reconnects.clear(); + + final r = await ws.switchToSealed(_areaA); + + expect(r.ok, isFalse); + expect(r.error, contains(_areaA.name)); + expect(ws.inSealedArea, isFalse); + expect(ws.switching, isFalse); + expect(ws.activeSlug, 'proj-a', reason: 'filter untouched'); + // Attempt at the area, then the rollback to the shared hub — + // and the sealed context was never announced in between. + expect(hub.reconnects, hasLength(2)); + expect(hub.reconnects.last.port, isNot(_areaA.port)); + expect(hub.reconnects.map((c) => c.sealedAnnounced), everyElement(isFalse)); + }); + + testWidgets('the runs poller is paused while the switch window is ' + 'open and resumes on the settled context', (tester) async { + final hub = _install(); + final ws = Workspace.instance; + + await tester.pumpWidget( + const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale('de'), + home: RunsPage(), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + final beforeWindow = hub.detachedReads; + expect(beforeWindow, greaterThan(0), + reason: 'sanity: the page polls at all'); + + // Hold the switch in flight and tick the 2 s poller inside it. + hub.healthyGate = Completer(); + final switching = ws.switchToSealed(_areaA); + await tester.pump(const Duration(milliseconds: 10)); + final inWindow = hub.detachedReads; + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 2)); + expect( + hub.detachedReads, + inWindow, + reason: 'a poll fired inside the switch window — it could have ' + 'rendered the sealed hub\'s data without the sealed marking', + ); + + hub.healthyGate!.complete(); + await switching; + await tester.pump(const Duration(milliseconds: 100)); + expect(ws.inSealedArea, isTrue); + expect( + hub.detachedReads, + greaterThan(inWindow), + reason: 'the end-of-switch notify must refresh the page against ' + 'the settled context', + ); + + // Tear down the page so its periodic timer dies with it. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + }); +} From 64c2a77dc9c590e2e68d5f5df0d6b17a326e799c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 28 Aug 2026 00:00:06 +0200 Subject: [PATCH 08/10] feat(workspace): one global switcher anchor in the shell sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- integration_test/guide_shots_test.dart | 5 +- lib/data/workspace_prefs.dart | 23 + lib/l10n/app_de.arb | 16 +- lib/l10n/app_en.arb | 16 +- lib/l10n/app_localizations.dart | 74 ++- lib/l10n/app_localizations_de.dart | 46 +- lib/l10n/app_localizations_en.dart | 46 +- lib/main.dart | 129 ++++ lib/pages/approvals.dart | 2 - lib/pages/audit.dart | 8 +- lib/pages/flows.dart | 9 +- lib/pages/runs.dart | 61 +- lib/widgets/chain_settings_dialog.dart | 17 +- lib/widgets/chain_workspace_switcher.dart | 690 ++++++++++++++-------- test/support/fake_hub.dart | 7 +- test/workspace_switcher_test.dart | 163 +++-- 16 files changed, 988 insertions(+), 324 deletions(-) diff --git a/integration_test/guide_shots_test.dart b/integration_test/guide_shots_test.dart index f1c7dc7..cae4260 100644 --- a/integration_test/guide_shots_test.dart +++ b/integration_test/guide_shots_test.dart @@ -262,10 +262,11 @@ void main() { } // 09: the workspace switcher, opened (shows the demo projects; - // with the sealed seed also the shield entry). + // with the sealed seed also the shield entry). The switcher is + // the ONE sidebar anchor now — global on every page. shell.navigateTo('audit'); await _pumpFrames(tester); - final switcher = find.byType(ChainWorkspaceSwitcher); + final switcher = find.byType(ChainWorkspaceAnchor); if (switcher.evaluate().isNotEmpty) { await tester.tap(switcher.first); await _pumpFrames(tester); diff --git a/lib/data/workspace_prefs.dart b/lib/data/workspace_prefs.dart index 2ed030a..c8a2ca5 100644 --- a/lib/data/workspace_prefs.dart +++ b/lib/data/workspace_prefs.dart @@ -22,6 +22,8 @@ class WorkspacePrefs { static Future load() async { final prefs = await SharedPreferences.getInstance(); sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false; + recentContexts.value = + prefs.getStringList(_kRecentContextsKey) ?? const []; } static Future setSealedNamesVisible(bool value) async { @@ -29,4 +31,25 @@ class WorkspacePrefs { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kSealedNamesKey, value); } + + static const _kRecentContextsKey = 'workspace.recent_contexts'; + static const _kRecentContextsMax = 5; + + /// Recently chosen switcher values (menu-value scheme: '' / + /// `p:` / `s:`), most recent first. The command + /// palette ranks matching contexts by this list so a poweruser's + /// frequent areas surface before the alphabet. + static final ValueNotifier> recentContexts = + ValueNotifier(const []); + + /// Record a switcher selection (deduped, capped, persisted). + static Future recordRecentContext(String value) async { + final next = [ + value, + ...recentContexts.value.where((v) => v != value), + ].take(_kRecentContextsMax).toList(); + recentContexts.value = next; + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_kRecentContextsKey, next); + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c0b5d6f..cdb6e49 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1413,6 +1413,9 @@ "searchHintOpen": "Enter öffnen", "searchHintClose": "Esc schließen", "searchGroupPages": "Seiten", + "searchGroupWorkspace": "Projekte & Bereiche", + "searchWorkspaceHint": "Kontext wechseln · ⌘P", + "searchWorkspaceSealedPickerHint": "Auswahl öffnen · ⌘P", "searchGroupModules": "Module", "searchGroupStore": "Store", "searchGroupFlows": "Flows", @@ -1833,7 +1836,14 @@ "federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.", "workspaceAll": "Alle Projekte", "workspaceDefaultProject": "Allgemein", - "workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt", + "workspaceSwitcherTooltip": "Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt", + "workspaceAnchorCaption": "Projekt / Bereich", + "workspaceStartConfirmTitle": "Bereich „{name}“ starten?", + "@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, + "workspaceStartConfirmBody": "Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.", + "workspaceStartConfirmAction": "Starten und verbinden", + "workspaceSealedAggregateWhy": "Namen bleiben zusammengefasst, weil sie Mandanten verraten können.", + "workspaceSealedAggregateSettings": "Immer anzeigen: Einstellungen → Sicherheit", "workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.", "runsTitle": "Läufe", "runsReloadTooltip": "Lauf-Liste neu laden", @@ -1841,6 +1851,10 @@ "runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.", "runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.", "runsEmptyGuideButton": "Anleitung öffnen", + "runsEmptyFilteredTitle": "Keine Läufe in Projekt „{name}“", + "@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}}, + "runsEmptyFilteredHint": "Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.", + "runsEmptyShowAll": "Alle Projekte anzeigen", "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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7376af6..33d5f44 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1437,6 +1437,9 @@ "searchHintOpen": "enter open", "searchHintClose": "esc close", "searchGroupPages": "Pages", + "searchGroupWorkspace": "Projects & areas", + "searchWorkspaceHint": "switch context · ⌘P", + "searchWorkspaceSealedPickerHint": "open the picker · ⌘P", "searchGroupModules": "Modules", "searchGroupStore": "Store", "searchGroupFlows": "Flows", @@ -1872,7 +1875,14 @@ "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.", "workspaceAll": "All projects", "workspaceDefaultProject": "General", - "workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project", + "workspaceSwitcherTooltip": "Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project", + "workspaceAnchorCaption": "Project / area", + "workspaceStartConfirmTitle": "Start area “{name}”?", + "@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, + "workspaceStartConfirmBody": "This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.", + "workspaceStartConfirmAction": "Start and connect", + "workspaceSealedAggregateWhy": "Names stay aggregated because they can reveal client identities.", + "workspaceSealedAggregateSettings": "Always show: Settings → Security", "workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.", "runsTitle": "Runs", "runsReloadTooltip": "Reload the runs list", @@ -1880,6 +1890,10 @@ "runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.", "runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.", "runsEmptyGuideButton": "Open the guide", + "runsEmptyFilteredTitle": "No runs in project “{name}”", + "@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}}, + "runsEmptyFilteredHint": "The working context filters this list — other projects may have runs.", + "runsEmptyShowAll": "Show all projects", "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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 453356a..935df78 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4400,6 +4400,24 @@ abstract class AppLocalizations { /// **'Pages'** String get searchGroupPages; + /// No description provided for @searchGroupWorkspace. + /// + /// In en, this message translates to: + /// **'Projects & areas'** + String get searchGroupWorkspace; + + /// No description provided for @searchWorkspaceHint. + /// + /// In en, this message translates to: + /// **'switch context · ⌘P'** + String get searchWorkspaceHint; + + /// No description provided for @searchWorkspaceSealedPickerHint. + /// + /// In en, this message translates to: + /// **'open the picker · ⌘P'** + String get searchWorkspaceSealedPickerHint; + /// No description provided for @searchGroupModules. /// /// In en, this message translates to: @@ -5752,9 +5770,45 @@ abstract class AppLocalizations { /// No description provided for @workspaceSwitcherTooltip. /// /// In en, this message translates to: - /// **'Workspace — filters this view and stamps new runs with the selected project'** + /// **'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'** String get workspaceSwitcherTooltip; + /// No description provided for @workspaceAnchorCaption. + /// + /// In en, this message translates to: + /// **'Project / area'** + String get workspaceAnchorCaption; + + /// No description provided for @workspaceStartConfirmTitle. + /// + /// In en, this message translates to: + /// **'Start area “{name}”?'** + String workspaceStartConfirmTitle(String name); + + /// No description provided for @workspaceStartConfirmBody. + /// + /// In en, this message translates to: + /// **'This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.'** + String get workspaceStartConfirmBody; + + /// No description provided for @workspaceStartConfirmAction. + /// + /// In en, this message translates to: + /// **'Start and connect'** + String get workspaceStartConfirmAction; + + /// No description provided for @workspaceSealedAggregateWhy. + /// + /// In en, this message translates to: + /// **'Names stay aggregated because they can reveal client identities.'** + String get workspaceSealedAggregateWhy; + + /// No description provided for @workspaceSealedAggregateSettings. + /// + /// In en, this message translates to: + /// **'Always show: Settings → Security'** + String get workspaceSealedAggregateSettings; + /// No description provided for @workspaceProtectedHint. /// /// In en, this message translates to: @@ -5797,6 +5851,24 @@ abstract class AppLocalizations { /// **'Open the guide'** String get runsEmptyGuideButton; + /// No description provided for @runsEmptyFilteredTitle. + /// + /// In en, this message translates to: + /// **'No runs in project “{name}”'** + String runsEmptyFilteredTitle(String name); + + /// No description provided for @runsEmptyFilteredHint. + /// + /// In en, this message translates to: + /// **'The working context filters this list — other projects may have runs.'** + String get runsEmptyFilteredHint; + + /// No description provided for @runsEmptyShowAll. + /// + /// In en, this message translates to: + /// **'Show all projects'** + String get runsEmptyShowAll; + /// No description provided for @runsHubTooOldTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e4f14ec..74fc698 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2595,6 +2595,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get searchGroupPages => 'Seiten'; + @override + String get searchGroupWorkspace => 'Projekte & Bereiche'; + + @override + String get searchWorkspaceHint => 'Kontext wechseln · ⌘P'; + + @override + String get searchWorkspaceSealedPickerHint => 'Auswahl öffnen · ⌘P'; + @override String get searchGroupModules => 'Module'; @@ -3414,7 +3423,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get workspaceSwitcherTooltip => - 'Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt'; + 'Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt'; + + @override + String get workspaceAnchorCaption => 'Projekt / Bereich'; + + @override + String workspaceStartConfirmTitle(String name) { + return 'Bereich „$name“ starten?'; + } + + @override + String get workspaceStartConfirmBody => + 'Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.'; + + @override + String get workspaceStartConfirmAction => 'Starten und verbinden'; + + @override + String get workspaceSealedAggregateWhy => + 'Namen bleiben zusammengefasst, weil sie Mandanten verraten können.'; + + @override + String get workspaceSealedAggregateSettings => + 'Immer anzeigen: Einstellungen → Sicherheit'; @override String get workspaceProtectedHint => @@ -3440,6 +3472,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get runsEmptyGuideButton => 'Anleitung öffnen'; + @override + String runsEmptyFilteredTitle(String name) { + return 'Keine Läufe in Projekt „$name“'; + } + + @override + String get runsEmptyFilteredHint => + 'Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.'; + + @override + String get runsEmptyShowAll => 'Alle Projekte anzeigen'; + @override String get runsHubTooOldTitle => 'Diese Ansicht braucht eine neuere Hub-Version'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5e22e89..aa2ab68 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2593,6 +2593,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchGroupPages => 'Pages'; + @override + String get searchGroupWorkspace => 'Projects & areas'; + + @override + String get searchWorkspaceHint => 'switch context · ⌘P'; + + @override + String get searchWorkspaceSealedPickerHint => 'open the picker · ⌘P'; + @override String get searchGroupModules => 'Modules'; @@ -3407,7 +3416,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get workspaceSwitcherTooltip => - 'Workspace — filters this view and stamps new runs with the selected project'; + 'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'; + + @override + String get workspaceAnchorCaption => 'Project / area'; + + @override + String workspaceStartConfirmTitle(String name) { + return 'Start area “$name”?'; + } + + @override + String get workspaceStartConfirmBody => + 'This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.'; + + @override + String get workspaceStartConfirmAction => 'Start and connect'; + + @override + String get workspaceSealedAggregateWhy => + 'Names stay aggregated because they can reveal client identities.'; + + @override + String get workspaceSealedAggregateSettings => + 'Always show: Settings → Security'; @override String get workspaceProtectedHint => @@ -3433,6 +3465,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get runsEmptyGuideButton => 'Open the guide'; + @override + String runsEmptyFilteredTitle(String name) { + return 'No runs in project “$name”'; + } + + @override + String get runsEmptyFilteredHint => + 'The working context filters this list — other projects may have runs.'; + + @override + String get runsEmptyShowAll => 'Show all projects'; + @override String get runsHubTooOldTitle => 'This view needs a newer hub version'; diff --git a/lib/main.dart b/lib/main.dart index 9342523..9e790e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -505,9 +505,18 @@ class StudioShellState extends State { /// Refreshed alongside the health probe — same 5 s tick. int _pendingApprovals = 0; + /// Opens the workspace anchor's menu from the Cmd+P shortcut. + final GlobalKey> _workspaceMenuKey = + GlobalKey>(); + @override void initState() { super.initState(); + // The shell renders connection-derived labels (sidebar endpoint, + // workspace anchor): rebuild when the workspace switches context + // so they can never show the previous hub (persona finding: the + // sidebar endpoint stayed stale until the next health tick). + Workspace.instance.addListener(_onWorkspaceChanged); _checkHealth(); _healthPoll = Timer.periodic( const Duration(seconds: 5), @@ -515,6 +524,10 @@ class StudioShellState extends State { ); } + void _onWorkspaceChanged() { + if (mounted) setState(() {}); + } + /// One-time hub-update hint (zero-learning-curve: the shell says /// actively that a newer hub release exists instead of hiding it /// on the doctor page). Null = nothing to show. Dismissal is @@ -627,6 +640,7 @@ class StudioShellState extends State { @override void dispose() { + Workspace.instance.removeListener(_onWorkspaceChanged); _healthPoll?.cancel(); super.dispose(); } @@ -656,10 +670,76 @@ class StudioShellState extends State { ChainSettingsDialog.show(context); }, ), + ..._workspaceSearchHits(l), ]; ChainSearchPalette.show(context, staticHits: hits); } + /// The palette's "Projects & areas" group — every context the + /// switcher offers, ranked by the operator's recent choices so a + /// poweruser's frequent areas surface before the alphabet. Sealed + /// names honour the Settings privacy choice: while the switcher + /// aggregates them, the palette must not list them either — it + /// offers one entry that opens the switcher's guarded picker. + List _workspaceSearchHits(AppLocalizations l) { + final ws = Workspace.instance; + final group = l.searchGroupWorkspace; + final recents = WorkspacePrefs.recentContexts.value; + int rank(String v) { + final i = recents.indexOf(v); + return i < 0 ? recents.length : i; + } + + void select(String value) { + Navigator.of(context).pop(); + handleWorkspaceMenuSelection(context, value); + } + + final entries = <({String value, String label, IconData icon})>[ + ( + value: workspaceMenuValueAll(), + label: l.workspaceAll, + icon: Icons.grid_view_outlined, + ), + for (final p in ws.projects) + ( + value: workspaceMenuValueProject(p.slug), + label: p.slug == 'general' ? l.workspaceDefaultProject : p.name, + icon: Icons.circle_outlined, + ), + if (WorkspacePrefs.sealedNamesVisible.value) + for (final a in ws.sealedAreas) + ( + value: workspaceMenuValueSealed(a.slug), + label: a.name, + icon: Icons.lock_outline, + ), + ]..sort((a, b) => rank(a.value).compareTo(rank(b.value))); + + return [ + for (final e in entries) + ChainSearchHit( + label: e.label, + hint: l.searchWorkspaceHint, + icon: e.icon, + group: group, + onSelect: () => select(e.value), + ), + if (!WorkspacePrefs.sealedNamesVisible.value && + ws.sealedAreas.isNotEmpty) + ChainSearchHit( + label: l.workspaceSealedAggregate(ws.sealedAreas.length), + hint: l.searchWorkspaceSealedPickerHint, + icon: Icons.lock_outline, + group: group, + onSelect: () { + Navigator.of(context).pop(); + _workspaceMenuKey.currentState?.showButtonMenu(); + }, + ), + ]; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -699,6 +779,12 @@ class StudioShellState extends State { const _OpenSearchIntent(), const SingleActivator(LogicalKeyboardKey.keyK, control: true): const _OpenSearchIntent(), + // Workspace switcher (project/area picker) — P as in + // project. Opens the sidebar anchor's menu. + const SingleActivator(LogicalKeyboardKey.keyP, meta: true): + const _OpenWorkspaceIntent(), + const SingleActivator(LogicalKeyboardKey.keyP, control: true): + const _OpenWorkspaceIntent(), }, child: Actions( actions: >{ @@ -720,6 +806,12 @@ class StudioShellState extends State { return null; }, ), + _OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>( + onInvoke: (_) { + _workspaceMenuKey.currentState?.showButtonMenu(); + return null; + }, + ), }, child: Focus( autofocus: true, @@ -738,6 +830,7 @@ class StudioShellState extends State { pendingApprovals: _pendingApprovals, forceExpanded: widget.startSidebarExpanded || pinned, onOpenSearch: _openSearchPalette, + workspaceMenuKey: _workspaceMenuKey, ), ), Container(width: 1, color: theme.colorScheme.outlineVariant), @@ -866,6 +959,10 @@ class _OpenSearchIntent extends Intent { const _OpenSearchIntent(); } +class _OpenWorkspaceIntent extends Intent { + const _OpenWorkspaceIntent(); +} + /// Platform-truthful label for the app's primary-modifier /// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere). String _metaShortcut(String key) => @@ -899,6 +996,11 @@ class _Sidebar extends StatefulWidget { /// knowing the shortcut. final VoidCallback? onOpenSearch; + /// Menu key for the workspace anchor so the shell's Cmd+P + /// shortcut (and the palette's guarded sealed-picker entry) can + /// open the switcher menu. + final GlobalKey>? workspaceMenuKey; + const _Sidebar({ required this.selectedIndex, required this.onSelect, @@ -909,6 +1011,7 @@ class _Sidebar extends StatefulWidget { this.pendingApprovals = 0, this.forceExpanded = false, this.onOpenSearch, + this.workspaceMenuKey, }); @override @@ -932,6 +1035,7 @@ class _SidebarState extends State<_Sidebar> static const double _brandRowH = 48; static const double _connRowH = 44; static const double _channelRowH = 28; + static const double _anchorRowH = 44; static const double _rowGap = 8; late final AnimationController _ctrl; // True while the channel-switch menu is open — suppresses the @@ -1110,6 +1214,31 @@ class _SidebarState extends State<_Sidebar> ) : const SizedBox.shrink(), ), + const SizedBox(height: _rowGap), + // The ONE workspace anchor — active project/area, + // visible on every page, opens the switcher menu + // (per-page switcher embeddings are gone; persona + // review 2026-08-27). Fixed height like the other + // header rows so expanding the rail never shifts + // the destinations below. + SizedBox( + height: _anchorRowH, + child: ChainWorkspaceAnchor( + t: t, + labelsInteractive: labelsInteractive, + iconColumnWidth: _collapsedWidth, + menuKey: widget.workspaceMenuKey, + onMenuOpen: () { + setState(() => _menuOpen = true); + _ctrl.forward(); + }, + onMenuClose: () { + if (!mounted) return; + setState(() => _menuOpen = false); + _ctrl.reverse(); + }, + ), + ), const SizedBox(height: ChainSpace.md), Expanded( child: ListView( diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index ca7da0d..70b653d 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -376,8 +376,6 @@ class _ApprovalsPageState extends State { ), ), actions: [ - const ChainWorkspaceSwitcher(), - const SizedBox(width: ChainSpace.md), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: AppLocalizations.of(context)!.helpTooltip, diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index b84a35a..c70b536 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -306,11 +306,9 @@ class _AuditPageState extends State { appBar: AppBar( title: Text(AppLocalizations.of(context)!.auditTitle), actions: [ - const ChainWorkspaceSwitcher(), - const SizedBox(width: ChainSpace.md), - // Narrow windows can't fit the inline chip row next to the - // workspace switcher — collapse to a checkmark menu so the - // app bar never overflows (responsive_test.dart pins this). + // Narrow windows can't fit the inline chip row — collapse + // to a checkmark menu so the app bar never overflows + // (responsive_test.dart pins this). Padding( padding: const EdgeInsets.only(right: ChainSpace.lg), child: MediaQuery.sizeOf(context).width < 900 diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index 98a6d81..b68c05e 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -23,7 +23,6 @@ import '../data/store_caps.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../widgets/chain_install_confirm.dart'; -import '../widgets/chain_workspace_switcher.dart'; class FlowsPage extends StatefulWidget { /// Pre-load this flow when the editor first builds. Studio @@ -220,11 +219,9 @@ class _FlowsPageState extends State { onInstallCapability: _onInstallCapability, onAddModuleSource: _onAddModuleSource, activeProject: Workspace.instance.activeSlug, - // Same switcher as Audit/Approvals/Runs, hosted in the - // editor's toolbar (the page's single header): it - // filters the flow list and is the project a new flow - // gets stamped with. - toolbarTrailing: const ChainWorkspaceSwitcher(), + // The workspace switcher lives ONCE in the shell sidebar + // (global anchor) — the editor toolbar no longer hosts + // its own copy. // Native file dialog for the Run tab's file inputs — // nobody should have to type an absolute path by hand. onPickFile: _pickFlowInputFile, diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 1961972..ba2a3be 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -254,6 +254,14 @@ class _RunsPageState extends State { } } + /// 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 _cancel(DetachedRun run) async { final l = AppLocalizations.of(context)!; setState(() => _cancelling.add(run.id)); @@ -333,8 +341,6 @@ class _RunsPageState extends State { appBar: AppBar( title: Text(l.runsTitle), actions: [ - const ChainWorkspaceSwitcher(), - const SizedBox(width: ChainSpace.md), IconButton( icon: const Icon(Icons.help_outline, size: 18), tooltip: l.helpTooltip, @@ -361,22 +367,41 @@ class _RunsPageState extends State { updating: _updatingHub, ) : _runs.isEmpty - ? 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'), - ), - ) + // 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, diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 56de1a3..d90c1c1 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart'; import 'theme_picker_grid.dart'; class ChainSettingsDialog extends StatefulWidget { - const ChainSettingsDialog({super.key}); + /// Category name to open on (e.g. 'security'); null = the first + /// category. Lets in-place explainers (the sealed-aggregate + /// why-line) jump straight to the toggle they talk about. + final String? initialCategory; + + const ChainSettingsDialog({super.key, this.initialCategory}); /// Convenience launcher used from the sidebar gear icon. - static Future show(BuildContext context) async { + static Future show( + BuildContext context, { + String? initialCategory, + }) async { final ok = await showDialog( context: context, - builder: (_) => const ChainSettingsDialog(), + builder: (_) => ChainSettingsDialog(initialCategory: initialCategory), ); return ok ?? false; } @@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State { @override void initState() { super.initState(); + for (final c in _Category.values) { + if (c.name == widget.initialCategory) _category = c; + } final ep = HubService.instance.currentEndpoint; _host = TextEditingController(text: ep.host); _port = TextEditingController(text: ep.port.toString()); diff --git a/lib/widgets/chain_workspace_switcher.dart b/lib/widgets/chain_workspace_switcher.dart index 8c86bfc..f9d9e76 100644 --- a/lib/widgets/chain_workspace_switcher.dart +++ b/lib/widgets/chain_workspace_switcher.dart @@ -1,13 +1,20 @@ -// ChainWorkspaceSwitcher — the AppBar workspace (project) control. +// Workspace switching — the ONE global control for "where am I +// working?". // // One control, two mechanics (docs/architecture/projects.md, // § Studio): for open/protected projects the choice sets the page // filter AND the label new runs are stamped with. "All projects" // stays reachable — a filter, not a jail. Sealed areas are a real // CONNECTION SWITCH: selecting one reconnects Studio to the area's -// own hub (starting it first if stopped, with a notice) and colours -// the identity bar. Studio's blue stays the app accent — the project -// colour is marking, not theming. +// own hub (starting it first if stopped, after an explicit +// confirmation) and colours the identity bar. Studio's blue stays +// the app accent — the project colour is marking, not theming. +// +// The control lives ONCE in the shell sidebar (ChainWorkspaceAnchor) +// so the active context is visible on every page — it used to be +// embedded per-page (Flows/Runs/Audit/Approvals), which left it +// invisible on the other five pages and moving between positions +// (persona review 2026-08-27). import 'package:flutter/material.dart'; @@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../theme/tokens.dart'; +import 'chain_settings_dialog.dart'; /// Menu-value scheme: '' = all projects, `p:` = shared project, -/// `s:` = sealed area. +/// `s:` = sealed area, `settings:security` = the why-line's +/// jump to the Settings toggle. const _kAll = ''; const _pProject = 'p:'; const _pSealed = 's:'; +const _kSettingsSecurity = 'settings:security'; -class ChainWorkspaceSwitcher extends StatelessWidget { - const ChainWorkspaceSwitcher({super.key}); +/// Menu values for external mount points (the command palette) — +/// the scheme itself stays private to this file. +String workspaceMenuValueAll() => _kAll; +String workspaceMenuValueProject(String slug) => '$_pProject$slug'; +String workspaceMenuValueSealed(String slug) => '$_pSealed$slug'; + +/// The sidebar anchor — the single place the workspace switcher is +/// mounted. Geometry mirrors the sidebar's header rows: a fixed +/// icon column (context marking) + the label that fades in with the +/// rail expansion [t]. +class ChainWorkspaceAnchor extends StatelessWidget { + /// Sidebar expansion 0..1 (collapsed → expanded). + final double t; + final bool labelsInteractive; + final double iconColumnWidth; + + /// Suppress the sidebar's hover-collapse while the menu is open — + /// same contract as the channel pill (the menu would otherwise + /// float at the pill's old x). + final VoidCallback? onMenuOpen; + final VoidCallback? onMenuClose; + + /// Lets the shell open the menu from the keyboard shortcut. + final GlobalKey>? menuKey; + + const ChainWorkspaceAnchor({ + super.key, + required this.t, + required this.labelsInteractive, + required this.iconColumnWidth, + this.onMenuOpen, + this.onMenuClose, + this.menuKey, + }); @override Widget build(BuildContext context) { @@ -35,232 +77,338 @@ class ChainWorkspaceSwitcher extends StatelessWidget { final l = AppLocalizations.of(context)!; final theme = Theme.of(context); final ws = Workspace.instance; + final label = workspaceContextLabel(l, ws); - final label = ws.inSealedArea - ? ws.activeSealed!.name - : (ws.isAll - ? l.workspaceAll - : (ws.active == null - ? ws.activeSlug - : _projectLabel(l, ws.active!))); + final row = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: iconColumnWidth, + child: Center(child: _ContextMark(ws: ws)), + ), + Expanded( + child: t > 0 + ? IgnorePointer( + ignoring: !labelsInteractive, + child: Opacity( + opacity: t, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l.workspaceAnchorCaption, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + label, + style: theme.textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Scales away mid-expansion — a fixed + // icon would overflow the row while the + // rail is still narrow. + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.only( + right: ChainSpace.md, + ), + child: Icon( + Icons.unfold_more, + size: 16, + color: + theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); return Tooltip( message: l.workspaceSwitcherTooltip, + waitDuration: const Duration(milliseconds: 400), child: PopupMenuButton( - onOpened: ws.refresh, - onSelected: (v) => _onSelected(context, v), - itemBuilder: (context) => _items(context, ws, l, theme), - child: _pill(context, ws, l, theme, label), + key: menuKey, + tooltip: '', // the outer Tooltip carries the message + onOpened: () { + onMenuOpen?.call(); + Workspace.instance.refresh(); + }, + onCanceled: () => onMenuClose?.call(), + onSelected: (v) { + onMenuClose?.call(); + handleWorkspaceMenuSelection(context, v); + }, + itemBuilder: (context) => + workspaceMenuItems(context, ws: ws, l: l, theme: theme), + child: SizedBox(height: 44, child: row), ), ); }, ); } +} - List> _items( - BuildContext context, - Workspace ws, - AppLocalizations l, - ThemeData theme, - ) { - final items = >[ - PopupMenuItem( - value: _kAll, - child: Row( - children: [ - Icon( - Icons.grid_view_outlined, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Text(l.workspaceAll), - ], - ), - ), - ]; - if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider()); - for (final p in ws.projects) { - items.add( - PopupMenuItem( - value: '$_pProject${p.slug}', - child: Row( - children: [ - _ProjectDot(color: p.color), - const SizedBox(width: ChainSpace.sm), - Flexible( - child: Text( - _projectLabel(l, p), - overflow: TextOverflow.ellipsis, - ), - ), - if (p.isProtected) ...[ - const SizedBox(width: ChainSpace.sm), - Tooltip( - message: l.workspaceProtectedHint, - child: Icon( - Icons.shield_outlined, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - ); - } - if (ws.sealedAreas.isNotEmpty) { - items.add(const PopupMenuDivider()); - items.add( - PopupMenuItem( - enabled: false, - height: 28, - child: Text( - l.workspaceSealedHeader, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - ), - ), - ), - ); - // Area names often carry client/mandate identity, so the - // section starts aggregated ("2 sealed areas") and reveals - // names only on a deliberate tap — unless the operator turned - // the direct listing back on in Settings -> Security - // (usertest security finding). The disabled PopupMenuItem is - // just the host; the section handles its own taps and pops - // the menu route with the regular `s:` value. - items.add( - PopupMenuItem( - enabled: false, - padding: EdgeInsets.zero, - child: SealedAreaSection( - areas: ws.sealedAreas, - namesVisible: WorkspacePrefs.sealedNamesVisible.value, - ), - ), - ); - } - return items; - } +/// The collapsed-column marking for the active context: grid = all +/// projects, coloured dot = project, dot + lock = sealed area. +class _ContextMark extends StatelessWidget { + final Workspace ws; - Future _onSelected(BuildContext context, String value) async { - final ws = Workspace.instance; - final l = AppLocalizations.of(context)!; - final messenger = ScaffoldMessenger.of(context); + const _ContextMark({required this.ws}); - if (value == _kAll) { - await ws.setActive(''); - return; - } - if (value.startsWith(_pProject)) { - await ws.setActive(value.substring(_pProject.length)); - return; - } - if (value.startsWith(_pSealed)) { - final slug = value.substring(_pSealed.length); - SealedArea? area; - for (final a in ws.sealedAreas) { - if (a.slug == slug) area = a; - } - if (area == null) return; - // Announce the intent — starting a stopped area takes a beat. - if (!area.running) { - messenger.showSnackBar( - SnackBar(content: Text(l.workspaceSealedStarting(area.name))), - ); - } - final r = await ws.switchToSealed(area); - messenger.hideCurrentSnackBar(); - if (!r.ok) { - messenger.showSnackBar( - SnackBar( - content: SelectableText(l.workspaceSealedSwitchFailed(r.error)), - duration: const Duration(seconds: 8), - ), - ); - } else if (r.started) { - messenger.showSnackBar( - SnackBar(content: Text(l.workspaceSealedStarted(area.name))), - ); - } - } - } - - Widget _pill( - BuildContext context, - Workspace ws, - AppLocalizations l, - ThemeData theme, - String label, - ) { - final Widget leading; - if (ws.inSealedArea) { - leading = Row( + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final sealed = ws.switching + ? (ws.switchTarget ?? ws.activeSealed) + : ws.activeSealed; + if (sealed != null) { + return Row( mainAxisSize: MainAxisSize.min, children: [ - _ProjectDot(color: ws.activeSealed!.color), + _ProjectDot(color: sealed.color), const SizedBox(width: 4), Icon( Icons.lock_outline, size: 13, color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(width: 4), ], ); - } else if (ws.active != null) { - leading = Padding( - padding: const EdgeInsets.only(right: 6), - child: _ProjectDot(color: ws.active!.color), - ); - } else { - leading = Padding( - padding: const EdgeInsets.only(right: 6), - child: Icon( - Icons.grid_view_outlined, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), - ); } + if (ws.active != null) { + return _ProjectDot(color: ws.active!.color); + } + return Icon( + Icons.grid_view_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ); + } +} - return Container( - padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(ChainRadius.sm), - ), +/// Display label for the active context (shared by anchor + tests). +String workspaceContextLabel(AppLocalizations l, Workspace ws) { + final sealed = ws.switching + ? (ws.switchTarget ?? ws.activeSealed) + : ws.activeSealed; + if (sealed != null) return sealed.name; + if (ws.isAll) return l.workspaceAll; + final active = ws.active; + return active == null ? ws.activeSlug : _projectLabel(l, active); +} + +/// The switcher menu, one source of truth for every mount point. +/// The active entry carries a checkmark so the menu answers "where +/// am I?" before anything is clicked. +List> workspaceMenuItems( + BuildContext context, { + required Workspace ws, + required AppLocalizations l, + required ThemeData theme, +}) { + final inSealed = ws.inSealedArea; + final items = >[ + PopupMenuItem( + value: _kAll, child: Row( - mainAxisSize: MainAxisSize.min, children: [ - leading, - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium, - ), - ), - if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[ - const SizedBox(width: 4), - Icon( - Icons.shield_outlined, - size: 13, - color: theme.colorScheme.onSurfaceVariant, - ), - ], - const SizedBox(width: 2), Icon( - Icons.arrow_drop_down, - size: 18, + Icons.grid_view_outlined, + size: 16, color: theme.colorScheme.onSurfaceVariant, ), + const SizedBox(width: ChainSpace.sm), + Expanded(child: Text(l.workspaceAll)), + _ActiveCheck(active: !inSealed && ws.isAll), ], ), + ), + ]; + if (ws.projects.isNotEmpty) items.add(const PopupMenuDivider()); + for (final p in ws.projects) { + items.add( + PopupMenuItem( + value: '$_pProject${p.slug}', + child: Row( + children: [ + _ProjectDot(color: p.color), + const SizedBox(width: ChainSpace.sm), + Flexible( + child: Text( + _projectLabel(l, p), + overflow: TextOverflow.ellipsis, + ), + ), + if (p.isProtected) ...[ + const SizedBox(width: ChainSpace.sm), + Tooltip( + message: l.workspaceProtectedHint, + child: Icon( + Icons.shield_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const Spacer(), + _ActiveCheck(active: !inSealed && ws.activeSlug == p.slug), + ], + ), + ), + ); + } + if (ws.sealedAreas.isNotEmpty) { + items.add(const PopupMenuDivider()); + items.add( + PopupMenuItem( + enabled: false, + height: 28, + child: Text( + l.workspaceSealedHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + ), + ); + // Area names often carry client/mandate identity, so the + // section starts aggregated ("2 sealed areas") and reveals + // names only on a deliberate tap — unless the operator turned + // the direct listing back on in Settings -> Security + // (usertest security finding). The disabled PopupMenuItem is + // just the host; the section handles its own taps and pops + // the menu route with the regular `s:` value. + items.add( + PopupMenuItem( + enabled: false, + padding: EdgeInsets.zero, + child: SealedAreaSection( + areas: ws.sealedAreas, + namesVisible: WorkspacePrefs.sealedNamesVisible.value, + activeSlug: ws.activeSealed?.slug, + ), + ), + ); + } + return items; +} + +/// Apply a switcher-menu selection. Shared by every mount point +/// (sidebar anchor, command palette). Starting a STOPPED sealed +/// area asks first — an area switch must never boot a hub daemon +/// as a click side-effect; a running area stays one click. +Future handleWorkspaceMenuSelection( + BuildContext context, + String value, +) async { + final ws = Workspace.instance; + final l = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.of(context); + + if (value == _kSettingsSecurity) { + await ChainSettingsDialog.show(context, initialCategory: 'security'); + return; + } + if (value == _kAll) { + WorkspacePrefs.recordRecentContext(value); + await ws.setActive(''); + return; + } + if (value.startsWith(_pProject)) { + WorkspacePrefs.recordRecentContext(value); + await ws.setActive(value.substring(_pProject.length)); + return; + } + if (value.startsWith(_pSealed)) { + final slug = value.substring(_pSealed.length); + SealedArea? area; + for (final a in ws.sealedAreas) { + if (a.slug == slug) area = a; + } + if (area == null) return; + final target = area; + if (!target.running) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.workspaceStartConfirmTitle(target.name)), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(l.workspaceStartConfirmBody), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.workspaceStartConfirmAction), + ), + ], + ), + ); + if (confirmed != true) return; + // Announce the intent — starting the area's hub takes a beat. + messenger.showSnackBar( + SnackBar(content: Text(l.workspaceSealedStarting(target.name))), + ); + } + WorkspacePrefs.recordRecentContext(value); + final r = await ws.switchToSealed(target); + messenger.hideCurrentSnackBar(); + if (!r.ok) { + messenger.showSnackBar( + SnackBar( + content: SelectableText(l.workspaceSealedSwitchFailed(r.error)), + duration: const Duration(seconds: 8), + ), + ); + } else if (r.started) { + messenger.showSnackBar( + SnackBar(content: Text(l.workspaceSealedStarted(target.name))), + ); + } + } +} + +/// Trailing checkmark slot — fixed width so rows with and without +/// the mark keep their text aligned. +class _ActiveCheck extends StatelessWidget { + final bool active; + + const _ActiveCheck({required this.active}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: 22, + child: active + ? Icon(Icons.check, size: 16, color: theme.colorScheme.primary) + : null, ); } } @@ -305,19 +453,26 @@ Color? parseAreaColor(String hex) { /// widget test can pump both privacy modes directly. /// /// [namesVisible] = the operator's Settings choice. When false the -/// block renders one aggregated row (lock + count); a deliberate -/// tap expands the named rows for THIS menu opening only — nothing -/// is persisted from the reveal. Rows select via +/// block renders one aggregated row (lock + count) with a why-line +/// (the aggregation is a deliberate confidentiality decision and +/// must explain itself in place) + a jump to the Settings toggle; +/// a deliberate tap expands the named rows for THIS menu opening +/// only — nothing is persisted from the reveal. Rows select via /// `Navigator.pop(context, 's:')`, which hands the value to /// the enclosing PopupMenuButton exactly like a regular item. class SealedAreaSection extends StatefulWidget { final List areas; final bool namesVisible; + /// Slug of the area Studio is connected to, or null — its row + /// carries the active checkmark. + final String? activeSlug; + const SealedAreaSection({ super.key, required this.areas, required this.namesVisible, + this.activeSlug, }); @override @@ -332,61 +487,102 @@ class _SealedAreaSectionState extends State { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; if (!widget.namesVisible && !_revealed) { - return InkWell( - onTap: () => setState(() => _revealed = true), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - // Two lines instead of one row: popup menus cap their - // width, and action texts must never be cut off - // (usertest finding class). Count on top, the reveal - // action fully readable beneath it. - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => setState(() => _revealed = true), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + // Two lines instead of one row: popup menus cap their + // width, and action texts must never be cut off + // (usertest finding class). Count on top, the reveal + // action fully readable beneath it. + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.lock_outline, - size: 13, - color: theme.colorScheme.onSurfaceVariant, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + size: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: ChainSpace.sm), + Text( + l.workspaceSealedAggregate(widget.areas.length), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ], ), - const SizedBox(width: ChainSpace.sm), - Text( - l.workspaceSealedAggregate(widget.areas.length), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface, + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.only(left: 21), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l.workspaceSealedRevealAction, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + Icon( + Icons.expand_more, + size: 14, + color: theme.colorScheme.primary, + ), + ], ), ), ], ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.only(left: 21), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l.workspaceSealedRevealAction, + ), + ), + // The why-line: aggregation is not a glitch but a + // confidentiality choice — say so where it happens, and + // point at the Settings toggle for machines where the + // listing is fine. + Padding( + padding: const EdgeInsets.fromLTRB(37, 0, 16, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: Text( + l.workspaceSealedAggregateWhy, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + InkWell( + onTap: () => + Navigator.pop(context, _kSettingsSecurity), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + l.workspaceSealedAggregateSettings, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, ), ), - Icon( - Icons.expand_more, - size: 14, - color: theme.colorScheme.primary, - ), - ], + ), ), - ), - ], + ], + ), ), - ), + ], ); } return Column( @@ -440,6 +636,8 @@ class _SealedAreaSectionState extends State { ), ), ), + const Spacer(), + _ActiveCheck(active: a.slug == widget.activeSlug), ], ), ), diff --git a/test/support/fake_hub.dart b/test/support/fake_hub.dart index 21b166d..42739a0 100644 --- a/test/support/fake_hub.dart +++ b/test/support/fake_hub.dart @@ -183,9 +183,14 @@ class FakeHubService extends Fake implements HubService { @override Future> listFlows() => _async('listFlows', () => const []); + /// Registry projects [listProjects] answers with — scriptable so + /// workspace suites can keep their seed across the switcher's + /// open-menu refresh. + List projects = const []; + @override Future> listProjects() => - _async('listProjects', () => const []); + _async('listProjects', () => projects); @override Stream streamEvents({ diff --git a/test/workspace_switcher_test.dart b/test/workspace_switcher_test.dart index f5f4c50..6c36327 100644 --- a/test/workspace_switcher_test.dart +++ b/test/workspace_switcher_test.dart @@ -1,10 +1,13 @@ -// Workspace switcher — stage-1 contract: the control renders the -// active selection, lists "All projects" plus every registry -// project (colour dot, shield for protected), and switching -// updates the shared Workspace notifier. +// Workspace anchor — contract: the sidebar's ONE switcher control +// renders the active selection, lists "All projects" plus every +// registry project (colour dot, shield for protected, checkmark on +// the active entry), keeps sealed-area names aggregated until +// deliberately revealed, asks before starting a stopped area, and +// switching updates the shared Workspace notifier. 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/data/sealed_areas.dart'; @@ -13,16 +16,25 @@ import 'package:chain_studio/data/workspace_prefs.dart'; import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/widgets/chain_workspace_switcher.dart'; +import 'support/fake_hub.dart'; + Widget _host() { return const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: Material(child: ChainWorkspaceSwitcher()), + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 220, + height: 44, + child: ChainWorkspaceAnchor( + t: 1, + labelsInteractive: true, + iconColumnWidth: 72, + ), + ), ), - body: SizedBox(), ), ); } @@ -36,26 +48,46 @@ const _clientA = ProjectRef( ); void main() { + late FakeHubService fakeHub; + + // Seed the workspace singleton AND script both service fakes to + // match: opening the menu triggers Workspace.refresh, which must + // answer from the fakes — never from a live hub or the real + // ~/.chain (hermeticity, shared/TESTING.md). + void seed({ + List projects = const [_general, _clientA], + String active = '', + List sealed = const [], + SealedArea? activeSealed, + }) { + fakeHub.projects = projects; + SealedAreaService.debugSetInstance(_FakeSealedAreas(sealed)); + Workspace.instance.debugSeed( + projects: projects, + active: active, + sealed: sealed, + activeSealed: activeSealed, + ); + } + setUp(() { - // Seed the singleton without a hub: tests drive the notifier - // directly through its test hook. - Workspace.instance.debugSeed(projects: [_general, _clientA], active: ''); + SharedPreferences.setMockInitialValues({}); + fakeHub = installFakeHub(); + addTearDown(() => SealedAreaService.debugSetInstance(null)); + seed(); }); tearDown(() { WorkspacePrefs.sealedNamesVisible.value = false; + WorkspacePrefs.recentContexts.value = const []; }); testWidgets('sealed areas stay aggregated until deliberately revealed', ( tester, ) async { - Workspace.instance.debugSeed( - projects: [_general], - active: '', - sealed: [_sealedGrid, _sealedLab], - ); + seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]); await tester.pumpWidget(_host()); - await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.tap(find.byType(ChainWorkspaceAnchor)); await tester.pumpAndSettle(); // No names on a casual glance — only the aggregate row. expect(find.text('grid'), findsNothing); @@ -67,17 +99,30 @@ void main() { expect(find.text('lab'), findsOneWidget); }); + testWidgets('the aggregate row explains itself and links to Settings', ( + tester, + ) async { + seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]); + await tester.pumpWidget(_host()); + await tester.tap(find.byType(ChainWorkspaceAnchor)); + await tester.pumpAndSettle(); + // The why-line: aggregation is a confidentiality decision and + // must say so in place (in-app docs rule), plus the jump to the + // Settings toggle. + expect( + find.textContaining('reveal client identities'), + findsOneWidget, + ); + expect(find.textContaining('Settings'), findsWidgets); + }); + testWidgets('the Settings toggle restores the direct listing', ( tester, ) async { WorkspacePrefs.sealedNamesVisible.value = true; - Workspace.instance.debugSeed( - projects: [_general], - active: '', - sealed: [_sealedGrid, _sealedLab], - ); + seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]); await tester.pumpWidget(_host()); - await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.tap(find.byType(ChainWorkspaceAnchor)); await tester.pumpAndSettle(); expect(find.text('grid'), findsOneWidget); expect(find.text('lab'), findsOneWidget); @@ -95,7 +140,7 @@ void main() { tester, ) async { await tester.pumpWidget(_host()); - await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.tap(find.byType(ChainWorkspaceAnchor)); await tester.pumpAndSettle(); expect(find.text('All projects'), findsWidgets); @@ -105,21 +150,38 @@ void main() { expect(find.byIcon(Icons.shield_outlined), findsOneWidget); }); + testWidgets('the active entry carries the checkmark', (tester) async { + seed(active: 'client-a'); + await tester.pumpWidget(_host()); + await tester.tap(find.byType(ChainWorkspaceAnchor)); + await tester.pumpAndSettle(); + // Exactly one checkmark, and it sits in the active project's row. + expect(find.byIcon(Icons.check), findsOneWidget); + expect( + find.descendant( + of: find.ancestor( + of: find.text('Client A'), + matching: find.byType(PopupMenuItem), + ), + matching: find.byIcon(Icons.check), + ), + findsOneWidget, + ); + }); + testWidgets('selecting a project updates the workspace and the label', ( tester, ) async { await tester.pumpWidget(_host()); - await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.tap(find.byType(ChainWorkspaceAnchor)); await tester.pumpAndSettle(); await tester.tap(find.text('Client A').last); await tester.pumpAndSettle(); expect(Workspace.instance.activeSlug, 'client-a'); expect(Workspace.instance.active?.isProtected, isTrue); - // The closed control now shows the active project (label + - // shield marker for protected). + // The closed control now shows the active project. expect(find.text('Client A'), findsOneWidget); - expect(find.byIcon(Icons.shield_outlined), findsOneWidget); }); test('active falls back to null when the slug left the registry', () { @@ -132,9 +194,8 @@ void main() { testWidgets('lists sealed areas with lock + running/stopped status', ( tester, ) async { - Workspace.instance.debugSeed( + seed( projects: [_general], - active: '', sealed: const [ SealedArea( slug: 'grid', @@ -153,7 +214,7 @@ void main() { ], ); await tester.pumpWidget(_host()); - await tester.tap(find.byType(ChainWorkspaceSwitcher)); + await tester.tap(find.byType(ChainWorkspaceAnchor)); await tester.pumpAndSettle(); // Sealed areas appear under the sealed header, aggregated by @@ -169,13 +230,31 @@ void main() { expect(find.text('stopped'), findsOneWidget); }); - testWidgets('the pill shows the active sealed area with a lock', ( + testWidgets('selecting a STOPPED area asks before starting its hub', ( tester, ) async { - Workspace.instance.debugSeed( + WorkspacePrefs.sealedNamesVisible.value = true; + seed(projects: [_general], sealed: [_sealedGrid]); // grid: stopped + await tester.pumpWidget(_host()); + await tester.tap(find.byType(ChainWorkspaceAnchor)); + await tester.pumpAndSettle(); + await tester.tap(find.text('grid')); + await tester.pumpAndSettle(); + + // The confirmation dialog — an area switch must never boot a + // hub daemon as a click side-effect. + expect(find.text('Start area “grid”?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(Workspace.instance.inSealedArea, isFalse); + }); + + testWidgets('the anchor shows the active sealed area with a lock', ( + tester, + ) async { + seed( projects: [_general], - active: '', - sealed: const [], activeSealed: const SealedArea( slug: 'grid', name: 'Grid', @@ -186,12 +265,24 @@ void main() { ); await tester.pumpWidget(_host()); expect(Workspace.instance.inSealedArea, isTrue); - // The closed pill names the sealed area and carries a lock. + // The closed anchor names the sealed area and carries a lock. expect(find.text('Grid'), findsOneWidget); expect(find.byIcon(Icons.lock_outline), findsWidgets); }); } +class _FakeSealedAreas extends SealedAreaService { + _FakeSealedAreas(this.areas) : super.forTest(); + + final List areas; + + @override + Future> list() async => areas; + + @override + Future boundEndpoint(String slug) async => null; +} + // Sealed-area confidentiality (usertest security finding): the // switcher must not disclose sealed-area names — often client // identity — on a casual glance. Aggregated row by default, From d69277d29daf532e11ca6d9bbb2c83aeb6dce082 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 28 Aug 2026 00:11:55 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(flows):=20sealed-aware=20flow=20list?= =?UTF-8?q?=20=E2=80=94=20hub=20sample=20flag,=20own=20dir,=20sample=20imp?= =?UTF-8?q?ort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three connection-truth fixes on the flow surface: * The editor now lists the CONNECTED hub's flows: inside a sealed area Studio passes the instance's own flows dir (~/.chain/sealed//data/flows) — previously the editor kept showing the shared hub's files whatever the connection, so a sealed area's list was simply wrong (and runSavedFlow hit the other hub's namespace). * A connection switch replaces the editor state entirely (keyed by the sealed slug): an open buffer from one context never survives into the other — same privacy class as the switch race. * Sample truth comes from the hub: listFlows' FlowSummary.sample (regenerated Dart SDK stubs) feeds the editor's sampleFlowNames; unknown (old hub / fetch failed) means no chips. Inside a sealed area the empty list offers the deliberate 'import example flows' action via chain flows import-samples against the instance's own dirs; the shared hub gets no such offer (it seeds samples itself, and a deliberate deletion is respected). Signed-off-by: flemming-it --- lib/data/hub.dart | 8 ++++ lib/data/system_actions.dart | 25 ++++++++++++ lib/pages/flows.dart | 73 ++++++++++++++++++++++++++++++++++++ pubspec.lock | 2 +- 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 93f253f..9ff0af9 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -1002,6 +1002,7 @@ class HubService { sizeBytes: f.sizeBytes.toInt(), requiredCapabilities: List.from(f.requiredCapabilities), project: f.project, + sample: f.sample, ), ) .toList() @@ -1593,12 +1594,19 @@ class SavedFlow { /// project, then `general`). final String project; + /// True when the file still carries the bundled-sample header — + /// it arrived via the hub's sample import, not from an operator. + /// Old hubs without the wire field report false, so no chip is + /// ever shown on guesswork. + final bool sample; + const SavedFlow({ required this.name, required this.path, required this.sizeBytes, required this.requiredCapabilities, this.project = '', + this.sample = false, }); } diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index a562091..0a3a96f 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -239,6 +239,31 @@ class SystemActions { return _runFai(['project', 'start', slug]); } + /// Import the bundled sample flows (`chain flows import-samples`; + /// existing files are kept). With [sealedSlug] the import targets + /// that sealed instance's own data/modules dirs — sealed areas + /// start without samples, this is the deliberate pull. + static Future<({bool ok, String stdout, String stderr})> + chainFlowsImportSamples({String? sealedSlug}) async { + if (sealedSlug == null || sealedSlug.isEmpty) { + return _runFai(['flows', 'import-samples']); + } + final home = + Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + ''; + final sep = Platform.pathSeparator; + final root = '$home$sep.chain${sep}sealed$sep$sealedSlug'; + return _runFai([ + 'flows', + 'import-samples', + '--data-dir', + '$root${sep}data', + '--modules-dir', + '$root${sep}modules', + ]); + } + static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index b68c05e..df948e2 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -11,6 +11,7 @@ // need to know what's inside the editor any more. import 'dart:async' show unawaited; +import 'dart:io' show Platform; import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart'; import 'package:file_picker/file_picker.dart'; @@ -20,6 +21,7 @@ import '../data/error_presentation.dart'; import '../data/flow_run_driver.dart'; import '../data/hub.dart'; import '../data/store_caps.dart'; +import '../data/system_actions.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; import '../widgets/chain_install_confirm.dart'; @@ -53,12 +55,19 @@ class _FlowsPageState extends State { /// editor would keep offering the OTHER hub's capabilities. String _connKey = Workspace.instance.activeSealed?.slug ?? ''; + /// Flow names the hub reports as bundled samples + /// (FlowSummary.sample). Null while unknown (fetch pending / + /// failed / hub too old) — the editor then shows no sample + /// chips rather than guessing. + Set? _sampleNames; + @override void initState() { super.initState(); _driver = StudioFlowRunDriver(); _capabilities = _loadCapabilities(); _loadStoreCapabilities(); + _loadSampleNames(); Workspace.instance.addListener(_onWorkspaceChanged); } @@ -78,9 +87,64 @@ class _FlowsPageState extends State { if (!mounted) return; setState(() { _storeCaps = null; // other hub — snapshot unknown again + _sampleNames = null; _capabilities = _loadCapabilities(); }); _loadStoreCapabilities(); + _loadSampleNames(); + } + + /// Pull the hub's sample marking for the current connection. + /// Soft-fails to "unknown" — no chips on guesswork. + Future _loadSampleNames() async { + final key = _connKey; + try { + final flows = await HubService.instance.listFlows(); + if (!mounted || key != _connKey) return; + setState(() { + _sampleNames = { + for (final f in flows) + if (f.sample) f.name, + }; + }); + } catch (_) { + // Unknown stays unknown. + } + } + + /// Flows directory of the CURRENT connection: a sealed area's + /// own instance dir, or null for the editor's shared-hub + /// default. Without this the editor kept listing the shared + /// hub's files while Studio was connected to a sealed area. + String? _flowsDirForConnection() { + final sealed = Workspace.instance.activeSealed; + if (sealed == null) return null; + final home = + Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + '.'; + return '$home/.chain/sealed/${sealed.slug}/data/flows'; + } + + /// Deliberate sample pull for a sealed area's empty hub (the + /// shared hub seeds samples itself; a deliberate deletion there + /// must not be second-guessed with an import offer). + Future _importSamples() async { + final sealed = Workspace.instance.activeSealed; + if (sealed == null) return; + final r = await SystemActions.chainFlowsImportSamples( + sealedSlug: sealed.slug, + ); + if (!mounted) return; + if (!r.ok) { + showChainErrorSnack( + context, + 'flows.importSamples', + (r.stderr.isEmpty ? r.stdout : r.stderr).trim(), + ); + return; + } + await _loadSampleNames(); } Future _loadStoreCapabilities() async { @@ -210,7 +274,16 @@ class _FlowsPageState extends State { future: _capabilities, builder: (context, snap) { final caps = snap.data ?? const []; + final sealed = Workspace.instance.activeSealed; return FlowEditorPage( + // A connection switch replaces the whole editor state: + // an open buffer from one context must never survive + // into the other (same privacy class as the switch + // race), and the file list re-roots at the new dir. + key: ValueKey('flow-editor-${sealed?.slug ?? ''}'), + flowsDir: _flowsDirForConnection(), + sampleFlowNames: _sampleNames, + onImportSamples: sealed == null ? null : _importSamples, initialFlowName: widget.initialFlowName, locale: editorLocale, runDriver: _driver, diff --git a/pubspec.lock b/pubspec.lock index 8235b00..81a6014 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -46,7 +46,7 @@ packages: path: "../fai_chain_studio_flow_editor" relative: true source: path - version: "0.25.0" + version: "0.26.0" characters: dependency: transitive description: From 10f38beafafe233e644d8095dd372cda5769b6ed Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 28 Aug 2026 00:28:02 +0200 Subject: [PATCH 10/10] =?UTF-8?q?chore:=200.82.0=20=E2=80=94=20switcher-re?= =?UTF-8?q?build=20changelog,=20anchor=20caption,=20capture=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anchor caption 'Projekt / Bereich' truncated uselessly at the rail width — 'Kontext'/'Context' fits and the tooltip carries the full explanation. workspace_shots_test renders the anchor, the open menu (checkmark, aggregated why-line), and the switching / in-area identity bar per theme as the visual-proof harness. Signed-off-by: flemming-it --- CHANGELOG.md | 34 ++++ integration_test/workspace_shots_test.dart | 193 +++++++++++++++++++++ lib/data/about_info.dart | 2 +- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- pubspec.yaml | 2 +- 9 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 integration_test/workspace_shots_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e414ae..a0ab36e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ lockstep. ## Unreleased +### Changed (0.82.0) + +Workspace-switcher rebuild (persona review 2026-08-27; both +high-rated findings fixed): + +- **Sealed-switch privacy race closed.** A connection switch now + runs inside an explicit switching window: the identity bar + announces the target BEFORE the client re-points, page pollers + pause, replies from the previous hub are dropped, and the sealed + context is announced only after the new hub answered healthy. + Guard: `workspace_switch_race_test`. +- **Project filter survives the sealed round trip.** Entering an + area parks the shared-hub filter; returning restores it — live + state and prefs agree again. +- **One global switcher anchor.** The switcher lives once in the + sidebar (context visible on every page, Cmd+P opens it); the four + per-page embeddings are gone. The sidebar endpoint label updates + with the workspace instead of lagging until the next health tick. +- **Stopped areas ask before starting** ("Bereich starten?") — + running areas keep switching with one click. +- **Honest scope tooltip** (the switcher applies everywhere, not + "this view"), **why-line at the aggregated sealed row** with a + jump to Settings → Security, **checkmark on the active entry**, + and the Cmd+K palette knows projects/areas (recent-use ranked, + sealed names honour the privacy setting). +- **Sealed-aware flow list.** Inside a sealed area the editor lists + the instance's own flows dir (it used to keep showing the shared + hub's files), the editor state is dropped on a connection switch, + sample flows follow the hub's FlowSummary.sample flag (chip + + collapsed "Beispiele" group, editor 0.26.0), and an empty sealed + list offers "Beispiel-Flows importieren". +- **Runs empty state names the filter** ("Keine Läufe in Projekt X" + + show-all action) instead of claiming the feature is off. + ### Changed (0.81.0) - **Approvals explain themselves.** The pending approval card now diff --git a/integration_test/workspace_shots_test.dart b/integration_test/workspace_shots_test.dart new file mode 100644 index 0000000..490916b --- /dev/null +++ b/integration_test/workspace_shots_test.dart @@ -0,0 +1,193 @@ +// Workspace-anchor capture harness — visual proof for the sidebar +// switcher rebuild (persona review 2026-08-27). Renders the full +// shell against the scriptable fake hub and writes a PNG per theme +// via a RepaintBoundary (driverless, headed macOS `flutter test`). +// +// flutter test integration_test/workspace_shots_test.dart -d macos +// +// Output: build/workspace-shots/-.png +// (or $WORKSPACE_SHOTS_OUT). +// +// Fixture names are deliberately generic — never real client or +// area names. + +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/data/sealed_areas.dart'; +import 'package:chain_studio/data/sidebar_prefs.dart'; +import 'package:chain_studio/data/workspace.dart'; +import 'package:chain_studio/data/workspace_prefs.dart'; +import 'package:chain_studio/main.dart'; +import 'package:chain_studio/widgets/chain_workspace_switcher.dart'; + +import '../test/support/fake_hub.dart'; + +final GlobalKey _shotKey = GlobalKey(); + +String get _outDir => + Platform.environment['WORKSPACE_SHOTS_OUT'] ?? 'build/workspace-shots'; + +Future _shot(WidgetTester tester, String name) async { + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(const Duration(milliseconds: 150)); + final boundary = + _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2.0); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File('$_outDir/$name.png'); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('workspace-shot: ${file.path}'); +} + +const _projects = [ + ProjectRef(slug: 'general', name: 'General'), + ProjectRef( + slug: 'projekt-alpha', + name: 'Projekt Alpha', + color: '#8b7cf6', + isolation: 'protected', + ), + ProjectRef(slug: 'projekt-beta', name: 'Projekt Beta', color: '#2e8f9e'), +]; + +const _areas = [ + SealedArea( + slug: 'bereich-nord', + name: 'Bereich Nord', + color: '#e0a458', + port: 51100, + running: true, + ), + SealedArea( + slug: 'bereich-sued', + name: 'Bereich Süd', + color: '#c25e5e', + port: 51101, + running: false, + ), +]; + +class _FakeSealedAreas extends SealedAreaService { + _FakeSealedAreas() : super.forTest(); + + @override + Future> list() async => _areas; + + @override + Future boundEndpoint(String slug) async => null; +} + +class _GatedHub extends FakeHubService { + Completer? healthyGate; + + @override + Future reconnect( + HubEndpoint endpoint, { + Object? authToken = const Object(), + bool persist = true, + }) async {} + + @override + Future healthy() async { + final gate = healthyGate; + if (gate != null) await gate.future; + return true; + } +} + +Future _boot(WidgetTester tester, _GatedHub hub, String theme) async { + SharedPreferences.setMockInitialValues({}); + hub.projects = _projects; + HubService.debugSetInstance(hub); + SealedAreaService.debugSetInstance(_FakeSealedAreas()); + SidebarPrefs.pinned.value = true; + Workspace.instance.debugSeed( + projects: _projects, + active: 'projekt-alpha', + sealed: _areas, + ); + tester.view.physicalSize = const Size(2560, 1600); + tester.view.devicePixelRatio = 2.0; + await tester.pumpWidget( + RepaintBoundary( + key: _shotKey, + child: StudioApp( + initialThemeMode: theme == 'dark' + ? ThemeModeValue.dark + : ThemeModeValue.light, + initialLocale: const Locale('de'), + startSidebarExpanded: true, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); +} + +Future _teardown(WidgetTester tester) async { + HubService.debugSetInstance(null); + SealedAreaService.debugSetInstance(null); + SidebarPrefs.pinned.value = false; + WorkspacePrefs.sealedNamesVisible.value = false; + Workspace.instance.debugSeed(projects: const [], active: ''); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + tester.view.reset(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + for (final theme in ['light', 'dark']) { + testWidgets('anchor + menu + switching bar — $theme', (tester) async { + final hub = _GatedHub(); + await _boot(tester, hub, theme); + + // 01: the shell with the sidebar anchor (active project). + await _shot(tester, '01-anchor-shell-$theme'); + + // 02: the switcher menu — checkmark on the active project, + // aggregated sealed row with the why-line + Settings link. + await tester.tap(find.byType(ChainWorkspaceAnchor)); + await tester.pump(const Duration(milliseconds: 300)); + await _shot(tester, '02-switcher-menu-$theme'); + + // 03: names revealed for this menu opening. + await tester.tap(find.text('Namen einblenden')); + await tester.pump(const Duration(milliseconds: 200)); + await _shot(tester, '03-switcher-menu-revealed-$theme'); + await tester.tapAt(const Offset(1200, 700)); // dismiss menu + await tester.pump(const Duration(milliseconds: 300)); + + // 04: the switching state — identity bar announces the + // target BEFORE the connection settles (privacy race fix). + hub.healthyGate = Completer(); + final switching = Workspace.instance.switchToSealed(_areas.first); + await tester.pump(const Duration(milliseconds: 100)); + await _shot(tester, '04-identity-switching-$theme'); + + // 05: inside the sealed area — bar + anchor agree. + hub.healthyGate!.complete(); + hub.healthyGate = null; + await switching; + await tester.pump(const Duration(milliseconds: 300)); + await _shot(tester, '05-identity-in-area-$theme'); + + await Workspace.instance.switchToShared(); + await _teardown(tester); + }); + } +} diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index 0e5af17..f799b54 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.81.0'; +const String kStudioVersion = '0.82.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cdb6e49..b038016 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1837,7 +1837,7 @@ "workspaceAll": "Alle Projekte", "workspaceDefaultProject": "Allgemein", "workspaceSwitcherTooltip": "Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt", - "workspaceAnchorCaption": "Projekt / Bereich", + "workspaceAnchorCaption": "Kontext", "workspaceStartConfirmTitle": "Bereich „{name}“ starten?", "@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, "workspaceStartConfirmBody": "Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 33d5f44..3070276 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1876,7 +1876,7 @@ "workspaceAll": "All projects", "workspaceDefaultProject": "General", "workspaceSwitcherTooltip": "Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project", - "workspaceAnchorCaption": "Project / area", + "workspaceAnchorCaption": "Context", "workspaceStartConfirmTitle": "Start area “{name}”?", "@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}}, "workspaceStartConfirmBody": "This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 935df78..b87bb43 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5776,7 +5776,7 @@ abstract class AppLocalizations { /// No description provided for @workspaceAnchorCaption. /// /// In en, this message translates to: - /// **'Project / area'** + /// **'Context'** String get workspaceAnchorCaption; /// No description provided for @workspaceStartConfirmTitle. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 74fc698..65a2489 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3426,7 +3426,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt'; @override - String get workspaceAnchorCaption => 'Projekt / Bereich'; + String get workspaceAnchorCaption => 'Kontext'; @override String workspaceStartConfirmTitle(String name) { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index aa2ab68..42e37fa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3419,7 +3419,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'; @override - String get workspaceAnchorCaption => 'Project / area'; + String get workspaceAnchorCaption => 'Context'; @override String workspaceStartConfirmTitle(String name) { diff --git a/pubspec.yaml b/pubspec.yaml index b79dbb0..e242644 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.81.0 +version: 0.82.0 environment: sdk: ^3.11.0-200.1.beta