feat(editor): Fix-dialog from diagnostic strip + inline approval in Run tab

Two big operator-UX wins that the previous batch missed:

1. Diagnostic-strip header gets a 'Beheben' (Fix) button next
   to 'Copy all'. Clicking opens a focused modal listing every
   issue with its quick-fix actions plus an 'apply every quick
   fix' master button. Replaces the cramped expand-strip flow
   for operators who want a deliberate dialog instead of
   scrolling in a 200-px footer.

2. Run tab inlines the Approve / Reject form right under any
   step that the hub paused on system.approval@^0. The
   FlowRunDriver gains three new methods (host-implements
   them; defaults throw with a clear message):

     - pendingApprovalIdForStep(flowName, stepId) → String?
     - approveApproval(approvalId, reviewer) → Future<void>
     - rejectApproval(approvalId, reviewer, reason)

   The _InlineApprovalCard polls the driver for ~3s waiting for
   the hub to materialise the approval row (event-stream race
   against the hub's create), then renders the same form the
   Approvals page does — reviewer + optional reason + Approve /
   Reject buttons. After submit the hub picks up the decision
   on its next poll and emits step.approved / step.rejected,
   which the existing event stream already maps to the right
   step status.

   Closes the operator question 'wie soll das gehen dass da
   Freigaben landen?': they land in the Approvals page AND
   inline in the Run tab; the inline path is now the natural
   workflow.

All 36 editor tests + 24 Studio tests green. Bumped to 0.21.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-09 09:18:17 +02:00
parent 8b38584555
commit 303e318ba8
5 changed files with 593 additions and 1 deletions

View file

@ -357,6 +357,25 @@ class _RunTabState extends State<RunTab> {
),
),
],
if (state.kind == _StepKind.awaiting) ...[
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(left: 24),
child: _InlineApprovalCard(
stepId: id,
flowName: _runFlowName ?? '',
driver: widget.driver,
strings: widget.strings,
onDecided: () {
// After approve/reject the hub keeps polling
// and either resumes the flow or fails it; the
// event stream surfaces the outcome. Nothing to
// do here besides redraw so the buttons go away.
if (mounted) setState(() {});
},
),
),
],
],
),
);
@ -707,3 +726,261 @@ class _CopyableErrorBoxState extends State<_CopyableErrorBox> {
);
}
}
/// Inline Approve / Reject card rendered under a step that
/// the hub has paused on `system.approval@^0`. Removes the
/// "go to the Approvals page" detour the operator otherwise
/// has to take the same decision can happen in-context while
/// the rest of the run is on screen.
///
/// Lifecycle:
/// 1. Card mounts in awaiting-state with a fetch-in-flight
/// indicator.
/// 2. Driver returns the pending approval id for this
/// (flowName, stepId) pair. Card switches to the Approve /
/// Reject form.
/// 3. Operator submits a decision. Card disables further
/// interaction and waits the next event (step.approved /
/// step.rejected) is what causes the hub to resume the
/// flow, and the StepStarted/StepFailed event will redraw
/// the parent step row + remove this card.
class _InlineApprovalCard extends StatefulWidget {
final String stepId;
final String flowName;
final FlowRunDriver? driver;
final FlowEditorStrings strings;
final VoidCallback onDecided;
const _InlineApprovalCard({
required this.stepId,
required this.flowName,
required this.driver,
required this.strings,
required this.onDecided,
});
@override
State<_InlineApprovalCard> createState() => _InlineApprovalCardState();
}
class _InlineApprovalCardState extends State<_InlineApprovalCard> {
String? _approvalId;
bool _fetching = true;
bool _submitting = false;
bool _submitted = false;
String? _error;
late final TextEditingController _reasonCtrl;
late final TextEditingController _reviewerCtrl;
@override
void initState() {
super.initState();
_reasonCtrl = TextEditingController();
_reviewerCtrl = TextEditingController(text: _defaultReviewer());
_fetch();
}
@override
void dispose() {
_reasonCtrl.dispose();
_reviewerCtrl.dispose();
super.dispose();
}
String _defaultReviewer() {
final user = Platform.environment['USER'] ??
Platform.environment['USERNAME'] ??
'studio';
return '$user@studio';
}
Future<void> _fetch() async {
final driver = widget.driver;
if (driver == null) {
setState(() => _fetching = false);
return;
}
// The hub creates the approval row when the flow hits the
// awaiting state. Race against the event arrival: poll up
// to 5 × 600 ms before giving up.
for (var i = 0; i < 5; i++) {
try {
final id = await driver.pendingApprovalIdForStep(
flowName: widget.flowName,
stepId: widget.stepId,
);
if (!mounted) return;
if (id != null) {
setState(() {
_approvalId = id;
_fetching = false;
});
return;
}
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_fetching = false;
});
return;
}
await Future.delayed(const Duration(milliseconds: 600));
}
if (mounted) setState(() => _fetching = false);
}
Future<void> _decide({required bool approve}) async {
final id = _approvalId;
final driver = widget.driver;
if (id == null || driver == null) return;
setState(() => _submitting = true);
try {
if (approve) {
await driver.approveApproval(
approvalId: id,
reviewer: _reviewerCtrl.text.trim(),
);
} else {
await driver.rejectApproval(
approvalId: id,
reviewer: _reviewerCtrl.text.trim(),
reason: _reasonCtrl.text.trim(),
);
}
if (!mounted) return;
setState(() {
_submitted = true;
_submitting = false;
});
widget.onDecided();
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_submitting = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final strings = widget.strings;
return Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.tertiary.withValues(alpha: 0.4),
),
),
child: _fetching
? Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(strings.runAwaitingApproval),
],
)
: _submitted
? Row(
children: [
Icon(
Icons.check_circle_outline,
size: 16,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: FaiSpace.sm),
Flexible(child: Text(strings.approvalDecided)),
],
)
: _approvalId == null
? Row(
children: [
Icon(
Icons.hourglass_empty,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Flexible(
child: Text(
_error ?? strings.approvalNotFound,
style: theme.textTheme.bodySmall,
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _reviewerCtrl,
decoration: InputDecoration(
labelText: strings.approvalReviewerLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 2,
child: TextField(
controller: _reasonCtrl,
decoration: InputDecoration(
labelText: strings.approvalReasonLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
],
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.sm),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: FaiSpace.sm),
Row(
children: [
FilledButton.icon(
onPressed: _submitting
? null
: () => _decide(approve: true),
icon: const Icon(Icons.check, size: 16),
label: Text(
_submitting
? strings.approvalSubmitting
: strings.approvalApprove,
),
),
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: _submitting
? null
: () => _decide(approve: false),
icon: const Icon(Icons.close, size: 16),
label: Text(strings.approvalReject),
),
],
),
],
),
);
}
}