neoagent 3.0.1-beta.2 → 3.0.1-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.env.example +19 -0
  2. package/README.md +16 -0
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/index.md +1 -0
  7. package/flutter_app/lib/main.dart +4 -1
  8. package/flutter_app/lib/main_app_shell.dart +316 -0
  9. package/flutter_app/lib/main_billing.dart +1465 -0
  10. package/flutter_app/lib/main_chat.dart +1568 -231
  11. package/flutter_app/lib/main_controller.dart +263 -26
  12. package/flutter_app/lib/main_devices.dart +1 -1
  13. package/flutter_app/lib/main_install.dart +1147 -0
  14. package/flutter_app/lib/main_navigation.dart +12 -0
  15. package/flutter_app/lib/main_operations.dart +134 -9
  16. package/flutter_app/lib/main_settings.dart +148 -52
  17. package/flutter_app/lib/main_shared.dart +1 -0
  18. package/flutter_app/lib/src/backend_client.dart +69 -1
  19. package/landing/index.html +2 -2
  20. package/lib/manager.js +156 -0
  21. package/lib/schema_migrations.js +84 -0
  22. package/package.json +10 -4
  23. package/server/admin/access.js +12 -7
  24. package/server/admin/admin.css +78 -0
  25. package/server/admin/admin.js +511 -23
  26. package/server/admin/billing.js +415 -0
  27. package/server/admin/index.html +120 -13
  28. package/server/admin/users.js +15 -15
  29. package/server/db/database.js +13 -21
  30. package/server/db/sessions_db.js +8 -0
  31. package/server/http/middleware.js +4 -4
  32. package/server/http/routes.js +9 -0
  33. package/server/http/static.js +4 -2
  34. package/server/middleware/requireBilling.js +12 -0
  35. package/server/public/.last_build_id +1 -1
  36. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  37. package/server/public/flutter_bootstrap.js +1 -1
  38. package/server/public/main.dart.js +89120 -85431
  39. package/server/routes/admin.js +524 -29
  40. package/server/routes/agents.js +203 -21
  41. package/server/routes/billing.js +127 -0
  42. package/server/routes/billing_webhook.js +43 -0
  43. package/server/routes/memory.js +1 -4
  44. package/server/routes/settings.js +1 -2
  45. package/server/routes/skills.js +1 -1
  46. package/server/routes/triggers.js +2 -2
  47. package/server/services/account/sessions.js +1 -9
  48. package/server/services/ai/loop/agent_engine_core.js +42 -0
  49. package/server/services/ai/loop/blank_recovery.js +36 -0
  50. package/server/services/ai/loop/completion_judge.js +42 -0
  51. package/server/services/ai/loop/conversation_loop.js +248 -34
  52. package/server/services/ai/loop/progress_monitor.js +6 -6
  53. package/server/services/ai/loopPolicy.js +37 -44
  54. package/server/services/ai/messagingFallback.js +25 -1
  55. package/server/services/ai/models.js +18 -0
  56. package/server/services/ai/preModelCompaction.js +25 -5
  57. package/server/services/ai/rate_limits.js +28 -4
  58. package/server/services/ai/systemPrompt.js +23 -5
  59. package/server/services/ai/taskAnalysis.js +2 -0
  60. package/server/services/ai/tools.js +231 -20
  61. package/server/services/billing/billing_email.js +106 -0
  62. package/server/services/billing/config.js +17 -0
  63. package/server/services/billing/plans.js +121 -0
  64. package/server/services/billing/stripe_client.js +21 -0
  65. package/server/services/billing/subscriptions.js +462 -0
  66. package/server/services/billing/trial_guard.js +111 -0
  67. package/server/services/browser/contentExtractor.js +629 -0
  68. package/server/services/browser/controller.js +4 -8
  69. package/server/services/desktop/gateway.js +7 -4
  70. package/server/services/integrations/google/calendar.js +30 -2
  71. package/server/services/integrations/secrets.js +7 -4
  72. package/server/services/manager.js +11 -0
  73. package/server/services/messaging/automation.js +1 -1
  74. package/server/services/messaging/telnyx.js +12 -11
  75. package/server/services/messaging/whatsapp.js +1 -1
  76. package/server/services/recordings/manager.js +13 -7
  77. package/server/services/runtime/backends/local-vm.js +40 -22
  78. package/server/services/tasks/runtime.js +103 -15
  79. package/server/services/tasks/schedule_utils.js +5 -5
  80. package/server/services/voice/runtimeManager.js +19 -10
  81. package/server/services/wearable/gateway.js +8 -5
  82. package/server/services/websocket.js +26 -8
  83. package/server/services/workspace/manager.js +37 -3
@@ -11,6 +11,7 @@ class ChatPanel extends StatefulWidget {
11
11
 
12
12
  class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
13
13
  static const double _autoScrollBottomThreshold = 120;
14
+ static const double _olderHistoryLoadThreshold = 180;
14
15
  static const int _autoScrollSettlePasses = 4;
15
16
 
16
17
  late final TextEditingController _composerController;
@@ -21,12 +22,19 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
21
22
  String _lastScrollContentSignature = '';
22
23
  bool _stickToBottom = true;
23
24
  bool _ignoreScrollUpdates = false;
25
+ bool _loadingOlderHistory = false;
24
26
  int _scrollGeneration = 0;
27
+ // Opacity-hide the list while the initial batch of messages settles to the
28
+ // bottom, so the user never sees the layout jitter across settle passes.
29
+ bool _awaitingInitialScrollSettle = false;
30
+ int _visibleMessageCountAtLastSettle = 0;
25
31
  bool _isSendingChatMessage = false;
26
32
  bool _isDictating = false;
27
33
  bool _isTranscribing = false;
28
34
  LiveVoiceCapture? _dictationCapture;
29
35
  final List<Uint8List> _dictationChunks = [];
36
+ Timer? _connectionBannerTimer;
37
+ bool _showConnectionBanner = false;
30
38
 
31
39
  @override
32
40
  void initState() {
@@ -36,6 +44,7 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
36
44
  _composerController.addListener(_handleComposerLayoutChanged);
37
45
  _scrollController.addListener(_handleScrollPositionChanged);
38
46
  widget.controller.addListener(_consumeQueuedDraft);
47
+ widget.controller.addListener(_updateConnectionBanner);
39
48
  _consumeQueuedDraft();
40
49
  _scheduleScrollToBottom(force: true);
41
50
  }
@@ -45,24 +54,49 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
45
54
  super.didUpdateWidget(oldWidget);
46
55
  if (oldWidget.controller != widget.controller) {
47
56
  oldWidget.controller.removeListener(_consumeQueuedDraft);
57
+ oldWidget.controller.removeListener(_updateConnectionBanner);
48
58
  widget.controller.addListener(_consumeQueuedDraft);
59
+ widget.controller.addListener(_updateConnectionBanner);
49
60
  _appliedSharedPayloadSignature = null;
50
61
  _lastScrollContentSignature = '';
51
62
  _stickToBottom = true;
63
+ _awaitingInitialScrollSettle = false;
64
+ _visibleMessageCountAtLastSettle = 0;
52
65
  _consumeQueuedDraft();
53
66
  _scheduleScrollToBottom(force: true);
54
67
  }
55
68
  }
56
69
 
70
+ void _updateConnectionBanner() {
71
+ final connected = widget.controller.socketConnected;
72
+ if (connected) {
73
+ _connectionBannerTimer?.cancel();
74
+ _connectionBannerTimer = null;
75
+ if (_showConnectionBanner) {
76
+ setState(() => _showConnectionBanner = false);
77
+ }
78
+ } else if (!_showConnectionBanner && !widget.controller.isBooting) {
79
+ // ??= keeps the first timer alive; repeated disconnect events don't reset the 2s clock.
80
+ _connectionBannerTimer ??= Timer(const Duration(seconds: 2), () {
81
+ if (mounted && !widget.controller.socketConnected) {
82
+ setState(() => _showConnectionBanner = true);
83
+ }
84
+ _connectionBannerTimer = null;
85
+ });
86
+ }
87
+ }
88
+
57
89
  @override
58
90
  void dispose() {
59
91
  WidgetsBinding.instance.removeObserver(this);
60
92
  widget.controller.removeListener(_consumeQueuedDraft);
93
+ widget.controller.removeListener(_updateConnectionBanner);
61
94
  _composerController.removeListener(_handleComposerLayoutChanged);
62
95
  _scrollController.removeListener(_handleScrollPositionChanged);
63
96
  _composerController.dispose();
64
97
  _scrollController.dispose();
65
98
  _dictationCapture?.dispose();
99
+ _connectionBannerTimer?.cancel();
66
100
  super.dispose();
67
101
  }
68
102
 
@@ -267,8 +301,18 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
267
301
  return pos.pixels >= pos.maxScrollExtent - _autoScrollBottomThreshold;
268
302
  }
269
303
 
304
+ bool get _isNearTop {
305
+ if (!_scrollController.hasClients) return false;
306
+ final pos = _scrollController.position;
307
+ if (!pos.hasContentDimensions) return false;
308
+ return pos.pixels <= _olderHistoryLoadThreshold;
309
+ }
310
+
270
311
  void _handleScrollPositionChanged() {
271
312
  if (_ignoreScrollUpdates || !_scrollController.hasClients) return;
313
+ if (_isNearTop) {
314
+ unawaited(_maybeLoadOlderHistory());
315
+ }
272
316
  final nearBottom = _isNearBottom;
273
317
  if (_stickToBottom && !nearBottom) {
274
318
  _scrollGeneration++;
@@ -278,6 +322,46 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
278
322
  }
279
323
  }
280
324
 
325
+ Future<void> _maybeLoadOlderHistory() async {
326
+ final controller = widget.controller;
327
+ if (_loadingOlderHistory ||
328
+ controller.isLoadingOlderChatHistory ||
329
+ !controller.chatHistoryHasMore ||
330
+ !_isNearTop) {
331
+ return;
332
+ }
333
+ _loadingOlderHistory = true;
334
+ final hasClients = _scrollController.hasClients;
335
+ final previousPixels = hasClients ? _scrollController.position.pixels : 0.0;
336
+ final previousMaxExtent = hasClients
337
+ ? _scrollController.position.maxScrollExtent
338
+ : 0.0;
339
+ final loaded = await controller.loadOlderChatHistory();
340
+ if (!mounted || !loaded) {
341
+ _loadingOlderHistory = false;
342
+ return;
343
+ }
344
+ WidgetsBinding.instance.addPostFrameCallback((_) {
345
+ if (!mounted || !_scrollController.hasClients) {
346
+ _loadingOlderHistory = false;
347
+ return;
348
+ }
349
+ final position = _scrollController.position;
350
+ final extentDelta = position.maxScrollExtent - previousMaxExtent;
351
+ final targetOffset = (previousPixels + extentDelta).clamp(
352
+ 0.0,
353
+ position.maxScrollExtent,
354
+ );
355
+ _ignoreScrollUpdates = true;
356
+ _scrollController.jumpTo(targetOffset.toDouble());
357
+ _ignoreScrollUpdates = false;
358
+ _loadingOlderHistory = false;
359
+ if (_isNearTop) {
360
+ unawaited(_maybeLoadOlderHistory());
361
+ }
362
+ });
363
+ }
364
+
281
365
  void _openModelPicker() {
282
366
  final controller = widget.controller;
283
367
  if (controller.hasLiveRun) return;
@@ -368,6 +452,12 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
368
452
  settle(remainingPasses - 1);
369
453
  }
370
454
  });
455
+ } else if (_awaitingInitialScrollSettle) {
456
+ setState(() {
457
+ _awaitingInitialScrollSettle = false;
458
+ _visibleMessageCountAtLastSettle =
459
+ widget.controller.visibleChatMessages.length;
460
+ });
371
461
  }
372
462
  });
373
463
  }
@@ -384,6 +474,15 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
384
474
  final isInitialContent = _lastScrollContentSignature.isEmpty;
385
475
  final shouldFollow = _stickToBottom || isInitialContent;
386
476
  _lastScrollContentSignature = signature;
477
+
478
+ if (messages.isEmpty) {
479
+ // Agent switch resets the settle tracker so the next batch triggers hiding.
480
+ _visibleMessageCountAtLastSettle = 0;
481
+ } else if (_visibleMessageCountAtLastSettle == 0 && !_awaitingInitialScrollSettle) {
482
+ // Messages just went from 0 → N: hide until the scroll position settles.
483
+ _awaitingInitialScrollSettle = true;
484
+ }
485
+
387
486
  if (shouldFollow) {
388
487
  _scheduleScrollToBottom(force: isInitialContent);
389
488
  }
@@ -396,6 +495,18 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
396
495
  _maybeFollowChatContent(messages, controller);
397
496
 
