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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<RunsPage> {
|
|||
RunsLoadIssue _issue = RunsLoadIssue.other;
|
||||
bool _loaded = false;
|
||||
Timer? _poll;
|
||||
bool _updatingHub = false;
|
||||
final Set<String> _cancelling = <String>{};
|
||||
|
||||
@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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -247,6 +350,8 @@ class _RunsPageState extends State<RunsPage> {
|
|||
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
||||
onOpenDoctor: () =>
|
||||
StudioShellState.of(context)?.navigateTo('doctor'),
|
||||
onUpdateHub: _updateHub,
|
||||
updating: _updatingHub,
|
||||
)
|
||||
: _runs.isEmpty
|
||||
? ChainEmptyState(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue