feat(approvals,runs): explain approvals in place + one-click hub update (0.81.0)

Approvals: the pending card now shows its full origin — flow, step,
run id (previously dropped at the Dart mapping layer), project, and
requested-at — under an ORIGIN heading, led by a one-line intro strip
that says what the inbox is and what Approve/Reject do. Approve/Reject
buttons carry tooltips; the history dialog gains project + run id.
Fixes the approvals doc drift (title/details/reviewer ->
prompt/show/timeout_seconds). Guard: approvals_origin_test renders the
card via the hermetic fake hub and pins every origin fact.

Runs: the "hub too old" state now leads with an in-place update button
(same `chain update apply` path as the Diagnose page), the Diagnose
deeplink demoted to secondary, with a CLI-absent fallback. Guard: two
new RunsLoadErrorView widget tests.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-26 15:50:41 +02:00
parent 14f824b8ef
commit 28f6fe1a9a
16 changed files with 613 additions and 115 deletions

View file

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

View file

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