feat(approvals,runs): explain approvals in place + one-click hub update (0.81.0)
Approvals: the pending card now shows its full origin — flow, step, run id (previously dropped at the Dart mapping layer), project, and requested-at — under an ORIGIN heading, led by a one-line intro strip that says what the inbox is and what Approve/Reject do. Approve/Reject buttons carry tooltips; the history dialog gains project + run id. Fixes the approvals doc drift (title/details/reviewer -> prompt/show/timeout_seconds). Guard: approvals_origin_test renders the card via the hermetic fake hub and pins every origin fact. Runs: the "hub too old" state now leads with an in-place update button (same `chain update apply` path as the Diagnose page), the Diagnose deeplink demoted to secondary, with a CLI-absent fallback. Guard: two new RunsLoadErrorView widget tests. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
14f824b8ef
commit
28f6fe1a9a
16 changed files with 613 additions and 115 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -6,6 +6,28 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## 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)
|
### Added (0.80.0)
|
||||||
|
|
||||||
- **Ollama joins the host services.** Ollama-backed store entries
|
- **Ollama joins the host services.** Ollama-backed store entries
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ Add an approval step before:
|
||||||
- anything the operator's compliance owner asked you to gate
|
- anything the operator's compliance owner asked you to gate
|
||||||
|
|
||||||
The approval is a recorded event — every approval shows up in
|
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
|
## How a flow declares one
|
||||||
|
|
||||||
|
|
@ -27,9 +29,8 @@ steps:
|
||||||
- id: review
|
- id: review
|
||||||
use: system.approval@^0
|
use: system.approval@^0
|
||||||
with:
|
with:
|
||||||
title: "Review the classifier output"
|
prompt: "Review the classifier output"
|
||||||
details: "$classify.response"
|
show: "$classify.response"
|
||||||
reviewer: "$inputs.reviewer"
|
|
||||||
timeout_seconds: 600
|
timeout_seconds: 600
|
||||||
|
|
||||||
- id: write
|
- id: write
|
||||||
|
|
@ -45,8 +46,9 @@ configured `timeout_seconds` elapses.
|
||||||
|
|
||||||
## How an operator answers one
|
## How an operator answers one
|
||||||
|
|
||||||
The Approvals page lists every pending approval. Click an
|
The Approvals page lists every pending approval. Each card
|
||||||
entry, read the title + details, decide:
|
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
|
- **Approve** — the flow resumes from this step with the
|
||||||
approval's `payload` as the step output.
|
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:
|
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)
|
- reviewer-decision recorded (with reason on reject)
|
||||||
- payload + signature on accept
|
- payload + signature on accept
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ Setze eine Freigabe vor:
|
||||||
|
|
||||||
Die Freigabe ist ein protokolliertes Ereignis — jeder
|
Die Freigabe ist ein protokolliertes Ereignis — jeder
|
||||||
Freigabe-Vorgang landet im Audit-Log mit Reviewer,
|
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
|
## So deklariert ein Flow eine Freigabe
|
||||||
|
|
||||||
|
|
@ -30,9 +32,8 @@ steps:
|
||||||
- id: review
|
- id: review
|
||||||
use: system.approval@^0
|
use: system.approval@^0
|
||||||
with:
|
with:
|
||||||
title: "Klassifikations-Output prüfen"
|
prompt: "Klassifikations-Output prüfen"
|
||||||
details: "$classify.response"
|
show: "$classify.response"
|
||||||
reviewer: "$inputs.reviewer"
|
|
||||||
timeout_seconds: 600
|
timeout_seconds: 600
|
||||||
|
|
||||||
- id: write
|
- id: write
|
||||||
|
|
@ -49,8 +50,10 @@ zugehörige Freigabe-Eintrag eine `decision` erhält
|
||||||
|
|
||||||
## So beantwortet der Operator eine Freigabe
|
## So beantwortet der Operator eine Freigabe
|
||||||
|
|
||||||
Die Freigaben-Seite listet jede ausstehende Freigabe. Klick
|
Die Freigaben-Seite listet jede ausstehende Freigabe. Jede
|
||||||
auf einen Eintrag, Titel + Details lesen, entscheiden:
|
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
|
- **Freigeben** — der Flow läuft ab diesem Step weiter, das
|
||||||
`payload` der Freigabe wird zum Step-Output.
|
`payload` der Freigabe wird zum Step-Output.
|
||||||
|
|
@ -69,7 +72,7 @@ realistisch brauchen darf.
|
||||||
|
|
||||||
Jeder Schreibvorgang einer Freigabe landet im Audit-Log:
|
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
|
- Reviewer-Entscheidung aufgezeichnet (mit Begründung bei
|
||||||
Ablehnung)
|
Ablehnung)
|
||||||
- Payload + Signatur bei Freigabe
|
- Payload + Signatur bei Freigabe
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.80.0';
|
const String kStudioVersion = '0.81.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
|
|
@ -79,34 +79,42 @@ class HubService {
|
||||||
/// null when it cannot be determined (then we keep the default).
|
/// null when it cannot be determined (then we keep the default).
|
||||||
static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() {
|
static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() {
|
||||||
try {
|
try {
|
||||||
final home = Platform.environment['HOME'] ??
|
final home =
|
||||||
Platform.environment['USERPROFILE'];
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||||
if (home == null || home.isEmpty) return null;
|
if (home == null || home.isEmpty) return null;
|
||||||
final sep = Platform.pathSeparator;
|
final sep = Platform.pathSeparator;
|
||||||
final base = '$home$sep.chain';
|
final base = '$home$sep.chain';
|
||||||
final ccFile = File('$base${sep}current-channel');
|
final ccFile = File('$base${sep}current-channel');
|
||||||
final channel =
|
final channel = ccFile.existsSync()
|
||||||
ccFile.existsSync() ? ccFile.readAsStringSync().trim() : 'local';
|
? ccFile.readAsStringSync().trim()
|
||||||
|
: 'local';
|
||||||
if (channel.isEmpty) return null;
|
if (channel.isEmpty) return null;
|
||||||
// Prefer the actual bound endpoint the daemon wrote.
|
// Prefer the actual bound endpoint the daemon wrote.
|
||||||
final epFile = File('$base${sep}run$sep$channel.endpoint');
|
final epFile = File('$base${sep}run$sep$channel.endpoint');
|
||||||
if (epFile.existsSync()) {
|
if (epFile.existsSync()) {
|
||||||
final raw =
|
final raw = epFile.readAsStringSync().trim().replaceFirst(
|
||||||
epFile.readAsStringSync().trim().replaceFirst(RegExp(r'^\w+://'), '');
|
RegExp(r'^\w+://'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
final i = raw.lastIndexOf(':');
|
final i = raw.lastIndexOf(':');
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
final host = raw.substring(0, i);
|
final host = raw.substring(0, i);
|
||||||
final port = int.tryParse(raw.substring(i + 1));
|
final port = int.tryParse(raw.substring(i + 1));
|
||||||
if (port != null) {
|
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.
|
// Fall back to the channel's well-known default port.
|
||||||
return (
|
return (
|
||||||
channel: channel,
|
channel: channel,
|
||||||
endpoint:
|
endpoint: HubEndpoint(
|
||||||
HubEndpoint(host: '127.0.0.1', port: _channelPorts[channel] ?? 50051),
|
host: '127.0.0.1',
|
||||||
|
port: _channelPorts[channel] ?? 50051,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -332,13 +340,12 @@ class HubService {
|
||||||
required String url,
|
required String url,
|
||||||
String bearerEnv = '',
|
String bearerEnv = '',
|
||||||
String pinnedPubkeyPem = '',
|
String pinnedPubkeyPem = '',
|
||||||
}) =>
|
}) => _client.addStore(
|
||||||
_client.addStore(
|
name: name,
|
||||||
name: name,
|
url: url,
|
||||||
url: url,
|
bearerEnv: bearerEnv,
|
||||||
bearerEnv: bearerEnv,
|
pinnedPubkeyPem: pinnedPubkeyPem,
|
||||||
pinnedPubkeyPem: pinnedPubkeyPem,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
/// Drop a configured store by name (the bundled seed cannot be removed).
|
/// Drop a configured store by name (the bundled seed cannot be removed).
|
||||||
Future<RemoveStoreResponse> removeStore(String name) =>
|
Future<RemoveStoreResponse> removeStore(String name) =>
|
||||||
|
|
@ -1156,6 +1163,7 @@ class HubService {
|
||||||
decidedBy: e.decidedBy,
|
decidedBy: e.decidedBy,
|
||||||
reason: e.reason,
|
reason: e.reason,
|
||||||
project: e.project,
|
project: e.project,
|
||||||
|
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -1184,8 +1192,7 @@ class HubService {
|
||||||
|
|
||||||
/// Cancel a running/pending detached invocation. Returns true when
|
/// Cancel a running/pending detached invocation. Returns true when
|
||||||
/// it was signalled, false if already finished or unknown.
|
/// it was signalled, false if already finished or unknown.
|
||||||
Future<bool> cancelDetachedRun(String id) =>
|
Future<bool> cancelDetachedRun(String id) => _client.cancelInvocation(id);
|
||||||
_client.cancelInvocation(id);
|
|
||||||
|
|
||||||
/// Federation satellites currently connected to this hub
|
/// Federation satellites currently connected to this hub
|
||||||
/// (primary side). Empty when none are connected.
|
/// (primary side). Empty when none are connected.
|
||||||
|
|
@ -1241,9 +1248,7 @@ class HubService {
|
||||||
/// error — the hint simply stays away then.
|
/// error — the hint simply stays away then.
|
||||||
Future<UpdateStatus?> checkHubUpdate() async {
|
Future<UpdateStatus?> checkHubUpdate() async {
|
||||||
try {
|
try {
|
||||||
final r = await _client.checkUpdate().timeout(
|
final r = await _client.checkUpdate().timeout(const Duration(seconds: 3));
|
||||||
const Duration(seconds: 3),
|
|
||||||
);
|
|
||||||
if (!r.updateAvailable) return null;
|
if (!r.updateAvailable) return null;
|
||||||
return UpdateStatus(
|
return UpdateStatus(
|
||||||
channel: r.channel,
|
channel: r.channel,
|
||||||
|
|
@ -1264,9 +1269,9 @@ class HubService {
|
||||||
Future<String> hubVersion() async {
|
Future<String> hubVersion() async {
|
||||||
try {
|
try {
|
||||||
final r = await _client.checkUpdate().timeout(
|
final r = await _client.checkUpdate().timeout(
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
onTimeout: () => CheckUpdateResponse(),
|
onTimeout: () => CheckUpdateResponse(),
|
||||||
);
|
);
|
||||||
return r.localVersion;
|
return r.localVersion;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -1326,8 +1331,7 @@ class HubService {
|
||||||
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
||||||
services: services
|
services: services
|
||||||
.map(
|
.map(
|
||||||
(s) =>
|
(s) => ServiceEntry(
|
||||||
ServiceEntry(
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
endpoint: s.endpoint,
|
endpoint: s.endpoint,
|
||||||
tags: s.tags,
|
tags: s.tags,
|
||||||
|
|
@ -1775,6 +1779,11 @@ class ApprovalRecord {
|
||||||
/// Project slug the requesting run was stamped with.
|
/// Project slug the requesting run was stamped with.
|
||||||
final String project;
|
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({
|
const ApprovalRecord({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.flowName,
|
required this.flowName,
|
||||||
|
|
@ -1788,6 +1797,7 @@ class ApprovalRecord {
|
||||||
required this.decidedBy,
|
required this.decidedBy,
|
||||||
required this.reason,
|
required this.reason,
|
||||||
this.project = '',
|
this.project = '',
|
||||||
|
this.flowExecution,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1101,6 +1101,13 @@
|
||||||
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
|
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
|
||||||
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
||||||
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
|
"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",
|
"approvalsApproveButton": "Freigeben",
|
||||||
"approvalsRejectButton": "Ablehnen",
|
"approvalsRejectButton": "Ablehnen",
|
||||||
"approvalsRejectDialogTitle": "Freigabe ablehnen",
|
"approvalsRejectDialogTitle": "Freigabe ablehnen",
|
||||||
|
|
@ -1822,6 +1829,8 @@
|
||||||
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
|
"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.",
|
"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",
|
"runsHubTooOldButton": "Diagnose öffnen",
|
||||||
|
"runsHubUpdateButton": "Hub jetzt aktualisieren",
|
||||||
|
"runsHubUpdateStarted": "Hub wird aktualisiert…",
|
||||||
"runsLoadFailedTitle": "Läufe konnten nicht geladen werden",
|
"runsLoadFailedTitle": "Läufe konnten nicht geladen werden",
|
||||||
"runsCancelButton": "Abbrechen",
|
"runsCancelButton": "Abbrechen",
|
||||||
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
||||||
|
|
|
||||||
|
|
@ -1119,6 +1119,13 @@
|
||||||
"approvalsNoDataConfirmAction": "Approve anyway",
|
"approvalsNoDataConfirmAction": "Approve anyway",
|
||||||
"approvalsRequestFallback": "Approval required for this step",
|
"approvalsRequestFallback": "Approval required for this step",
|
||||||
"approvalsFlowStepMeta": "Flow: {flow} · Step: {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": {
|
"@approvalsFlowStepMeta": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"flow": { "type": "String" },
|
"flow": { "type": "String" },
|
||||||
|
|
@ -1861,6 +1868,8 @@
|
||||||
"runsHubTooOldTitle": "This view needs a newer hub version",
|
"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.",
|
"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",
|
"runsHubTooOldButton": "Open Doctor",
|
||||||
|
"runsHubUpdateButton": "Update hub now",
|
||||||
|
"runsHubUpdateStarted": "Updating hub…",
|
||||||
"runsLoadFailedTitle": "Runs could not be loaded",
|
"runsLoadFailedTitle": "Runs could not be loaded",
|
||||||
"runsCancelButton": "Cancel",
|
"runsCancelButton": "Cancel",
|
||||||
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
||||||
|
|
|
||||||
|
|
@ -3554,6 +3554,48 @@ abstract class AppLocalizations {
|
||||||
/// **'Flow: {flow} · Step: {step}'**
|
/// **'Flow: {flow} · Step: {step}'**
|
||||||
String approvalsFlowStepMeta(String flow, String 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.
|
/// No description provided for @approvalsApproveButton.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5683,6 +5725,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Open Doctor'**
|
/// **'Open Doctor'**
|
||||||
String get runsHubTooOldButton;
|
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.
|
/// No description provided for @runsLoadFailedTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2058,6 +2058,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Flow: $flow · Schritt: $step';
|
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
|
@override
|
||||||
String get approvalsApproveButton => 'Freigeben';
|
String get approvalsApproveButton => 'Freigeben';
|
||||||
|
|
||||||
|
|
@ -3366,6 +3388,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsHubTooOldButton => 'Diagnose öffnen';
|
String get runsHubTooOldButton => 'Diagnose öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateButton => 'Hub jetzt aktualisieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateStarted => 'Hub wird aktualisiert…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden';
|
String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2062,6 +2062,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Flow: $flow · Step: $step';
|
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
|
@override
|
||||||
String get approvalsApproveButton => 'Approve';
|
String get approvalsApproveButton => 'Approve';
|
||||||
|
|
||||||
|
|
@ -3357,6 +3379,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsHubTooOldButton => 'Open Doctor';
|
String get runsHubTooOldButton => 'Open Doctor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateButton => 'Update hub now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateStarted => 'Updating hub…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get runsLoadFailedTitle => 'Runs could not be loaded';
|
String get runsLoadFailedTitle => 'Runs could not be loaded';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _reject(ApprovalRecord a) async {
|
Future<void> _reject(ApprovalRecord a) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final reason = await _promptReason(context);
|
final reason = await _promptReason(context);
|
||||||
|
|
@ -380,46 +379,66 @@ class _PendingList extends StatelessWidget {
|
||||||
final selected = pending
|
final selected = pending
|
||||||
.where((a) => selectedIds.contains(a.id))
|
.where((a) => selectedIds.contains(a.id))
|
||||||
.toList();
|
.toList();
|
||||||
return Stack(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
ListView.separated(
|
// A calm one-line explainer so a first-time reviewer knows
|
||||||
padding: EdgeInsets.fromLTRB(
|
// what this inbox is and what Approve / Reject actually do,
|
||||||
|
// without opening the doc sheet.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
ChainSpace.xl,
|
ChainSpace.xl,
|
||||||
|
ChainSpace.lg,
|
||||||
ChainSpace.xl,
|
ChainSpace.xl,
|
||||||
ChainSpace.xl,
|
0,
|
||||||
// Keep the last card clear of the floating
|
|
||||||
// batch action bar.
|
|
||||||
selected.isEmpty ? ChainSpace.xl : 96.0,
|
|
||||||
),
|
),
|
||||||
itemCount: pending.length,
|
child: ChainInlineHelp(text: l.approvalsIntroHelp),
|
||||||
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)
|
Expanded(
|
||||||
Positioned(
|
child: Stack(
|
||||||
left: ChainSpace.xl,
|
children: [
|
||||||
right: ChainSpace.xl,
|
ListView.separated(
|
||||||
bottom: ChainSpace.lg,
|
padding: EdgeInsets.fromLTRB(
|
||||||
child: _BatchActionBar(
|
ChainSpace.xl,
|
||||||
selectedCount: selected.length,
|
ChainSpace.md,
|
||||||
totalCount: pending.length,
|
ChainSpace.xl,
|
||||||
inFlight: batchInFlight,
|
// Keep the last card clear of the floating
|
||||||
onSelectAll: () => onSelectAll(pending),
|
// batch action bar.
|
||||||
onClear: onClearSelection,
|
selected.isEmpty ? ChainSpace.xl : 96.0,
|
||||||
onApprove: () => onBatchApprove(selected),
|
),
|
||||||
onReject: () => onBatchReject(selected),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.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(
|
return ChainCard(
|
||||||
accentTop: true,
|
accentTop: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -623,26 +658,54 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: ChainSpace.md),
|
||||||
// Technical context, demoted to a small metadata line.
|
// Origin block — the "which flow, which step, which run, in
|
||||||
Row(
|
// which project, requested when" a reviewer needs to place
|
||||||
children: [
|
// the request in context. The run id is copyable so it can
|
||||||
Icon(
|
// be cross-referenced against the event timeline on the
|
||||||
Icons.account_tree_outlined,
|
// Audit page.
|
||||||
size: 13,
|
Text(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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(
|
originLine(
|
||||||
child: Text(
|
Icons.schedule,
|
||||||
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
|
Text(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
'${l.approvalsOriginRequested}: '
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
'${_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),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
|
@ -720,22 +783,28 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
OutlinedButton.icon(
|
Tooltip(
|
||||||
onPressed: onReject,
|
message: l.approvalsRejectTooltip,
|
||||||
icon: const Icon(Icons.close, size: 16),
|
child: OutlinedButton.icon(
|
||||||
label: Text(l.approvalsRejectButton),
|
onPressed: onReject,
|
||||||
style: OutlinedButton.styleFrom(
|
icon: const Icon(Icons.close, size: 16),
|
||||||
foregroundColor: theme.colorScheme.error,
|
label: Text(l.approvalsRejectButton),
|
||||||
side: BorderSide(
|
style: OutlinedButton.styleFrom(
|
||||||
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
foregroundColor: theme.colorScheme.error,
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
FilledButton.icon(
|
Tooltip(
|
||||||
onPressed: onApprove,
|
message: l.approvalsApproveTooltip,
|
||||||
icon: const Icon(Icons.check, size: 16),
|
child: FilledButton.icon(
|
||||||
label: Text(l.approvalsApproveButton),
|
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,
|
AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||||
_formatTimestamp(record.createdAt.toLocal()),
|
_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)
|
if (record.reason.isNotEmpty)
|
||||||
_kv(
|
_kv(
|
||||||
theme,
|
theme,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
import '../data/friendly_error.dart';
|
import '../data/friendly_error.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
import '../data/system_actions.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
import '../main.dart' show StudioShellState;
|
||||||
|
|
@ -78,16 +79,28 @@ class RunsLoadErrorView extends StatelessWidget {
|
||||||
/// Opens the runs guide (the feature-off empty state's CTA).
|
/// Opens the runs guide (the feature-off empty state's CTA).
|
||||||
final VoidCallback onOpenGuide;
|
final VoidCallback onOpenGuide;
|
||||||
|
|
||||||
/// Opens the doctor page (the "hub too old" state's CTA, where
|
/// Opens the doctor page (the "hub too old" state's secondary CTA,
|
||||||
/// the update banner lives). Null hides the button.
|
/// and the fallback when the update cannot run from here). Null
|
||||||
|
/// hides the button.
|
||||||
final VoidCallback? onOpenDoctor;
|
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({
|
const RunsLoadErrorView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.error,
|
required this.error,
|
||||||
required this.issue,
|
required this.issue,
|
||||||
required this.onOpenGuide,
|
required this.onOpenGuide,
|
||||||
this.onOpenDoctor,
|
this.onOpenDoctor,
|
||||||
|
this.onUpdateHub,
|
||||||
|
this.updating = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -110,16 +123,57 @@ class RunsLoadErrorView extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case RunsLoadIssue.unsupported:
|
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(
|
return ChainEmptyState(
|
||||||
icon: Icons.system_update_alt_outlined,
|
icon: Icons.system_update_alt_outlined,
|
||||||
title: l.runsHubTooOldTitle,
|
title: l.runsHubTooOldTitle,
|
||||||
hint: l.runsHubTooOldHint,
|
hint: l.runsHubTooOldHint,
|
||||||
action: onOpenDoctor == null
|
action: onUpdateHub == null
|
||||||
? null
|
? (onOpenDoctor == null
|
||||||
: OutlinedButton.icon(
|
? null
|
||||||
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
|
: OutlinedButton.icon(
|
||||||
label: Text(l.runsHubTooOldButton),
|
icon: const Icon(
|
||||||
onPressed: onOpenDoctor,
|
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:
|
case RunsLoadIssue.unreachable:
|
||||||
|
|
@ -150,6 +204,7 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
RunsLoadIssue _issue = RunsLoadIssue.other;
|
RunsLoadIssue _issue = RunsLoadIssue.other;
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
Timer? _poll;
|
Timer? _poll;
|
||||||
|
bool _updatingHub = false;
|
||||||
final Set<String> _cancelling = <String>{};
|
final Set<String> _cancelling = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -214,6 +269,54 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the hub update in place from the "hub too old" state.
|
||||||
|
/// Uses the same `chain update apply --channel <c>` path as the
|
||||||
|
/// Diagnose page. When there is no local CLI, or the release host
|
||||||
|
/// offers no update, we hand off to the Diagnose page, which
|
||||||
|
/// already renders the offline / locate-binary recovery cleanly
|
||||||
|
/// instead of guessing here.
|
||||||
|
Future<void> _updateHub() async {
|
||||||
|
final shell = StudioShellState.of(context);
|
||||||
|
if (!SystemActions.chainBinaryExists()) {
|
||||||
|
shell?.navigateTo('doctor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _updatingHub = true);
|
||||||
|
try {
|
||||||
|
final status = await HubService.instance.checkHubUpdate();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (status == null) {
|
||||||
|
// No update on offer, or the release host is unreachable —
|
||||||
|
// the Diagnose page states the exact reason.
|
||||||
|
setState(() => _updatingHub = false);
|
||||||
|
shell?.navigateTo('doctor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final r = await SystemActions.chainUpdateApply(status.channel);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _updatingHub = false);
|
||||||
|
if (r.ok) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l.doctorApplyDone)));
|
||||||
|
// The CLI restarts the daemon; the 2 s poll picks up the
|
||||||
|
// now-supported RPC on its own, but nudge it immediately.
|
||||||
|
await _refresh();
|
||||||
|
} else {
|
||||||
|
showChainErrorSnack(
|
||||||
|
context,
|
||||||
|
'runs.update',
|
||||||
|
(r.stderr.isEmpty ? r.stdout : r.stderr).trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _updatingHub = false);
|
||||||
|
showChainErrorSnack(context, 'runs.update', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -247,6 +350,8 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
||||||
onOpenDoctor: () =>
|
onOpenDoctor: () =>
|
||||||
StudioShellState.of(context)?.navigateTo('doctor'),
|
StudioShellState.of(context)?.navigateTo('doctor'),
|
||||||
|
onUpdateHub: _updateHub,
|
||||||
|
updating: _updatingHub,
|
||||||
)
|
)
|
||||||
: _runs.isEmpty
|
: _runs.isEmpty
|
||||||
? ChainEmptyState(
|
? ChainEmptyState(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.80.0
|
version: 0.81.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
90
test/approvals_origin_test.dart
Normal file
90
test/approvals_origin_test.dart
Normal file
|
|
@ -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<Text>(find.byType(Text))) {
|
||||||
|
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
|
||||||
|
}
|
||||||
|
for (final w in tester.widgetList<SelectableText>(
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,53 @@ void main() {
|
||||||
expect(doctorOpened, isTrue);
|
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', (
|
testWidgets('a genuinely unreachable hub still says unreachable', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@ class FakeHubService extends Fake implements HubService {
|
||||||
bool detachedEnabled = false;
|
bool detachedEnabled = false;
|
||||||
bool probeServing = true;
|
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<ApprovalRecord> approvals = const [];
|
||||||
|
|
||||||
void failWith(Object error, {Set<String>? only}) {
|
void failWith(Object error, {Set<String>? only}) {
|
||||||
if (only == null) {
|
if (only == null) {
|
||||||
failAll = error;
|
failAll = error;
|
||||||
|
|
@ -209,7 +214,12 @@ class FakeHubService extends Fake implements HubService {
|
||||||
List<String> statuses = const [],
|
List<String> statuses = const [],
|
||||||
int limit = 200,
|
int limit = 200,
|
||||||
String project = '',
|
String project = '',
|
||||||
}) => _async('listApprovalsRecords', () => const []);
|
}) => _async(
|
||||||
|
'listApprovalsRecords',
|
||||||
|
() => statuses.isEmpty
|
||||||
|
? approvals
|
||||||
|
: approvals.where((a) => statuses.contains(a.status)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue