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

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