chain-studio/lib/pages/runs.dart
flemming-it cc1618f512
Some checks are pending
Security / Security check (push) Waiting to run
fix(runs): plain-language empty state with a guide button
The empty state explained itself in operator jargon (detach,
detached.enabled, operator config) and offered no way forward.
Now it says what background runs are in everyday language and
opens the built-in guide — which carries the exact activation
steps — via a real button. DE and EN.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-17 23:23:15 +02:00

251 lines
7.8 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
/// Detached-runs monitor (T3 parity): the invocations submitted with
/// `detach: true`, with a cancel button while they are still running.
/// Workspace-scoped like Audit and Approvals. Detached invocations are
/// opt-in (`detached.enabled`), so an empty list is the normal case
/// for most operators — the empty state says so plainly.
class RunsPage extends StatefulWidget {
const RunsPage({super.key});
@override
State<RunsPage> createState() => _RunsPageState();
}
class _RunsPageState extends State<RunsPage> {
List<DetachedRun> _runs = const [];
String? _error;
bool _loaded = false;
Timer? _poll;
final Set<String> _cancelling = <String>{};
@override
void initState() {
super.initState();
Workspace.instance.addListener(_refresh);
Workspace.instance.ensureLoaded();
_refresh();
// A run's phase changes without user action, so poll — same 2 s
// tick the Audit page uses.
_poll = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
}
@override
void dispose() {
Workspace.instance.removeListener(_refresh);
_poll?.cancel();
super.dispose();
}
Future<void> _refresh() async {
try {
final runs = await HubService.instance.listDetachedRuns(
project: Workspace.instance.activeSlug,
);
if (!mounted) return;
setState(() {
_runs = runs;
_error = null;
_loaded = true;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loaded = true;
});
}
}
Future<void> _cancel(DetachedRun run) async {
final l = AppLocalizations.of(context)!;
setState(() => _cancelling.add(run.id));
try {
final ok = await HubService.instance.cancelDetachedRun(run.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok ? l.runsCancelSignalled(run.flowName) : l.runsCancelTooLate,
),
),
);
await _refresh();
} catch (e) {
if (!mounted) return;
showChainErrorSnack(context, 'runs.cancel', e);
} finally {
if (mounted) setState(() => _cancelling.remove(run.id));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(l.runsTitle),
actions: [
const ChainWorkspaceSwitcher(),
const SizedBox(width: ChainSpace.md),
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: l.helpTooltip,
onPressed: () => showFaiDoc(context, 'runs'),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: l.runsReloadTooltip,
onPressed: _refresh,
),
const SizedBox(width: ChainSpace.sm),
],
),
body: !_loaded
? const Center(child: CircularProgressIndicator())
: _error != null && _runs.isEmpty
? ChainEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: l.hubUnreachable,
hint: l.hubUnreachableHint,
)
: _runs.isEmpty
? ChainEmptyState(
icon: Icons.rocket_launch_outlined,
title: l.runsEmptyTitle,
hint: l.runsEmptyHint,
// The guide carries the plain-language explanation plus
// the exact operator steps (config snippet) — a click
// target instead of a raw config key in the hint.
action: OutlinedButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16),
label: Text(l.runsEmptyGuideButton),
onPressed: () => showFaiDoc(context, 'runs'),
),
)
: ListView.separated(
padding: const EdgeInsets.all(ChainSpace.lg),
itemCount: _runs.length,
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.sm),
itemBuilder: (context, i) => _RunRow(
run: _runs[i],
cancelling: _cancelling.contains(_runs[i].id),
onCancel: () => _cancel(_runs[i]),
),
),
);
}
}
class _RunRow extends StatelessWidget {
final DetachedRun run;
final bool cancelling;
final VoidCallback onCancel;
const _RunRow({
required this.run,
required this.cancelling,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final (tone, label) = _phaseChip(l);
return ChainCard(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
run.flowName.isEmpty ? run.id : run.flowName,
style: theme.textTheme.titleSmall,
),
const SizedBox(width: ChainSpace.sm),
ChainPill(label: label, tone: tone),
],
),
const SizedBox(height: 4),
Text(
_subtitle(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (run.error.isNotEmpty) ...[
const SizedBox(height: 4),
SelectableText(
run.error,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
),
),
if (run.isCancellable)
cancelling
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: Text(l.runsCancelButton),
),
],
),
);
}
String _subtitle(AppLocalizations l) {
final parts = <String>[];
if (run.currentStep.isNotEmpty) {
parts.add(l.runsCurrentStep(run.currentStep));
}
if (run.project.isNotEmpty) parts.add(run.project);
parts.add(run.id);
return parts.join(' · ');
}
(ChainPillTone, String) _phaseChip(AppLocalizations l) {
switch (run.phase) {
case DetachedPhase.pending:
return (ChainPillTone.neutral, l.runsPhasePending);
case DetachedPhase.running:
return (ChainPillTone.accent, l.runsPhaseRunning);
case DetachedPhase.succeeded:
return (ChainPillTone.success, l.runsPhaseSucceeded);
case DetachedPhase.failed:
return (ChainPillTone.danger, l.runsPhaseFailed);
case DetachedPhase.cancelled:
return (ChainPillTone.warning, l.runsPhaseCancelled);
case DetachedPhase.unknown:
return (ChainPillTone.neutral, l.runsPhaseUnknown);
}
}
}