398
497
  final threadChildren = <Widget>[
498
+ if (controller.isLoadingOlderChatHistory) ...<Widget>[
499
+ const Padding(
500
+ padding: EdgeInsets.only(bottom: 16),
501
+ child: Center(
502
+ child: SizedBox(
503
+ width: 20,
504
+ height: 20,
505
+ child: CircularProgressIndicator(strokeWidth: 2),
506
+ ),
507
+ ),
508
+ ),
509
+ ],
399
510
  if (controller.usageAndLimits case final usage?
400
511
  when usage.hasLimits) ...<Widget>[
401
512
  _RateLimitStatusCard(usage: usage),
@@ -471,26 +582,29 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
471
582
  Expanded(
472
583
  child: Stack(
473
584
  children: <Widget>[
474
- SelectionArea(
475
- child: ListView(
476
- controller: _scrollController,
477
- padding: EdgeInsets.fromLTRB(
478
- sidePadding,
479
- 30,
480
- sidePadding,
481
- 18,
482
- ),
483
- children: <Widget>[
484
- Center(
485
- child: ConstrainedBox(
486
- constraints: const BoxConstraints(maxWidth: 860),
487
- child: Column(
488
- crossAxisAlignment: CrossAxisAlignment.stretch,
489
- children: threadChildren,
585
+ Opacity(
586
+ opacity: _awaitingInitialScrollSettle ? 0.0 : 1.0,
587
+ child: SelectionArea(
588
+ child: ListView(
589
+ controller: _scrollController,
590
+ padding: EdgeInsets.fromLTRB(
591
+ sidePadding,
592
+ 30,
593
+ sidePadding,
594
+ 18,
595
+ ),
596
+ children: <Widget>[
597
+ Center(
598
+ child: ConstrainedBox(
599
+ constraints: const BoxConstraints(maxWidth: 860),
600
+ child: Column(
601
+ crossAxisAlignment: CrossAxisAlignment.stretch,
602
+ children: threadChildren,
603
+ ),
490
604
  ),
491
605
  ),
492
- ),
493
- ],
606
+ ],
607
+ ),
494
608
  ),
495
609
  ),
496
610
  if (!_stickToBottom)
@@ -518,6 +632,20 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
518
632
  constraints: const BoxConstraints(maxWidth: 860),
519
633
  child: Column(
520
634
  children: <Widget>[
635
+ if (_showConnectionBanner)
636
+ Padding(
637
+ padding: const EdgeInsets.only(bottom: 8),
638
+ child: _ConnectionReconnectingBanner(
639
+ hasNetwork: controller.hasNetworkConnection,
640
+ ),
641
+ ),
642
+ if (controller.pendingApproval != null)
643
+ Padding(
644
+ padding: const EdgeInsets.only(bottom: 8),
645
+ child: _PendingApprovalBanner(
646
+ approval: controller.pendingApproval!,
647
+ ),
648
+ ),
521
649
  if (_pendingSharedAttachments.isNotEmpty)
522
650
  Padding(
523
651
  padding: const EdgeInsets.only(bottom: 10),
@@ -1414,6 +1542,10 @@ class _MessagingPanelState extends State<MessagingPanel> {
1414
1542
  ),
1415
1543
  const SizedBox(height: 22),
1416
1544
  ],
1545
+ if (controller.ignoredChats.isNotEmpty) ...[
1546
+ const SizedBox(height: 18),
1547
+ _IgnoredChatsPanel(controller: controller),
1548
+ ],
1417
1549
  _MessagingActivityPanel(messages: controller.messagingMessages),
1418
1550
  ],
1419
1551
  );
@@ -1839,6 +1971,101 @@ class _MessagingGroupHeader extends StatelessWidget {
1839
1971
  }
1840
1972
  }
1841
1973
 
1974
+ class _IgnoredChatsPanel extends StatelessWidget {
1975
+ const _IgnoredChatsPanel({required this.controller});
1976
+
1977
+ final NeoAgentController controller;
1978
+
1979
+ @override
1980
+ Widget build(BuildContext context) {
1981
+ final ignored = controller.ignoredChats;
1982
+ return Container(
1983
+ padding: const EdgeInsets.all(18),
1984
+ decoration: BoxDecoration(
1985
+ color: _bgCard,
1986
+ borderRadius: BorderRadius.circular(8),
1987
+ border: Border.all(color: _borderLight),
1988
+ ),
1989
+ child: Column(
1990
+ crossAxisAlignment: CrossAxisAlignment.start,
1991
+ children: [
1992
+ Row(
1993
+ children: [
1994
+ Expanded(
1995
+ child: Column(
1996
+ crossAxisAlignment: CrossAxisAlignment.start,
1997
+ children: [
1998
+ Text(
1999
+ 'Ignored Channels',
2000
+ style: TextStyle(
2001
+ color: _textPrimary,
2002
+ fontSize: 18,
2003
+ fontWeight: FontWeight.w800,
2004
+ ),
2005
+ ),
2006
+ const SizedBox(height: 3),
2007
+ Text(
2008
+ 'These channels are permanently silenced. To receive messages from them, add them manually to the access policy for the relevant platform.',
2009
+ style: TextStyle(color: _textSecondary, fontSize: 13, height: 1.4),
2010
+ ),
2011
+ ],
2012
+ ),
2013
+ ),
2014
+ _StatusPill(label: '${ignored.length}', color: _textMuted),
2015
+ ],
2016
+ ),
2017
+ const SizedBox(height: 14),
2018
+ for (final key in ignored)
2019
+ Builder(builder: (context) {
2020
+ final sep = key.indexOf(':');
2021
+ final platform = sep > 0 ? key.substring(0, sep) : key;
2022
+ final chatId = sep > 0 ? key.substring(sep + 1) : '';
2023
+ return Container(
2024
+ margin: const EdgeInsets.only(bottom: 8),
2025
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2026
+ decoration: BoxDecoration(
2027
+ color: _bgSecondary,
2028
+ borderRadius: BorderRadius.circular(8),
2029
+ border: Border.all(color: _borderLight),
2030
+ ),
2031
+ child: Row(
2032
+ children: [
2033
+ Icon(Icons.block_rounded, size: 16, color: _textMuted),
2034
+ const SizedBox(width: 10),
2035
+ Expanded(
2036
+ child: Column(
2037
+ crossAxisAlignment: CrossAxisAlignment.start,
2038
+ children: [
2039
+ Text(
2040
+ platform.toUpperCase(),
2041
+ style: TextStyle(
2042
+ color: _textMuted,
2043
+ fontSize: 10,
2044
+ fontWeight: FontWeight.w700,
2045
+ letterSpacing: 0.6,
2046
+ ),
2047
+ ),
2048
+ Text(
2049
+ chatId.isNotEmpty ? chatId : platform,
2050
+ style: TextStyle(color: _textPrimary, fontSize: 13),
2051
+ ),
2052
+ ],
2053
+ ),
2054
+ ),
2055
+ TextButton(
2056
+ onPressed: () => controller.removeIgnoredChat(key),
2057
+ child: Text('Remove'),
2058
+ ),
2059
+ ],
2060
+ ),
2061
+ );
2062
+ }),
2063
+ ],
2064
+ ),
2065
+ );
2066
+ }
2067
+ }
2068
+
1842
2069
  class _MessagingActivityPanel extends StatelessWidget {
1843
2070
  const _MessagingActivityPanel({required this.messages});
1844
2071
 
@@ -1967,6 +2194,156 @@ class _MessagingActivityItem extends StatelessWidget {
1967
2194
  }
1968
2195
  }
1969
2196
 
2197
+ // ─── Connection reconnecting banner (#65) ─────────────────────────────────
2198
+
2199
+ class _ConnectionReconnectingBanner extends StatelessWidget {
2200
+ const _ConnectionReconnectingBanner({required this.hasNetwork});
2201
+
2202
+ final bool hasNetwork;
2203
+
2204
+ @override
2205
+ Widget build(BuildContext context) {
2206
+ final msg = hasNetwork ? 'Reconnecting to server…' : 'No network connection';
2207
+ return Container(
2208
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2209
+ decoration: BoxDecoration(
2210
+ color: _warning.withValues(alpha: 0.12),
2211
+ borderRadius: BorderRadius.circular(12),
2212
+ border: Border.all(color: _warning.withValues(alpha: 0.32)),
2213
+ ),
2214
+ child: Row(
2215
+ children: <Widget>[
2216
+ if (hasNetwork)
2217
+ SizedBox.square(
2218
+ dimension: 14,
2219
+ child: CircularProgressIndicator(strokeWidth: 2, color: _warning),
2220
+ )
2221
+ else
2222
+ Icon(Icons.wifi_off_outlined, size: 16, color: _warning),
2223
+ const SizedBox(width: 10),
2224
+ Expanded(
2225
+ child: Text(
2226
+ msg,
2227
+ style: TextStyle(
2228
+ color: _warning,
2229
+ fontSize: 13,
2230
+ fontWeight: FontWeight.w600,
2231
+ ),
2232
+ ),
2233
+ ),
2234
+ ],
2235
+ ),
2236
+ );
2237
+ }
2238
+ }
2239
+
2240
+ // ─── Pending approval banner (#62) ────────────────────────────────────────
2241
+
2242
+ class _PendingApprovalBanner extends StatefulWidget {
2243
+ const _PendingApprovalBanner({required this.approval});
2244
+
2245
+ final ToolApprovalRequest approval;
2246
+
2247
+ @override
2248
+ State<_PendingApprovalBanner> createState() => _PendingApprovalBannerState();
2249
+ }
2250
+
2251
+ class _PendingApprovalBannerState extends State<_PendingApprovalBanner> {
2252
+ late Timer _timer;
2253
+ int _secondsRemaining = 30;
2254
+
2255
+ @override
2256
+ void initState() {
2257
+ super.initState();
2258
+ _secondsRemaining = _remaining();
2259
+ _timer = Timer.periodic(const Duration(seconds: 1), (_) {
2260
+ if (mounted) setState(() => _secondsRemaining = _remaining());
2261
+ });
2262
+ }
2263
+
2264
+ @override
2265
+ void didUpdateWidget(covariant _PendingApprovalBanner oldWidget) {
2266
+ super.didUpdateWidget(oldWidget);
2267
+ if (oldWidget.approval.approvalId != widget.approval.approvalId) {
2268
+ _timer.cancel();
2269
+ _timer = Timer.periodic(const Duration(seconds: 1), (_) {
2270
+ if (mounted) setState(() => _secondsRemaining = _remaining());
2271
+ });
2272
+ setState(() => _secondsRemaining = _remaining());
2273
+ }
2274
+ }
2275
+
2276
+ int _remaining() {
2277
+ final expiry = widget.approval.expiresAt;
2278
+ if (expiry == null) return 30;
2279
+ return expiry.difference(DateTime.now()).inSeconds.clamp(0, 300);
2280
+ }
2281
+
2282
+ @override
2283
+ void dispose() {
2284
+ _timer.cancel();
2285
+ super.dispose();
2286
+ }
2287
+
2288
+ @override
2289
+ Widget build(BuildContext context) {
2290
+ final toolName = widget.approval.toolName.ifEmpty('tool');
2291
+ return Container(
2292
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2293
+ decoration: BoxDecoration(
2294
+ color: _accent.withValues(alpha: 0.10),
2295
+ borderRadius: BorderRadius.circular(12),
2296
+ border: Border.all(color: _accent.withValues(alpha: 0.32)),
2297
+ ),
2298
+ child: Row(
2299
+ children: <Widget>[
2300
+ _PulseHalo(
2301
+ color: _accent,
2302
+ animate: true,
2303
+ child: Container(
2304
+ width: 22,
2305
+ height: 22,
2306
+ decoration: BoxDecoration(
2307
+ color: _accent.withValues(alpha: 0.16),
2308
+ shape: BoxShape.circle,
2309
+ ),
2310
+ child: Icon(
2311
+ Icons.security_outlined,
2312
+ size: 13,
2313
+ color: _accent,
2314
+ ),
2315
+ ),
2316
+ ),
2317
+ const SizedBox(width: 10),
2318
+ Expanded(
2319
+ child: RichText(
2320
+ text: TextSpan(
2321
+ style: TextStyle(color: _textPrimary, fontSize: 13),
2322
+ children: <TextSpan>[
2323
+ const TextSpan(
2324
+ text: 'Waiting for approval: ',
2325
+ style: TextStyle(fontWeight: FontWeight.w600),
2326
+ ),
2327
+ TextSpan(
2328
+ text: toolName,
2329
+ style: TextStyle(color: _accent),
2330
+ ),
2331
+ TextSpan(
2332
+ text: ' ${_secondsRemaining}s',
2333
+ style: TextStyle(color: _textSecondary),
2334
+ ),
2335
+ ],
2336
+ ),
2337
+ ),
2338
+ ),
2339
+ ],
2340
+ ),
2341
+ );
2342
+ }
2343
+ }
2344
+
2345
+ // ─── Runs page ─────────────────────────────────────────────────────────────
2346
+
1970
2347
  class RunsPanel extends StatefulWidget {
1971
2348
  const RunsPanel({super.key, required this.controller, this.embedded = false});
1972
2349
 
@@ -1984,6 +2361,7 @@ class _RunsPanelState extends State<RunsPanel> {
1984
2361
  RunDetailSnapshot? _detail;
1985
2362
  bool _loadingDetail = false;
1986
2363
  String? _detailError;
2364
+ String? _selectedGraphNodeId;
1987
2365
 
1988
2366
  @override
1989
2367
  void initState() {
@@ -2160,127 +2538,155 @@ class _RunsPanelState extends State<RunsPanel> {
2160
2538
  orElse: () => null,
2161
2539
  );
2162
2540
  final detail = _detail?.run.id == selected?.id ? _detail : null;
2541
+ final padding = widget.embedded ? EdgeInsets.zero : _pagePadding(context);
2542
+
2543
+ Widget header = const SizedBox.shrink();
2544
+ if (!widget.embedded) {
2545
+ header = _PageTitle(
2546
+ title: 'Runs',
2547
+ subtitle: 'Explore run flows, tool steps, and responses.',
2548
+ trailing: OutlinedButton.icon(
2549
+ onPressed: _refreshRuns,
2550
+ icon: const Icon(Icons.refresh),
2551
+ label: const Text('Refresh'),
2552
+ ),
2553
+ );
2554
+ }
2163
2555
 
2164
- return ListView(
2165
- padding: widget.embedded ? EdgeInsets.zero : _pagePadding(context),
2166
- children: <Widget>[
2167
- if (!widget.embedded)
2168
- _PageTitle(
2169
- title: 'Runs',
2170
- subtitle:
2171
- 'Inspect recent runs, failures, tool steps, and final responses.',
2172
- trailing: OutlinedButton.icon(
2173
- onPressed: _refreshRuns,
2174
- icon: Icon(Icons.refresh),
2175
- label: Text('Refresh'),
2176
- ),
2177
- )
2178
- else
2179
- Align(
2180
- alignment: Alignment.centerRight,
2181
- child: Padding(
2182
- padding: const EdgeInsets.only(bottom: 12),
2183
- child: OutlinedButton.icon(
2184
- onPressed: _refreshRuns,
2185
- icon: const Icon(Icons.refresh),
2186
- label: const Text('Refresh'),
2187
- ),
2188
- ),
2189
- ),
2190
- if (controller.errorMessage != null) ...<Widget>[
2191
- _InlineError(message: controller.errorMessage!),
2192
- const SizedBox(height: 16),
2193
- ],
2194
- if (controller.activeRun != null ||
2195
- controller.toolEvents.isNotEmpty) ...<Widget>[
2196
- _RunStatusPanel(
2197
- run: controller.activeRun,
2198
- tools: controller.toolEvents,
2199
- ),
2200
- const SizedBox(height: 16),
2201
- ],
2202
- if (controller.recentRuns.isEmpty)
2556
+ if (controller.recentRuns.isEmpty) {
2557
+ return ListView(
2558
+ padding: padding,
2559
+ children: <Widget>[
2560
+ header,
2203
2561
  const _EmptyCard(
2204
2562
  title: 'No runs yet',
2205
2563
  subtitle:
2206
2564
  'Send a task from chat and its execution history will show up here.',
2207
- )
2208
- else ...<Widget>[
2209
- _RunsMetricsStrip(
2210
- runs: filteredRuns,
2211
- totalLoaded: controller.recentRuns.length,
2212
- ),
2213
- const SizedBox(height: 16),
2214
- _RunsFilterBar(
2215
- searchController: _searchController,
2216
- statusFilter: _statusFilter,
2217
- onStatusChanged: _setStatusFilter,
2218
2565
  ),
2219
- const SizedBox(height: 16),
2220
- if (filteredRuns.isEmpty)
2221
- const _EmptyCard(
2222
- title: 'No matching runs',
2223
- subtitle:
2224
- 'Try clearing the search or switching the status filter.',
2225
- )
2226
- else
2227
- LayoutBuilder(
2228
- builder: (context, constraints) {
2229
- final wide = constraints.maxWidth >= 1120;
2230
- final historyPane = _RunsHistoryPane(
2231
- runs: filteredRuns,
2232
- selectedRunId: _selectedRunId,
2233
- onSelect: _selectRun,
2234
- );
2235
- final detailPane = _RunDetailWorkspace(
2236
- run: selected,
2237
- detail: detail,
2238
- errorMessage: _detailError,
2239
- loading: _loadingDetail,
2240
- onDelete: _deleteSelectedRun,
2241
- onCopyResponse: _copyResponse,
2242
- );
2243
- if (!wide) {
2244
- return Column(
2245
- children: <Widget>[
2246
- detailPane,
2247
- const SizedBox(height: 16),
2248
- historyPane,
2249
- ],
2250
- );
2251
- }
2252
- return Row(
2253
- crossAxisAlignment: CrossAxisAlignment.start,
2254
- children: <Widget>[
2255
- SizedBox(width: 360, child: historyPane),
2256
- const SizedBox(width: 16),
2257
- Expanded(child: detailPane),
2258
- ],
2259
- );
2260
- },
2261
- ),
2262
2566
  ],
2263
- ],
2567
+ );
2568
+ }
2569
+
2570
+ final selectorRail = _RunSelectorRail(
2571
+ controller: controller,
2572
+ filteredRuns: filteredRuns,
2573
+ selectedRunId: _selectedRunId,
2574
+ searchController: _searchController,
2575
+ statusFilter: _statusFilter,
2576
+ onStatusChanged: _setStatusFilter,
2577
+ onSelect: (id) {
2578
+ _selectRun(id);
2579
+ setState(() => _selectedGraphNodeId = null);
2580
+ },
2581
+ onRefresh: _refreshRuns,
2264
2582
  );
2265
- }
2266
- }
2267
2583
 
2268
- class _MessagingCard extends StatelessWidget {
2269
- const _MessagingCard({
2270
- required this.platform,
2271
- required this.status,
2272
- required this.accessCatalog,
2273
- required this.controller,
2274
- required this.onConnect,
2275
- required this.onDisconnect,
2276
- required this.onLogout,
2277
- });
2584
+ final graphCanvas = _RunFlowGraphCanvas(
2585
+ run: selected,
2586
+ detail: detail,
2587
+ loading: _loadingDetail,
2588
+ errorMessage: _detailError,
2589
+ selectedNodeId: _selectedGraphNodeId,
2590
+ onNodeSelected: (id) => setState(() => _selectedGraphNodeId = id),
2591
+ );
2278
2592
 
2279
- final MessagingPlatformDescriptor platform;
2280
- final MessagingPlatformStatus? status;
2281
- final MessagingAccessCatalog accessCatalog;
2282
- final NeoAgentController controller;
2283
- final Future<void> Function() onConnect;
2593
+ final detailPanel = _RunNodeDetailPanel(
2594
+ run: selected,
2595
+ detail: detail,
2596
+ nodeId: _selectedGraphNodeId,
2597
+ loading: _loadingDetail,
2598
+ onDelete: _deleteSelectedRun,
2599
+ onCopyResponse: _copyResponse,
2600
+ );
2601
+
2602
+ return LayoutBuilder(
2603
+ builder: (context, constraints) {
2604
+ final wide = constraints.maxWidth >= 1100;
2605
+ final medium = constraints.maxWidth >= 760;
2606
+
2607
+ final body = wide
2608
+ ? Row(
2609
+ crossAxisAlignment: CrossAxisAlignment.start,
2610
+ children: <Widget>[
2611
+ SizedBox(width: 260, child: selectorRail),
2612
+ const SizedBox(width: 12),
2613
+ Expanded(child: graphCanvas),
2614
+ const SizedBox(width: 12),
2615
+ SizedBox(width: 310, child: detailPanel),
2616
+ ],
2617
+ )
2618
+ : medium
2619
+ ? Row(
2620
+ crossAxisAlignment: CrossAxisAlignment.start,
2621
+ children: <Widget>[
2622
+ SizedBox(width: 220, child: selectorRail),
2623
+ const SizedBox(width: 12),
2624
+ Expanded(
2625
+ child: Column(
2626
+ crossAxisAlignment: CrossAxisAlignment.stretch,
2627
+ children: <Widget>[
2628
+ graphCanvas,
2629
+ const SizedBox(height: 12),
2630
+ detailPanel,
2631
+ ],
2632
+ ),
2633
+ ),
2634
+ ],
2635
+ )
2636
+ : Column(
2637
+ crossAxisAlignment: CrossAxisAlignment.stretch,
2638
+ children: <Widget>[
2639
+ graphCanvas,
2640
+ const SizedBox(height: 12),
2641
+ selectorRail,
2642
+ const SizedBox(height: 12),
2643
+ detailPanel,
2644
+ ],
2645
+ );
2646
+
2647
+ return SingleChildScrollView(
2648
+ padding: padding,
2649
+ child: Column(
2650
+ crossAxisAlignment: CrossAxisAlignment.stretch,
2651
+ children: <Widget>[
2652
+ header,
2653
+ if (controller.errorMessage != null) ...<Widget>[
2654
+ _InlineError(message: controller.errorMessage!),
2655
+ const SizedBox(height: 12),
2656
+ ],
2657
+ if (controller.activeRun != null ||
2658
+ controller.toolEvents.isNotEmpty) ...<Widget>[
2659
+ _RunStatusPanel(
2660
+ run: controller.activeRun,
2661
+ tools: controller.toolEvents,
2662
+ ),
2663
+ const SizedBox(height: 12),
2664
+ ],
2665
+ body,
2666
+ ],
2667
+ ),
2668
+ );
2669
+ },
2670
+ );
2671
+ }
2672
+ }
2673
+
2674
+ class _MessagingCard extends StatelessWidget {
2675
+ const _MessagingCard({
2676
+ required this.platform,
2677
+ required this.status,
2678
+ required this.accessCatalog,
2679
+ required this.controller,
2680
+ required this.onConnect,
2681
+ required this.onDisconnect,
2682
+ required this.onLogout,
2683
+ });
2684
+
2685
+ final MessagingPlatformDescriptor platform;
2686
+ final MessagingPlatformStatus? status;
2687
+ final MessagingAccessCatalog accessCatalog;
2688
+ final NeoAgentController controller;
2689
+ final Future<void> Function() onConnect;
2284
2690
  final Future<void> Function() onDisconnect;
2285
2691
  final Future<void> Function() onLogout;
2286
2692
 
@@ -3645,113 +4051,105 @@ class _RunHeroCard extends StatelessWidget {
3645
4051
 
3646
4052
  @override
3647
4053
  Widget build(BuildContext context) {
3648
- return Container(
3649
- width: double.infinity,
3650
- padding: const EdgeInsets.all(22),
3651
- decoration: BoxDecoration(
3652
- gradient: LinearGradient(
3653
- colors: <Color>[
3654
- run.statusColor.withValues(alpha: 0.18),
3655
- _bgSecondary,
3656
- ],
3657
- begin: Alignment.topLeft,
3658
- end: Alignment.bottomRight,
3659
- ),
3660
- borderRadius: BorderRadius.circular(24),
3661
- border: Border.all(color: _borderLight),
3662
- ),
3663
- child: Column(
3664
- crossAxisAlignment: CrossAxisAlignment.start,
3665
- children: <Widget>[
3666
- Row(
3667
- crossAxisAlignment: CrossAxisAlignment.start,
3668
- children: <Widget>[
3669
- Expanded(
3670
- child: Column(
3671
- crossAxisAlignment: CrossAxisAlignment.start,
3672
- children: <Widget>[
3673
- Wrap(
3674
- spacing: 10,
3675
- runSpacing: 10,
3676
- children: <Widget>[
3677
- _StatusPill(
3678
- label: run.statusLabel,
3679
- color: run.statusColor,
3680
- ),
3681
- _MetaPill(
3682
- label: run.triggerLabel,
3683
- icon: Icons.bolt_outlined,
3684
- ),
3685
- _MetaPill(
3686
- label: run.modelLabel,
3687
- icon: Icons.memory_outlined,
3688
- ),
3689
- if (run.deliverableType.trim().isNotEmpty)
3690
- _MetaPill(
3691
- label: run.deliverableType.replaceAll('_', ' '),
3692
- icon: Icons.inventory_2_outlined,
3693
- ),
3694
- ],
3695
- ),
3696
- const SizedBox(height: 16),
3697
- Text(
3698
- run.title,
3699
- style: TextStyle(
3700
- fontSize: 24,
3701
- fontWeight: FontWeight.w800,
3702
- height: 1.15,
3703
- ),
3704
- ),
3705
- const SizedBox(height: 10),
3706
- Wrap(
3707
- spacing: 10,
3708
- runSpacing: 10,
3709
- children: <Widget>[
3710
- _MetaPill(
3711
- label: 'Started ${run.createdAtLabel}',
3712
- icon: Icons.schedule_outlined,
3713
- ),
3714
- _MetaPill(
3715
- label: run.durationLabel,
3716
- icon: Icons.timer_outlined,
3717
- ),
3718
- _MetaPill(
3719
- label: '${run.totalTokensLabel} tokens',
3720
- icon: Icons.toll_outlined,
3721
- ),
3722
- _MetaPill(
3723
- label: run.id.length <= 12
3724
- ? run.id
3725
- : '${run.id.substring(0, 12)}…',
3726
- icon: Icons.tag_outlined,
3727
- ),
3728
- ],
3729
- ),
3730
- ],
4054
+ final statusColor = run.statusColor;
4055
+ return Card(
4056
+ child: Padding(
4057
+ padding: const EdgeInsets.all(14),
4058
+ child: Column(
4059
+ crossAxisAlignment: CrossAxisAlignment.start,
4060
+ children: <Widget>[
4061
+ Row(
4062
+ crossAxisAlignment: CrossAxisAlignment.center,
4063
+ children: <Widget>[
4064
+ Container(
4065
+ width: 8,
4066
+ height: 8,
4067
+ decoration: BoxDecoration(
4068
+ color: statusColor,
4069
+ shape: BoxShape.circle,
4070
+ ),
4071
+ ),
4072
+ const SizedBox(width: 7),
4073
+ Text(
4074
+ run.statusLabel,
4075
+ style: TextStyle(
4076
+ color: statusColor,
4077
+ fontWeight: FontWeight.w600,
4078
+ fontSize: 12,
4079
+ ),
3731
4080
  ),
4081
+ const Spacer(),
4082
+ IconButton(
4083
+ tooltip: 'Delete run',
4084
+ icon: const Icon(Icons.delete_outline, size: 18),
4085
+ onPressed: onDelete,
4086
+ visualDensity: VisualDensity.compact,
4087
+ color: _textSecondary,
4088
+ ),
4089
+ ],
4090
+ ),
4091
+ const SizedBox(height: 8),
4092
+ Text(
4093
+ run.title,
4094
+ style: const TextStyle(
4095
+ fontSize: 15,
4096
+ fontWeight: FontWeight.w700,
4097
+ height: 1.3,
3732
4098
  ),
3733
- const SizedBox(width: 12),
3734
- OutlinedButton.icon(
3735
- onPressed: onDelete,
3736
- icon: Icon(Icons.delete_outline),
3737
- label: Text('Delete'),
4099
+ maxLines: 3,
4100
+ overflow: TextOverflow.ellipsis,
4101
+ ),
4102
+ const SizedBox(height: 10),
4103
+ Wrap(
4104
+ spacing: 6,
4105
+ runSpacing: 6,
4106
+ children: <Widget>[
4107
+ _MetaPill(
4108
+ label: run.triggerLabel,
4109
+ icon: Icons.bolt_outlined,
4110
+ ),
4111
+ _MetaPill(
4112
+ label: run.modelLabel,
4113
+ icon: Icons.memory_outlined,
4114
+ ),
4115
+ _MetaPill(
4116
+ label: run.createdAtLabel,
4117
+ icon: Icons.schedule_outlined,
4118
+ ),
4119
+ _MetaPill(
4120
+ label: run.durationLabel,
4121
+ icon: Icons.timer_outlined,
4122
+ ),
4123
+ if (run.totalTokensLabel.isNotEmpty)
4124
+ _MetaPill(
4125
+ label: '${run.totalTokensLabel} tok',
4126
+ icon: Icons.toll_outlined,
4127
+ ),
4128
+ if (run.deliverableType.trim().isNotEmpty)
4129
+ _MetaPill(
4130
+ label: run.deliverableType.replaceAll('_', ' '),
4131
+ icon: Icons.inventory_2_outlined,
4132
+ ),
4133
+ ],
4134
+ ),
4135
+ if (run.error.trim().isNotEmpty) ...<Widget>[
4136
+ const SizedBox(height: 10),
4137
+ Container(
4138
+ width: double.infinity,
4139
+ padding: const EdgeInsets.all(10),
4140
+ decoration: BoxDecoration(
4141
+ color: const Color(0x19EF4444),
4142
+ borderRadius: BorderRadius.circular(10),
4143
+ border: Border.all(color: const Color(0x4CEF4444)),
4144
+ ),
4145
+ child: Text(
4146
+ run.error,
4147
+ style: TextStyle(fontSize: 12, height: 1.45),
4148
+ ),
3738
4149
  ),
3739
4150
  ],
3740
- ),
3741
- if (run.error.trim().isNotEmpty) ...<Widget>[
3742
- const SizedBox(height: 16),
3743
- Container(
3744
- width: double.infinity,
3745
- padding: const EdgeInsets.all(14),
3746
- decoration: BoxDecoration(
3747
- color: const Color(0x19EF4444),
3748
- borderRadius: BorderRadius.circular(14),
3749
- border: Border.all(color: const Color(0x4CEF4444)),
3750
- ),
3751
- child: Text(run.error, style: TextStyle(height: 1.45)),
3752
- ),
3753
4151
  ],
3754
- ],
4152
+ ),
3755
4153
  ),
3756
4154
  );
3757
4155
  }
@@ -4203,6 +4601,945 @@ class _RunDetailBlock extends StatelessWidget {
4203
4601
  }
4204
4602
  }
4205
4603
 
4604
+ // ─── Run flow graph (#66) ─────────────────────────────────────────────────
4605
+
4606
+ // ── Data model ──────────────────────────────────────────────────────────────
4607
+
4608
+ class _FlowNode {
4609
+ _FlowNode({
4610
+ required this.id,
4611
+ required this.label,
4612
+ required this.nodeType,
4613
+ required this.status,
4614
+ this.step,
4615
+ });
4616
+
4617
+ final String id;
4618
+ final String label;
4619
+ final String nodeType;
4620
+ final String status;
4621
+ final RunStepItem? step;
4622
+ double x = 0;
4623
+ double y = 0;
4624
+ int lane = 0;
4625
+
4626
+ static const double w = 156.0;
4627
+ static const double h = 64.0;
4628
+ static const double hGap = 34.0;
4629
+ static const double vGap = 80.0;
4630
+
4631
+ Color get nodeColor {
4632
+ if (status == 'failed') return _danger;
4633
+ switch (nodeType) {
4634
+ case 'start':
4635
+ return _accent;
4636
+ case 'end':
4637
+ return _success;
4638
+ case 'fail':
4639
+ return _danger;
4640
+ case 'model':
4641
+ return _info;
4642
+ case 'plan':
4643
+ return _warning;
4644
+ case 'subagent':
4645
+ return _accentHover;
4646
+ case 'verify':
4647
+ return const Color(0xFF8B5CF6);
4648
+ default:
4649
+ return _success;
4650
+ }
4651
+ }
4652
+
4653
+ IconData get nodeIcon {
4654
+ switch (nodeType) {
4655
+ case 'start':
4656
+ return Icons.play_circle_outline;
4657
+ case 'end':
4658
+ return Icons.check_circle_outline;
4659
+ case 'fail':
4660
+ return Icons.error_outline;
4661
+ case 'model':
4662
+ return Icons.psychology_outlined;
4663
+ case 'plan':
4664
+ return Icons.list_alt_outlined;
4665
+ case 'subagent':
4666
+ return Icons.account_tree_outlined;
4667
+ case 'verify':
4668
+ return Icons.fact_check_outlined;
4669
+ case 'note':
4670
+ return Icons.notes_outlined;
4671
+ default:
4672
+ return Icons.build_circle_outlined;
4673
+ }
4674
+ }
4675
+ }
4676
+
4677
+ List<_FlowNode> _buildRunFlowNodes(RunDetailSnapshot detail) {
4678
+ final nodes = <_FlowNode>[];
4679
+
4680
+ nodes.add(
4681
+ _FlowNode(
4682
+ id: '__start__',
4683
+ label: 'Start',
4684
+ nodeType: 'start',
4685
+ status: 'completed',
4686
+ ),
4687
+ );
4688
+
4689
+ int subLaneCounter = 0;
4690
+ for (final step in detail.steps) {
4691
+ final lower = '${step.type} ${step.toolName}'.toLowerCase();
4692
+ String nodeType;
4693
+ int nodeLane = 0;
4694
+ if (lower.contains('model') ||
4695
+ lower.contains('think') ||
4696
+ lower.contains('llm')) {
4697
+ nodeType = 'model';
4698
+ } else if (lower.contains('plan')) {
4699
+ nodeType = 'plan';
4700
+ } else if (lower.contains('subagent') ||
4701
+ lower.contains('helper') ||
4702
+ lower.contains('delegat')) {
4703
+ nodeType = 'subagent';
4704
+ nodeLane = ++subLaneCounter;
4705
+ } else if (lower.contains('verif')) {
4706
+ nodeType = 'verify';
4707
+ } else if (lower.contains('note') || lower.contains('analysis')) {
4708
+ nodeType = 'note';
4709
+ } else {
4710
+ nodeType = 'tool';
4711
+ }
4712
+ nodes.add(
4713
+ _FlowNode(
4714
+ id: step.id,
4715
+ label: step.label,
4716
+ nodeType: nodeType,
4717
+ status: step.status,
4718
+ step: step,
4719
+ )..lane = nodeLane,
4720
+ );
4721
+ }
4722
+
4723
+ nodes.add(
4724
+ _FlowNode(
4725
+ id: '__end__',
4726
+ label: detail.run.isFailure ? 'Failed' : 'Done',
4727
+ nodeType: detail.run.isFailure ? 'fail' : 'end',
4728
+ status: detail.run.isFailure ? 'failed' : 'completed',
4729
+ ),
4730
+ );
4731
+
4732
+ // Layout positions
4733
+ final mainNodes = nodes.where((n) => n.lane == 0).toList();
4734
+ double cx = 0;
4735
+ for (final n in mainNodes) {
4736
+ n.x = cx;
4737
+ n.y = 0;
4738
+ cx += _FlowNode.w + _FlowNode.hGap;
4739
+ }
4740
+
4741
+ final subLanes = <int, List<_FlowNode>>{};
4742
+ for (final n in nodes.where((n) => n.lane > 0)) {
4743
+ subLanes.putIfAbsent(n.lane, () => []).add(n);
4744
+ }
4745
+ int laneRow = 1;
4746
+ for (final laneNodes in subLanes.values) {
4747
+ double subCx = _FlowNode.w + _FlowNode.hGap;
4748
+ for (final n in laneNodes) {
4749
+ n.x = subCx;
4750
+ n.y = laneRow * (_FlowNode.h + _FlowNode.vGap);
4751
+ subCx += _FlowNode.w + _FlowNode.hGap;
4752
+ }
4753
+ laneRow++;
4754
+ }
4755
+
4756
+ return nodes;
4757
+ }
4758
+
4759
+ List<(String, String, bool)> _buildRunFlowEdges(List<_FlowNode> nodes) {
4760
+ final edges = <(String, String, bool)>[];
4761
+ final mainLane = nodes.where((n) => n.lane == 0).toList();
4762
+ for (int i = 0; i + 1 < mainLane.length; i++) {
4763
+ edges.add((mainLane[i].id, mainLane[i + 1].id, false));
4764
+ }
4765
+ // Delegation nodes: connect from start to their first node
4766
+ for (final n in nodes.where((n) => n.lane > 0)) {
4767
+ edges.add(('__start__', n.id, true));
4768
+ }
4769
+ return edges;
4770
+ }
4771
+
4772
+ // ── Edge painter ──────────────────────────────────────────────────────────────
4773
+
4774
+ class _RunGraphEdgePainter extends CustomPainter {
4775
+ const _RunGraphEdgePainter({required this.nodeMap, required this.edges});
4776
+
4777
+ final Map<String, _FlowNode> nodeMap;
4778
+ final List<(String, String, bool)> edges;
4779
+
4780
+ @override
4781
+ void paint(Canvas canvas, Size size) {
4782
+ for (final (fromId, toId, isDelegation) in edges) {
4783
+ final from = nodeMap[fromId];
4784
+ final to = nodeMap[toId];
4785
+ if (from == null || to == null) continue;
4786
+
4787
+ final color = isDelegation
4788
+ ? _accentHover.withValues(alpha: 0.55)
4789
+ : _border.withValues(alpha: 0.8);
4790
+
4791
+ final paint = Paint()
4792
+ ..color = color
4793
+ ..style = PaintingStyle.stroke
4794
+ ..strokeWidth = 1.8
4795
+ ..strokeCap = StrokeCap.round;
4796
+
4797
+ final start = Offset(
4798
+ from.x + _FlowNode.w,
4799
+ from.y + _FlowNode.h / 2,
4800
+ );
4801
+ final end = Offset(to.x, to.y + _FlowNode.h / 2);
4802
+
4803
+ if (from.y != to.y) {
4804
+ final mid = (start.dx + end.dx) / 2;
4805
+ final path = Path()
4806
+ ..moveTo(start.dx, start.dy)
4807
+ ..cubicTo(mid, start.dy, mid, end.dy, end.dx, end.dy);
4808
+ canvas.drawPath(path, paint);
4809
+ } else {
4810
+ canvas.drawLine(start, end, paint);
4811
+ }
4812
+
4813
+ // Arrowhead
4814
+ final arrowPaint = Paint()
4815
+ ..color = color
4816
+ ..style = PaintingStyle.fill;
4817
+ const arrowSize = 6.0;
4818
+ final arrow = Path()
4819
+ ..moveTo(end.dx, end.dy)
4820
+ ..lineTo(end.dx - arrowSize, end.dy - arrowSize * 0.5)
4821
+ ..lineTo(end.dx - arrowSize, end.dy + arrowSize * 0.5)
4822
+ ..close();
4823
+ canvas.drawPath(arrow, arrowPaint);
4824
+ }
4825
+ }
4826
+
4827
+ @override
4828
+ bool shouldRepaint(_RunGraphEdgePainter old) =>
4829
+ old.nodeMap != nodeMap || old.edges != edges;
4830
+ }
4831
+
4832
+ // ── Individual node widget ────────────────────────────────────────────────────
4833
+
4834
+ class _FlowNodeWidget extends StatelessWidget {
4835
+ const _FlowNodeWidget({
4836
+ required this.node,
4837
+ required this.selected,
4838
+ required this.onTap,
4839
+ });
4840
+
4841
+ final _FlowNode node;
4842
+ final bool selected;
4843
+ final VoidCallback onTap;
4844
+
4845
+ @override
4846
+ Widget build(BuildContext context) {
4847
+ final color = node.nodeColor;
4848
+ final isSpecial =
4849
+ node.nodeType == 'start' ||
4850
+ node.nodeType == 'end' ||
4851
+ node.nodeType == 'fail';
4852
+ return GestureDetector(
4853
+ onTap: onTap,
4854
+ child: Container(
4855
+ width: _FlowNode.w,
4856
+ height: _FlowNode.h,
4857
+ decoration: BoxDecoration(
4858
+ color: selected
4859
+ ? color.withValues(alpha: 0.16)
4860
+ : isSpecial
4861
+ ? color.withValues(alpha: 0.10)
4862
+ : _bgCard,
4863
+ borderRadius: BorderRadius.circular(18),
4864
+ border: Border.all(
4865
+ color: selected ? color : color.withValues(alpha: 0.30),
4866
+ width: selected ? 2.0 : 1.2,
4867
+ ),
4868
+ boxShadow: <BoxShadow>[
4869
+ BoxShadow(
4870
+ color: color.withValues(alpha: selected ? 0.22 : 0.07),
4871
+ blurRadius: selected ? 14 : 6,
4872
+ offset: const Offset(0, 3),
4873
+ ),
4874
+ ],
4875
+ ),
4876
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
4877
+ child: Column(
4878
+ mainAxisAlignment: MainAxisAlignment.center,
4879
+ crossAxisAlignment: CrossAxisAlignment.start,
4880
+ children: <Widget>[
4881
+ Row(
4882
+ children: <Widget>[
4883
+ Icon(node.nodeIcon, size: 13, color: color),
4884
+ const SizedBox(width: 6),
4885
+ Expanded(
4886
+ child: Text(
4887
+ node.label,
4888
+ maxLines: 1,
4889
+ overflow: TextOverflow.ellipsis,
4890
+ style: TextStyle(
4891
+ fontSize: 12,
4892
+ fontWeight: FontWeight.w700,
4893
+ color: _textPrimary,
4894
+ ),
4895
+ ),
4896
+ ),
4897
+ ],
4898
+ ),
4899
+ const SizedBox(height: 4),
4900
+ Row(
4901
+ children: <Widget>[
4902
+ Container(
4903
+ width: 5,
4904
+ height: 5,
4905
+ decoration: BoxDecoration(
4906
+ shape: BoxShape.circle,
4907
+ color: color,
4908
+ ),
4909
+ ),
4910
+ const SizedBox(width: 5),
4911
+ Text(
4912
+ isSpecial ? node.nodeType : node.status,
4913
+ style: TextStyle(
4914
+ color: _textSecondary,
4915
+ fontSize: 11,
4916
+ ),
4917
+ ),
4918
+ ],
4919
+ ),
4920
+ ],
4921
+ ),
4922
+ ),
4923
+ );
4924
+ }
4925
+ }
4926
+
4927
+ // ── Flow graph canvas ────────────────────────────────────────────────────────
4928
+
4929
+ class _RunFlowGraphCanvas extends StatefulWidget {
4930
+ const _RunFlowGraphCanvas({
4931
+ required this.run,
4932
+ required this.detail,
4933
+ required this.loading,
4934
+ required this.errorMessage,
4935
+ required this.selectedNodeId,
4936
+ required this.onNodeSelected,
4937
+ });
4938
+
4939
+ final RunSummary? run;
4940
+ final RunDetailSnapshot? detail;
4941
+ final bool loading;
4942
+ final String? errorMessage;
4943
+ final String? selectedNodeId;
4944
+ final ValueChanged<String?> onNodeSelected;
4945
+
4946
+ @override
4947
+ State<_RunFlowGraphCanvas> createState() => _RunFlowGraphCanvasState();
4948
+ }
4949
+
4950
+ class _RunFlowGraphCanvasState extends State<_RunFlowGraphCanvas> {
4951
+ final TransformationController _transform = TransformationController();
4952
+ String? _lastRunId;
4953
+
4954
+ @override
4955
+ void didUpdateWidget(covariant _RunFlowGraphCanvas old) {
4956
+ super.didUpdateWidget(old);
4957
+ final currentId = widget.detail?.run.id ?? widget.run?.id;
4958
+ if (currentId != _lastRunId && currentId != null) {
4959
+ _lastRunId = currentId;
4960
+ _transform.value = Matrix4.identity();
4961
+ }
4962
+ }
4963
+
4964
+ @override
4965
+ void dispose() {
4966
+ _transform.dispose();
4967
+ super.dispose();
4968
+ }
4969
+
4970
+ @override
4971
+ Widget build(BuildContext context) {
4972
+ if (widget.run == null) {
4973
+ return const _EmptyCard(
4974
+ title: 'Select a run',
4975
+ subtitle: 'Pick a run from the list on the left to explore its flow.',
4976
+ );
4977
+ }
4978
+
4979
+ if (widget.loading && widget.detail == null) {
4980
+ return Card(
4981
+ child: Padding(
4982
+ padding: const EdgeInsets.all(40),
4983
+ child: Row(
4984
+ mainAxisAlignment: MainAxisAlignment.center,
4985
+ children: <Widget>[
4986
+ const SizedBox.square(
4987
+ dimension: 20,
4988
+ child: CircularProgressIndicator(strokeWidth: 2),
4989
+ ),
4990
+ const SizedBox(width: 14),
4991
+ Text(
4992
+ 'Loading run flow…',
4993
+ style: TextStyle(color: _textSecondary),
4994
+ ),
4995
+ ],
4996
+ ),
4997
+ ),
4998
+ );
4999
+ }
5000
+
5001
+ if (widget.errorMessage != null) {
5002
+ return _InlineError(message: widget.errorMessage!);
5003
+ }
5004
+
5005
+ final detail = widget.detail;
5006
+ if (detail == null) {
5007
+ return const _EmptyCard(
5008
+ title: 'No detail available',
5009
+ subtitle: 'This run has no recorded step data.',
5010
+ );
5011
+ }
5012
+
5013
+ final nodes = _buildRunFlowNodes(detail);
5014
+ final edges = _buildRunFlowEdges(nodes);
5015
+ final nodeMap = <String, _FlowNode>{for (final n in nodes) n.id: n};
5016
+
5017
+ final canvasW =
5018
+ nodes.fold(0.0, (m, n) => math.max(m, n.x + _FlowNode.w)) + 48;
5019
+ final canvasH =
5020
+ nodes.fold(0.0, (m, n) => math.max(m, n.y + _FlowNode.h)) + 48;
5021
+
5022
+ final stepCount = nodes.length - 2; // subtract start + end virtuals
5023
+
5024
+ return Card(
5025
+ child: Column(
5026
+ crossAxisAlignment: CrossAxisAlignment.start,
5027
+ children: <Widget>[
5028
+ Padding(
5029
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
5030
+ child: Row(
5031
+ children: <Widget>[
5032
+ Expanded(
5033
+ child: Text(
5034
+ '$stepCount step${stepCount == 1 ? '' : 's'} · tap a node to inspect',
5035
+ style: TextStyle(
5036
+ color: _textSecondary,
5037
+ fontSize: 12.5,
5038
+ ),
5039
+ ),
5040
+ ),
5041
+ if (widget.loading)
5042
+ const Padding(
5043
+ padding: EdgeInsets.only(right: 8),
5044
+ child: SizedBox.square(
5045
+ dimension: 14,
5046
+ child: CircularProgressIndicator(strokeWidth: 2),
5047
+ ),
5048
+ ),
5049
+ TextButton.icon(
5050
+ onPressed: () => _transform.value = Matrix4.identity(),
5051
+ style: TextButton.styleFrom(
5052
+ visualDensity: VisualDensity.compact,
5053
+ padding: const EdgeInsets.symmetric(
5054
+ horizontal: 10,
5055
+ vertical: 6,
5056
+ ),
5057
+ ),
5058
+ icon: const Icon(Icons.center_focus_strong_outlined, size: 15),
5059
+ label: const Text('Reset view', style: TextStyle(fontSize: 12)),
5060
+ ),
5061
+ ],
5062
+ ),
5063
+ ),
5064
+ const SizedBox(height: 8),
5065
+ SizedBox(
5066
+ height: math.max(220.0, math.min(canvasH + 24, 380.0)),
5067
+ child: ClipRect(
5068
+ child: InteractiveViewer(
5069
+ transformationController: _transform,
5070
+ constrained: false,
5071
+ minScale: 0.2,
5072
+ maxScale: 3.0,
5073
+ boundaryMargin: const EdgeInsets.all(64),
5074
+ child: Padding(
5075
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
5076
+ child: SizedBox(
5077
+ width: canvasW,
5078
+ height: canvasH,
5079
+ child: Stack(
5080
+ children: <Widget>[
5081
+ Positioned.fill(
5082
+ child: CustomPaint(
5083
+ painter: _RunGraphEdgePainter(
5084
+ nodeMap: nodeMap,
5085
+ edges: edges,
5086
+ ),
5087
+ ),
5088
+ ),
5089
+ ...nodes.map(
5090
+ (node) => Positioned(
5091
+ left: node.x,
5092
+ top: node.y,
5093
+ child: _FlowNodeWidget(
5094
+ node: node,
5095
+ selected: node.id == widget.selectedNodeId,
5096
+ onTap: () => widget.onNodeSelected(
5097
+ node.id == widget.selectedNodeId ? null : node.id,
5098
+ ),
5099
+ ),
5100
+ ),
5101
+ ),
5102
+ ],
5103
+ ),
5104
+ ),
5105
+ ),
5106
+ ),
5107
+ ),
5108
+ ),
5109
+ const SizedBox(height: 8),
5110
+ Padding(
5111
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
5112
+ child: Wrap(
5113
+ spacing: 12,
5114
+ runSpacing: 8,
5115
+ children: <Widget>[
5116
+ _GraphLegendChip(color: _info, label: 'Model turn'),
5117
+ _GraphLegendChip(color: _success, label: 'Tool'),
5118
+ _GraphLegendChip(color: _warning, label: 'Plan'),
5119
+ _GraphLegendChip(color: _accentHover, label: 'Sub-agent'),
5120
+ _GraphLegendChip(color: _danger, label: 'Failed'),
5121
+ ],
5122
+ ),
5123
+ ),
5124
+ ],
5125
+ ),
5126
+ );
5127
+ }
5128
+ }
5129
+
5130
+ class _GraphLegendChip extends StatelessWidget {
5131
+ const _GraphLegendChip({required this.color, required this.label});
5132
+
5133
+ final Color color;
5134
+ final String label;
5135
+
5136
+ @override
5137
+ Widget build(BuildContext context) {
5138
+ return Row(
5139
+ mainAxisSize: MainAxisSize.min,
5140
+ children: <Widget>[
5141
+ Container(
5142
+ width: 8,
5143
+ height: 8,
5144
+ decoration: BoxDecoration(shape: BoxShape.circle, color: color),
5145
+ ),
5146
+ const SizedBox(width: 5),
5147
+ Text(label, style: TextStyle(fontSize: 11, color: _textSecondary)),
5148
+ ],
5149
+ );
5150
+ }
5151
+ }
5152
+
5153
+ // ── Run selector rail ─────────────────────────────────────────────────────────
5154
+
5155
+ class _RunSelectorRail extends StatelessWidget {
5156
+ const _RunSelectorRail({
5157
+ required this.controller,
5158
+ required this.filteredRuns,
5159
+ required this.selectedRunId,
5160
+ required this.searchController,
5161
+ required this.statusFilter,
5162
+ required this.onStatusChanged,
5163
+ required this.onSelect,
5164
+ required this.onRefresh,
5165
+ });
5166
+
5167
+ final NeoAgentController controller;
5168
+ final List<RunSummary> filteredRuns;
5169
+ final String? selectedRunId;
5170
+ final TextEditingController searchController;
5171
+ final String statusFilter;
5172
+ final ValueChanged<String> onStatusChanged;
5173
+ final ValueChanged<String> onSelect;
5174
+ final VoidCallback onRefresh;
5175
+
5176
+ @override
5177
+ Widget build(BuildContext context) {
5178
+ const filters = <String>['all', 'running', 'completed', 'failed'];
5179
+ final totalLoaded = controller.recentRuns.length;
5180
+
5181
+ return Card(
5182
+ child: Padding(
5183
+ padding: const EdgeInsets.all(14),
5184
+ child: Column(
5185
+ crossAxisAlignment: CrossAxisAlignment.start,
5186
+ children: <Widget>[
5187
+ Row(
5188
+ children: <Widget>[
5189
+ Expanded(
5190
+ child: Text(
5191
+ '${filteredRuns.length}${filteredRuns.length != totalLoaded ? '/$totalLoaded' : ''} runs',
5192
+ style: const TextStyle(
5193
+ fontWeight: FontWeight.w700,
5194
+ fontSize: 13,
5195
+ ),
5196
+ ),
5197
+ ),
5198
+ IconButton(
5199
+ tooltip: 'Refresh',
5200
+ icon: const Icon(Icons.refresh, size: 18),
5201
+ onPressed: onRefresh,
5202
+ visualDensity: VisualDensity.compact,
5203
+ ),
5204
+ ],
5205
+ ),
5206
+ const SizedBox(height: 8),
5207
+ TextField(
5208
+ controller: searchController,
5209
+ decoration: InputDecoration(
5210
+ isDense: true,
5211
+ hintText: 'Search runs…',
5212
+ prefixIcon: const Icon(Icons.search, size: 18),
5213
+ suffixIcon: searchController.text.trim().isEmpty
5214
+ ? null
5215
+ : IconButton(
5216
+ icon: const Icon(Icons.close, size: 16),
5217
+ onPressed: searchController.clear,
5218
+ visualDensity: VisualDensity.compact,
5219
+ ),
5220
+ contentPadding: const EdgeInsets.symmetric(
5221
+ horizontal: 10,
5222
+ vertical: 8,
5223
+ ),
5224
+ ),
5225
+ ),
5226
+ const SizedBox(height: 8),
5227
+ Wrap(
5228
+ spacing: 6,
5229
+ runSpacing: 6,
5230
+ children: filters.map((f) {
5231
+ return ChoiceChip(
5232
+ label: Text(
5233
+ _titleCase(f),
5234
+ style: const TextStyle(fontSize: 11),
5235
+ ),
5236
+ selected: statusFilter == f,
5237
+ selectedColor: _accentMuted,
5238
+ backgroundColor: _bgSecondary,
5239
+ side: BorderSide(color: _border),
5240
+ onSelected: (_) => onStatusChanged(f),
5241
+ visualDensity: VisualDensity.compact,
5242
+ );
5243
+ }).toList(),
5244
+ ),
5245
+ const SizedBox(height: 10),
5246
+ if (filteredRuns.isEmpty)
5247
+ Padding(
5248
+ padding: const EdgeInsets.symmetric(vertical: 12),
5249
+ child: Text(
5250
+ 'No matching runs',
5251
+ style: TextStyle(color: _textSecondary, fontSize: 12),
5252
+ ),
5253
+ )
5254
+ else
5255
+ ...<Widget>[
5256
+ ListView.separated(
5257
+ shrinkWrap: true,
5258
+ physics: const NeverScrollableScrollPhysics(),
5259
+ itemCount: math.min(filteredRuns.length, 40),
5260
+ separatorBuilder: (_, __) => const SizedBox(height: 6),
5261
+ itemBuilder: (context, index) {
5262
+ final run = filteredRuns[index];
5263
+ final isSelected = run.id == selectedRunId;
5264
+ return _RunSelectorRow(
5265
+ run: run,
5266
+ selected: isSelected,
5267
+ onTap: () => onSelect(run.id),
5268
+ );
5269
+ },
5270
+ ),
5271
+ if (filteredRuns.length > 40)
5272
+ Padding(
5273
+ padding: const EdgeInsets.only(top: 8),
5274
+ child: Text(
5275
+ 'Showing 40 of ${filteredRuns.length} — use search to narrow results',
5276
+ style: TextStyle(color: _textSecondary, fontSize: 11),
5277
+ ),
5278
+ ),
5279
+ ],
5280
+ ],
5281
+ ),
5282
+ ),
5283
+ );
5284
+ }
5285
+ }
5286
+
5287
+ class _RunSelectorRow extends StatelessWidget {
5288
+ const _RunSelectorRow({
5289
+ required this.run,
5290
+ required this.selected,
5291
+ required this.onTap,
5292
+ });
5293
+
5294
+ final RunSummary run;
5295
+ final bool selected;
5296
+ final VoidCallback onTap;
5297
+
5298
+ @override
5299
+ Widget build(BuildContext context) {
5300
+ final color = run.statusColor;
5301
+ return InkWell(
5302
+ borderRadius: BorderRadius.circular(12),
5303
+ onTap: onTap,
5304
+ child: Container(
5305
+ padding: const EdgeInsets.all(10),
5306
+ decoration: BoxDecoration(
5307
+ color: selected ? _accentMuted : _bgSecondary,
5308
+ borderRadius: BorderRadius.circular(12),
5309
+ border: Border.all(color: selected ? _accent : _border),
5310
+ ),
5311
+ child: Row(
5312
+ crossAxisAlignment: CrossAxisAlignment.start,
5313
+ children: <Widget>[
5314
+ Container(
5315
+ width: 8,
5316
+ height: 8,
5317
+ margin: const EdgeInsets.only(top: 4),
5318
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
5319
+ ),
5320
+ const SizedBox(width: 8),
5321
+ Expanded(
5322
+ child: Column(
5323
+ crossAxisAlignment: CrossAxisAlignment.start,
5324
+ children: <Widget>[
5325
+ Text(
5326
+ run.title,
5327
+ maxLines: 2,
5328
+ overflow: TextOverflow.ellipsis,
5329
+ style: const TextStyle(
5330
+ fontWeight: FontWeight.w600,
5331
+ fontSize: 12,
5332
+ height: 1.3,
5333
+ ),
5334
+ ),
5335
+ const SizedBox(height: 3),
5336
+ Text(
5337
+ '${run.createdAtLabel} · ${run.durationLabel}',
5338
+ style: TextStyle(
5339
+ color: _textSecondary,
5340
+ fontSize: 11,
5341
+ ),
5342
+ ),
5343
+ ],
5344
+ ),
5345
+ ),
5346
+ ],
5347
+ ),
5348
+ ),
5349
+ );
5350
+ }
5351
+ }
5352
+
5353
+ // ── Node detail panel ─────────────────────────────────────────────────────────
5354
+
5355
+ class _RunNodeDetailPanel extends StatelessWidget {
5356
+ const _RunNodeDetailPanel({
5357
+ required this.run,
5358
+ required this.detail,
5359
+ required this.nodeId,
5360
+ required this.loading,
5361
+ required this.onDelete,
5362
+ required this.onCopyResponse,
5363
+ });
5364
+
5365
+ final RunSummary? run;
5366
+ final RunDetailSnapshot? detail;
5367
+ final String? nodeId;
5368
+ final bool loading;
5369
+ final Future<void> Function() onDelete;
5370
+ final Future<void> Function(String) onCopyResponse;
5371
+
5372
+ RunStepItem? get _selectedStep {
5373
+ if (nodeId == null || detail == null) return null;
5374
+ return detail!.steps.cast<RunStepItem?>().firstWhere(
5375
+ (s) => s?.id == nodeId,
5376
+ orElse: () => null,
5377
+ );
5378
+ }
5379
+
5380
+ @override
5381
+ Widget build(BuildContext context) {
5382
+ final r = run;
5383
+ final d = detail;
5384
+ final step = _selectedStep;
5385
+
5386
+ if (r == null) return const SizedBox.shrink();
5387
+
5388
+ return Column(
5389
+ crossAxisAlignment: CrossAxisAlignment.stretch,
5390
+ children: <Widget>[
5391
+ _RunHeroCard(run: r, onDelete: onDelete),
5392
+ const SizedBox(height: 12),
5393
+ if (step != null) ...<Widget>[
5394
+ _RunSelectedStepCard(step: step),
5395
+ const SizedBox(height: 12),
5396
+ ] else if (d != null) ...<Widget>[
5397
+ _RunResponseCard(
5398
+ response: d.response,
5399
+ onCopy: () => onCopyResponse(d.response),
5400
+ ),
5401
+ if (d.run.deliverableType.trim().isNotEmpty) ...<Widget>[
5402
+ const SizedBox(height: 12),
5403
+ _DeliverableSummaryCard(run: d.run),
5404
+ ],
5405
+ const SizedBox(height: 12),
5406
+ Wrap(
5407
+ spacing: 10,
5408
+ runSpacing: 10,
5409
+ children: <Widget>[
5410
+ _RunMetricCard(
5411
+ title: 'Steps',
5412
+ value: '${d.steps.length}',
5413
+ helper: 'Recorded',
5414
+ color: _info,
5415
+ ),
5416
+ _RunMetricCard(
5417
+ title: 'Done',
5418
+ value: '${d.completedTools}',
5419
+ helper: 'Successful',
5420
+ color: _success,
5421
+ ),
5422
+ _RunMetricCard(
5423
+ title: 'Failed',
5424
+ value: '${d.failedTools}',
5425
+ helper: 'Errors',
5426
+ color: _danger,
5427
+ ),
5428
+ ],
5429
+ ),
5430
+ ] else if (loading)
5431
+ const Card(
5432
+ child: Padding(
5433
+ padding: EdgeInsets.all(20),
5434
+ child: Center(child: CircularProgressIndicator()),
5435
+ ),
5436
+ ),
5437
+ ],
5438
+ );
5439
+ }
5440
+ }
5441
+
5442
+ class _RunSelectedStepCard extends StatelessWidget {
5443
+ const _RunSelectedStepCard({required this.step});
5444
+
5445
+ final RunStepItem step;
5446
+
5447
+ @override
5448
+ Widget build(BuildContext context) {
5449
+ final color = step.statusColor;
5450
+ return Card(
5451
+ child: Padding(
5452
+ padding: const EdgeInsets.all(16),
5453
+ child: Column(
5454
+ crossAxisAlignment: CrossAxisAlignment.start,
5455
+ children: <Widget>[
5456
+ Row(
5457
+ children: <Widget>[
5458
+ Container(
5459
+ padding: const EdgeInsets.all(8),
5460
+ decoration: BoxDecoration(
5461
+ color: color.withValues(alpha: 0.14),
5462
+ borderRadius: BorderRadius.circular(10),
5463
+ ),
5464
+ child: Icon(Icons.build_circle_outlined, size: 18, color: color),
5465
+ ),
5466
+ const SizedBox(width: 10),
5467
+ Expanded(
5468
+ child: Column(
5469
+ crossAxisAlignment: CrossAxisAlignment.start,
5470
+ children: <Widget>[
5471
+ Text(
5472
+ step.label,
5473
+ style: const TextStyle(
5474
+ fontWeight: FontWeight.w700,
5475
+ fontSize: 14,
5476
+ ),
5477
+ ),
5478
+ const SizedBox(height: 2),
5479
+ Text(
5480
+ step.typeLabel,
5481
+ style: TextStyle(
5482
+ color: _textSecondary,
5483
+ fontSize: 12,
5484
+ ),
5485
+ ),
5486
+ ],
5487
+ ),
5488
+ ),
5489
+ _StatusPill(label: step.statusLabel, color: color),
5490
+ ],
5491
+ ),
5492
+ const SizedBox(height: 10),
5493
+ Wrap(
5494
+ spacing: 8,
5495
+ runSpacing: 8,
5496
+ children: <Widget>[
5497
+ if (step.startedAt != null)
5498
+ _MetaPill(
5499
+ label: step.startedAtLabel!,
5500
+ icon: Icons.schedule_outlined,
5501
+ ),
5502
+ if (step.durationLabel != null)
5503
+ _MetaPill(
5504
+ label: step.durationLabel!,
5505
+ icon: Icons.timer_outlined,
5506
+ ),
5507
+ if (step.tokensUsed > 0)
5508
+ _MetaPill(
5509
+ label: '${_formatNumber(step.tokensUsed)} tokens',
5510
+ icon: Icons.toll_outlined,
5511
+ ),
5512
+ ],
5513
+ ),
5514
+ if (step.description.trim().isNotEmpty &&
5515
+ step.description.trim() != step.summary.trim()) ...<Widget>[
5516
+ const SizedBox(height: 10),
5517
+ _RunDetailBlock(label: 'Description', value: step.description),
5518
+ ],
5519
+ if (step.inputSummary.trim().isNotEmpty) ...<Widget>[
5520
+ const SizedBox(height: 8),
5521
+ _RunDetailBlock(label: 'Input', value: step.inputSummary),
5522
+ ],
5523
+ if (step.error.trim().isNotEmpty) ...<Widget>[
5524
+ const SizedBox(height: 8),
5525
+ _RunDetailBlock(label: 'Error', value: step.error, monospace: true),
5526
+ ] else if (step.result.trim().isNotEmpty) ...<Widget>[
5527
+ const SizedBox(height: 8),
5528
+ _RunDetailBlock(
5529
+ label: 'Result',
5530
+ value: _truncateRunText(step.result),
5531
+ monospace: true,
5532
+ ),
5533
+ ],
5534
+ ],
5535
+ ),
5536
+ ),
5537
+ );
5538
+ }
5539
+ }
5540
+
5541
+ // ──────────────────────────────────────────────────────────────────────────────
5542
+
4206
5543
  Future<void> openMessagingConfig(
4207
5544
  BuildContext context,
4208
5545
  NeoAgentController controller,