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 createState() => _RunsPageState(); } class _RunsPageState extends State { List _runs = const []; String? _error; bool _loaded = false; Timer? _poll; final Set _cancelling = {}; @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 _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 _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, ) : 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 = []; 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); } } }