neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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 (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. 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;
@@ -345,6 +429,45 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
345
429
  if (!force && !_stickToBottom) return;
346
430
  final generation = ++_scrollGeneration;
347
431
 
432
+ if (_awaitingInitialScrollSettle) {
433
+ // Frame-by-frame stability detection: keep jumping and checking until
434
+ // maxScrollExtent is the same two frames in a row, then reveal.
435
+ // Falls back to revealing after 120 frames (~2 s) to avoid infinite wait.
436
+ double? prevExtent;
437
+ void settleInitial(int framesLeft) {
438
+ WidgetsBinding.instance.addPostFrameCallback((_) {
439
+ if (!mounted || generation != _scrollGeneration || !_scrollController.hasClients) {
440
+ return;
441
+ }
442
+ final pos = _scrollController.position;
443
+ double? currentExtent;
444
+ if (pos.hasContentDimensions) {
445
+ currentExtent = pos.maxScrollExtent;
446
+ _ignoreScrollUpdates = true;
447
+ _scrollController.jumpTo(currentExtent);
448
+ _ignoreScrollUpdates = false;
449
+ }
450
+ _stickToBottom = true;
451
+ final stable = currentExtent != null && currentExtent == prevExtent;
452
+ prevExtent = currentExtent;
453
+ if (stable || framesLeft <= 0) {
454
+ if (_awaitingInitialScrollSettle) {
455
+ setState(() {
456
+ _awaitingInitialScrollSettle = false;
457
+ _visibleMessageCountAtLastSettle =
458
+ widget.controller.visibleChatMessages.length;
459
+ });
460
+ }
461
+ return;
462
+ }
463
+ settleInitial(framesLeft - 1);
464
+ });
465
+ }
466
+ settleInitial(120);
467
+ return;
468
+ }
469
+
470
+ // Regular settle (content already visible): fixed passes with short delay.
348
471
  void settle(int remainingPasses) {
349
472
  WidgetsBinding.instance.addPostFrameCallback((_) {
350
473
  if (!mounted ||
@@ -384,6 +507,15 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
384
507
  final isInitialContent = _lastScrollContentSignature.isEmpty;
385
508
  final shouldFollow = _stickToBottom || isInitialContent;
386
509
  _lastScrollContentSignature = signature;
510
+
511
+ if (messages.isEmpty) {
512
+ // Agent switch resets the settle tracker so the next batch triggers hiding.
513
+ _visibleMessageCountAtLastSettle = 0;
514
+ } else if (_visibleMessageCountAtLastSettle == 0 && !_awaitingInitialScrollSettle) {
515
+ // Messages just went from 0 → N: hide until the scroll position settles.
516
+ _awaitingInitialScrollSettle = true;
517
+ }
518
+
387
519
  if (shouldFollow) {
388
520
  _scheduleScrollToBottom(force: isInitialContent);
389
521
  }
@@ -396,6 +528,18 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
396
528
  _maybeFollowChatContent(messages, controller);
397
529
 
398
530
  final threadChildren = <Widget>[
531
+ if (controller.isLoadingOlderChatHistory) ...<Widget>[
532
+ const Padding(
533
+ padding: EdgeInsets.only(bottom: 16),
534
+ child: Center(
535
+ child: SizedBox(
536
+ width: 20,
537
+ height: 20,
538
+ child: CircularProgressIndicator(strokeWidth: 2),
539
+ ),
540
+ ),
541
+ ),
542
+ ],
399
543
  if (controller.usageAndLimits case final usage?
400
544
  when usage.hasLimits) ...<Widget>[
401
545
  _RateLimitStatusCard(usage: usage),
@@ -471,26 +615,29 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
471
615
  Expanded(
472
616
  child: Stack(
473
617
  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,
618
+ Opacity(
619
+ opacity: _awaitingInitialScrollSettle ? 0.0 : 1.0,
620
+ child: SelectionArea(
621
+ child: ListView(
622
+ controller: _scrollController,
623
+ padding: EdgeInsets.fromLTRB(
624
+ sidePadding,
625
+ 30,
626
+ sidePadding,
627
+ 18,
628
+ ),
629
+ children: <Widget>[
630
+ Center(
631
+ child: ConstrainedBox(
632
+ constraints: const BoxConstraints(maxWidth: 860),
633
+ child: Column(
634
+ crossAxisAlignment: CrossAxisAlignment.stretch,
635
+ children: threadChildren,
636
+ ),
490
637
  ),
491
638
  ),
492
- ),
493
- ],
639
+ ],
640
+ ),
494
641
  ),
495
642
  ),
496
643
  if (!_stickToBottom)
@@ -518,6 +665,20 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
518
665
  constraints: const BoxConstraints(maxWidth: 860),
519
666
  child: Column(
520
667
  children: <Widget>[
668
+ if (_showConnectionBanner)
669
+ Padding(
670
+ padding: const EdgeInsets.only(bottom: 8),
671
+ child: _ConnectionReconnectingBanner(
672
+ hasNetwork: controller.hasNetworkConnection,
673
+ ),
674
+ ),
675
+ if (controller.pendingApproval != null)
676
+ Padding(
677
+ padding: const EdgeInsets.only(bottom: 8),
678
+ child: _PendingApprovalBanner(
679
+ approval: controller.pendingApproval!,
680
+ ),
681
+ ),
521
682
  if (_pendingSharedAttachments.isNotEmpty)
522
683
  Padding(
523
684
  padding: const EdgeInsets.only(bottom: 10),
@@ -1414,6 +1575,10 @@ class _MessagingPanelState extends State<MessagingPanel> {
1414
1575
  ),
1415
1576
  const SizedBox(height: 22),
1416
1577
  ],
1578
+ if (controller.ignoredChats.isNotEmpty) ...[
1579
+ const SizedBox(height: 18),
1580
+ _IgnoredChatsPanel(controller: controller),
1581
+ ],
1417
1582
  _MessagingActivityPanel(messages: controller.messagingMessages),
1418
1583
  ],
1419
1584
  );
@@ -1839,6 +2004,101 @@ class _MessagingGroupHeader extends StatelessWidget {
1839
2004
  }
1840
2005
  }
1841
2006
 
2007
+ class _IgnoredChatsPanel extends StatelessWidget {
2008
+ const _IgnoredChatsPanel({required this.controller});
2009
+
2010
+ final NeoAgentController controller;
2011
+
2012
+ @override
2013
+ Widget build(BuildContext context) {
2014
+ final ignored = controller.ignoredChats;
2015
+ return Container(
2016
+ padding: const EdgeInsets.all(18),
2017
+ decoration: BoxDecoration(
2018
+ color: _bgCard,
2019
+ borderRadius: BorderRadius.circular(8),
2020
+ border: Border.all(color: _borderLight),
2021
+ ),
2022
+ child: Column(
2023
+ crossAxisAlignment: CrossAxisAlignment.start,
2024
+ children: [
2025
+ Row(
2026
+ children: [
2027
+ Expanded(
2028
+ child: Column(
2029
+ crossAxisAlignment: CrossAxisAlignment.start,
2030
+ children: [
2031
+ Text(
2032
+ 'Ignored Channels',
2033
+ style: TextStyle(
2034
+ color: _textPrimary,
2035
+ fontSize: 18,
2036
+ fontWeight: FontWeight.w800,
2037
+ ),
2038
+ ),
2039
+ const SizedBox(height: 3),
2040
+ Text(
2041
+ 'These channels are permanently silenced. To receive messages from them, add them manually to the access policy for the relevant platform.',
2042
+ style: TextStyle(color: _textSecondary, fontSize: 13, height: 1.4),
2043
+ ),
2044
+ ],
2045
+ ),
2046
+ ),
2047
+ _StatusPill(label: '${ignored.length}', color: _textMuted),
2048
+ ],
2049
+ ),
2050
+ const SizedBox(height: 14),
2051
+ for (final key in ignored)
2052
+ Builder(builder: (context) {
2053
+ final sep = key.indexOf(':');
2054
+ final platform = sep > 0 ? key.substring(0, sep) : key;
2055
+ final chatId = sep > 0 ? key.substring(sep + 1) : '';
2056
+ return Container(
2057
+ margin: const EdgeInsets.only(bottom: 8),
2058
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2059
+ decoration: BoxDecoration(
2060
+ color: _bgSecondary,
2061
+ borderRadius: BorderRadius.circular(8),
2062
+ border: Border.all(color: _borderLight),
2063
+ ),
2064
+ child: Row(
2065
+ children: [
2066
+ Icon(Icons.block_rounded, size: 16, color: _textMuted),
2067
+ const SizedBox(width: 10),
2068
+ Expanded(
2069
+ child: Column(
2070
+ crossAxisAlignment: CrossAxisAlignment.start,
2071
+ children: [
2072
+ Text(
2073
+ platform.toUpperCase(),
2074
+ style: TextStyle(
2075
+ color: _textMuted,
2076
+ fontSize: 10,
2077
+ fontWeight: FontWeight.w700,
2078
+ letterSpacing: 0.6,
2079
+ ),
2080
+ ),
2081
+ Text(
2082
+ chatId.isNotEmpty ? chatId : platform,
2083
+ style: TextStyle(color: _textPrimary, fontSize: 13),
2084
+ ),
2085
+ ],
2086
+ ),
2087
+ ),
2088
+ TextButton(
2089
+ onPressed: () => controller.removeIgnoredChat(key),
2090
+ child: Text('Remove'),
2091
+ ),
2092
+ ],
2093
+ ),
2094
+ );
2095
+ }),
2096
+ ],
2097
+ ),
2098
+ );
2099
+ }
2100
+ }
2101
+
1842
2102
  class _MessagingActivityPanel extends StatelessWidget {
1843
2103
  const _MessagingActivityPanel({required this.messages});
1844
2104
 
@@ -1967,6 +2227,156 @@ class _MessagingActivityItem extends StatelessWidget {
1967
2227
  }
1968
2228
  }
1969
2229
 
2230
+ // ─── Connection reconnecting banner (#65) ─────────────────────────────────
2231
+
2232
+ class _ConnectionReconnectingBanner extends StatelessWidget {
2233
+ const _ConnectionReconnectingBanner({required this.hasNetwork});
2234
+
2235
+ final bool hasNetwork;
2236
+
2237
+ @override
2238
+ Widget build(BuildContext context) {
2239
+ final msg = hasNetwork ? 'Reconnecting to server…' : 'No network connection';
2240
+ return Container(
2241
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2242
+ decoration: BoxDecoration(
2243
+ color: _warning.withValues(alpha: 0.12),
2244
+ borderRadius: BorderRadius.circular(12),
2245
+ border: Border.all(color: _warning.withValues(alpha: 0.32)),
2246
+ ),
2247
+ child: Row(
2248
+ children: <Widget>[
2249
+ if (hasNetwork)
2250
+ SizedBox.square(
2251
+ dimension: 14,
2252
+ child: CircularProgressIndicator(strokeWidth: 2, color: _warning),
2253
+ )
2254
+ else
2255
+ Icon(Icons.wifi_off_outlined, size: 16, color: _warning),
2256
+ const SizedBox(width: 10),
2257
+ Expanded(
2258
+ child: Text(
2259
+ msg,
2260
+ style: TextStyle(
2261
+ color: _warning,
2262
+ fontSize: 13,
2263
+ fontWeight: FontWeight.w600,
2264
+ ),
2265
+ ),
2266
+ ),
2267
+ ],
2268
+ ),
2269
+ );
2270
+ }
2271
+ }
2272
+
2273
+ // ─── Pending approval banner (#62) ────────────────────────────────────────
2274
+
2275
+ class _PendingApprovalBanner extends StatefulWidget {
2276
+ const _PendingApprovalBanner({required this.approval});
2277
+
2278
+ final ToolApprovalRequest approval;
2279
+
2280
+ @override
2281
+ State<_PendingApprovalBanner> createState() => _PendingApprovalBannerState();
2282
+ }
2283
+
2284
+ class _PendingApprovalBannerState extends State<_PendingApprovalBanner> {
2285
+ late Timer _timer;
2286
+ int _secondsRemaining = 30;
2287
+
2288
+ @override
2289
+ void initState() {
2290
+ super.initState();
2291
+ _secondsRemaining = _remaining();
2292
+ _timer = Timer.periodic(const Duration(seconds: 1), (_) {
2293
+ if (mounted) setState(() => _secondsRemaining = _remaining());
2294
+ });
2295
+ }
2296
+
2297
+ @override
2298
+ void didUpdateWidget(covariant _PendingApprovalBanner oldWidget) {
2299
+ super.didUpdateWidget(oldWidget);
2300
+ if (oldWidget.approval.approvalId != widget.approval.approvalId) {
2301
+ _timer.cancel();
2302
+ _timer = Timer.periodic(const Duration(seconds: 1), (_) {
2303
+ if (mounted) setState(() => _secondsRemaining = _remaining());
2304
+ });
2305
+ setState(() => _secondsRemaining = _remaining());
2306
+ }
2307
+ }
2308
+
2309
+ int _remaining() {
2310
+ final expiry = widget.approval.expiresAt;
2311
+ if (expiry == null) return 30;
2312
+ return expiry.difference(DateTime.now()).inSeconds.clamp(0, 300);
2313
+ }
2314
+
2315
+ @override
2316
+ void dispose() {
2317
+ _timer.cancel();
2318
+ super.dispose();
2319
+ }
2320
+
2321
+ @override
2322
+ Widget build(BuildContext context) {
2323
+ final toolName = widget.approval.toolName.ifEmpty('tool');
2324
+ return Container(
2325
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2326
+ decoration: BoxDecoration(
2327
+ color: _accent.withValues(alpha: 0.10),
2328
+ borderRadius: BorderRadius.circular(12),
2329
+ border: Border.all(color: _accent.withValues(alpha: 0.32)),
2330
+ ),
2331
+ child: Row(
2332
+ children: <Widget>[
2333
+ _PulseHalo(
2334
+ color: _accent,
2335
+ animate: true,
2336
+ child: Container(
2337
+ width: 22,
2338
+ height: 22,
2339
+ decoration: BoxDecoration(
2340
+ color: _accent.withValues(alpha: 0.16),
2341
+ shape: BoxShape.circle,
2342
+ ),
2343
+ child: Icon(
2344
+ Icons.security_outlined,
2345
+ size: 13,
2346
+ color: _accent,
2347
+ ),
2348
+ ),
2349
+ ),
2350
+ const SizedBox(width: 10),
2351
+ Expanded(
2352
+ child: RichText(
2353
+ text: TextSpan(
2354
+ style: TextStyle(color: _textPrimary, fontSize: 13),
2355
+ children: <TextSpan>[
2356
+ const TextSpan(
2357
+ text: 'Waiting for approval: ',
2358
+ style: TextStyle(fontWeight: FontWeight.w600),
2359
+ ),
2360
+ TextSpan(
2361
+ text: toolName,
2362
+ style: TextStyle(color: _accent),
2363
+ ),
2364
+ TextSpan(
2365
+ text: ' ${_secondsRemaining}s',
2366
+ style: TextStyle(color: _textSecondary),
2367
+ ),
2368
+ ],
2369
+ ),
2370
+ ),
2371
+ ),
2372
+ ],
2373
+ ),
2374
+ );
2375
+ }
2376
+ }
2377
+
2378
+ // ─── Runs page ─────────────────────────────────────────────────────────────
2379
+
1970
2380
  class RunsPanel extends StatefulWidget {
1971
2381
  const RunsPanel({super.key, required this.controller, this.embedded = false});
1972
2382
 
@@ -1984,6 +2394,7 @@ class _RunsPanelState extends State<RunsPanel> {
1984
2394
  RunDetailSnapshot? _detail;
1985
2395
  bool _loadingDetail = false;
1986
2396
  String? _detailError;
2397
+ String? _selectedGraphNodeId;
1987
2398
 
1988
2399
  @override
1989
2400
  void initState() {
@@ -2160,119 +2571,147 @@ class _RunsPanelState extends State<RunsPanel> {
2160
2571
  orElse: () => null,
2161
2572
  );
2162
2573
  final detail = _detail?.run.id == selected?.id ? _detail : null;
2574
+ final padding = widget.embedded ? EdgeInsets.zero : _pagePadding(context);
2575
+
2576
+ Widget header = const SizedBox.shrink();
2577
+ if (!widget.embedded) {
2578
+ header = _PageTitle(
2579
+ title: 'Runs',
2580
+ subtitle: 'Explore run flows, tool steps, and responses.',
2581
+ trailing: OutlinedButton.icon(
2582
+ onPressed: _refreshRuns,
2583
+ icon: const Icon(Icons.refresh),
2584
+ label: const Text('Refresh'),
2585
+ ),
2586
+ );
2587
+ }
2163
2588
 
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)
2589
+ if (controller.recentRuns.isEmpty) {
2590
+ return ListView(
2591
+ padding: padding,
2592
+ children: <Widget>[
2593
+ header,
2203
2594
  const _EmptyCard(
2204
2595
  title: 'No runs yet',
2205
2596
  subtitle:
2206
2597
  '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
2598
  ),
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
2599
  ],
2263
- ],
2600
+ );
2601
+ }
2602
+
2603
+ final selectorRail = _RunSelectorRail(
2604
+ controller: controller,
2605
+ filteredRuns: filteredRuns,
2606
+ selectedRunId: _selectedRunId,
2607
+ searchController: _searchController,
2608
+ statusFilter: _statusFilter,
2609
+ onStatusChanged: _setStatusFilter,
2610
+ onSelect: (id) {
2611
+ _selectRun(id);
2612
+ setState(() => _selectedGraphNodeId = null);
2613
+ },
2614
+ onRefresh: _refreshRuns,
2264
2615
  );
2265
- }
2266
- }
2267
2616
 
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,
2617
+ final graphCanvas = _RunFlowGraphCanvas(
2618
+ run: selected,
2619
+ detail: detail,
2620
+ loading: _loadingDetail,
2621
+ errorMessage: _detailError,
2622
+ selectedNodeId: _selectedGraphNodeId,
2623
+ onNodeSelected: (id) => setState(() => _selectedGraphNodeId = id),
2624
+ );
2625
+
2626
+ final detailPanel = _RunNodeDetailPanel(
2627
+ run: selected,
2628
+ detail: detail,
2629
+ nodeId: _selectedGraphNodeId,
2630
+ loading: _loadingDetail,
2631
+ onDelete: _deleteSelectedRun,
2632
+ onCopyResponse: _copyResponse,
2633
+ );
2634
+
2635
+ return LayoutBuilder(
2636
+ builder: (context, constraints) {
2637
+ final wide = constraints.maxWidth >= 1100;
2638
+ final medium = constraints.maxWidth >= 760;
2639
+
2640
+ final body = wide
2641
+ ? Row(
2642
+ crossAxisAlignment: CrossAxisAlignment.start,
2643
+ children: <Widget>[
2644
+ SizedBox(width: 260, child: selectorRail),
2645
+ const SizedBox(width: 12),
2646
+ Expanded(child: graphCanvas),
2647
+ const SizedBox(width: 12),
2648
+ SizedBox(width: 310, child: detailPanel),
2649
+ ],
2650
+ )
2651
+ : medium
2652
+ ? Row(
2653
+ crossAxisAlignment: CrossAxisAlignment.start,
2654
+ children: <Widget>[
2655
+ SizedBox(width: 220, child: selectorRail),
2656
+ const SizedBox(width: 12),
2657
+ Expanded(
2658
+ child: Column(
2659
+ crossAxisAlignment: CrossAxisAlignment.stretch,
2660
+ children: <Widget>[
2661
+ graphCanvas,
2662
+ const SizedBox(height: 12),
2663
+ detailPanel,
2664
+ ],
2665
+ ),
2666
+ ),
2667
+ ],
2668
+ )
2669
+ : Column(
2670
+ crossAxisAlignment: CrossAxisAlignment.stretch,
2671
+ children: <Widget>[
2672
+ graphCanvas,
2673
+ const SizedBox(height: 12),
2674
+ selectorRail,
2675
+ const SizedBox(height: 12),
2676
+ detailPanel,
2677
+ ],
2678
+ );
2679
+
2680
+ return SingleChildScrollView(
2681
+ padding: padding,
2682
+ child: Column(
2683
+ crossAxisAlignment: CrossAxisAlignment.stretch,
2684
+ children: <Widget>[
2685
+ header,
2686
+ if (controller.errorMessage != null) ...<Widget>[
2687
+ _InlineError(message: controller.errorMessage!),
2688
+ const SizedBox(height: 12),
2689
+ ],
2690
+ if (controller.activeRun != null ||
2691
+ controller.toolEvents.isNotEmpty) ...<Widget>[
2692
+ _RunStatusPanel(
2693
+ run: controller.activeRun,
2694
+ tools: controller.toolEvents,
2695
+ ),
2696
+ const SizedBox(height: 12),
2697
+ ],
2698
+ body,
2699
+ ],
2700
+ ),
2701
+ );
2702
+ },
2703
+ );
2704
+ }
2705
+ }
2706
+
2707
+ class _MessagingCard extends StatelessWidget {
2708
+ const _MessagingCard({
2709
+ required this.platform,
2710
+ required this.status,
2711
+ required this.accessCatalog,
2712
+ required this.controller,
2713
+ required this.onConnect,
2714
+ required this.onDisconnect,
2276
2715
  required this.onLogout,
2277
2716
  });
2278
2717
 
@@ -3645,113 +4084,105 @@ class _RunHeroCard extends StatelessWidget {
3645
4084
 
3646
4085
  @override
3647
4086
  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
- ],
4087
+ final statusColor = run.statusColor;
4088
+ return Card(
4089
+ child: Padding(
4090
+ padding: const EdgeInsets.all(14),
4091
+ child: Column(
4092
+ crossAxisAlignment: CrossAxisAlignment.start,
4093
+ children: <Widget>[
4094
+ Row(
4095
+ crossAxisAlignment: CrossAxisAlignment.center,
4096
+ children: <Widget>[
4097
+ Container(
4098
+ width: 8,
4099
+ height: 8,
4100
+ decoration: BoxDecoration(
4101
+ color: statusColor,
4102
+ shape: BoxShape.circle,
4103
+ ),
4104
+ ),
4105
+ const SizedBox(width: 7),
4106
+ Text(
4107
+ run.statusLabel,
4108
+ style: TextStyle(
4109
+ color: statusColor,
4110
+ fontWeight: FontWeight.w600,
4111
+ fontSize: 12,
4112
+ ),
4113
+ ),
4114
+ const Spacer(),
4115
+ IconButton(
4116
+ tooltip: 'Delete run',
4117
+ icon: const Icon(Icons.delete_outline, size: 18),
4118
+ onPressed: onDelete,
4119
+ visualDensity: VisualDensity.compact,
4120
+ color: _textSecondary,
3731
4121
  ),
4122
+ ],
4123
+ ),
4124
+ const SizedBox(height: 8),
4125
+ Text(
4126
+ run.title,
4127
+ style: const TextStyle(
4128
+ fontSize: 15,
4129
+ fontWeight: FontWeight.w700,
4130
+ height: 1.3,
3732
4131
  ),
3733
- const SizedBox(width: 12),
3734
- OutlinedButton.icon(
3735
- onPressed: onDelete,
3736
- icon: Icon(Icons.delete_outline),
3737
- label: Text('Delete'),
4132
+ maxLines: 3,
4133
+ overflow: TextOverflow.ellipsis,
4134
+ ),
4135
+ const SizedBox(height: 10),
4136
+ Wrap(
4137
+ spacing: 6,
4138
+ runSpacing: 6,
4139
+ children: <Widget>[
4140
+ _MetaPill(
4141
+ label: run.triggerLabel,
4142
+ icon: Icons.bolt_outlined,
4143
+ ),
4144
+ _MetaPill(
4145
+ label: run.modelLabel,
4146
+ icon: Icons.memory_outlined,
4147
+ ),
4148
+ _MetaPill(
4149
+ label: run.createdAtLabel,
4150
+ icon: Icons.schedule_outlined,
4151
+ ),
4152
+ _MetaPill(
4153
+ label: run.durationLabel,
4154
+ icon: Icons.timer_outlined,
4155
+ ),
4156
+ if (run.totalTokensLabel.isNotEmpty)
4157
+ _MetaPill(
4158
+ label: '${run.totalTokensLabel} tok',
4159
+ icon: Icons.toll_outlined,
4160
+ ),
4161
+ if (run.deliverableType.trim().isNotEmpty)
4162
+ _MetaPill(
4163
+ label: run.deliverableType.replaceAll('_', ' '),
4164
+ icon: Icons.inventory_2_outlined,
4165
+ ),
4166
+ ],
4167
+ ),
4168
+ if (run.error.trim().isNotEmpty) ...<Widget>[
4169
+ const SizedBox(height: 10),
4170
+ Container(
4171
+ width: double.infinity,
4172
+ padding: const EdgeInsets.all(10),
4173
+ decoration: BoxDecoration(
4174
+ color: const Color(0x19EF4444),
4175
+ borderRadius: BorderRadius.circular(10),
4176
+ border: Border.all(color: const Color(0x4CEF4444)),
4177
+ ),
4178
+ child: Text(
4179
+ run.error,
4180
+ style: TextStyle(fontSize: 12, height: 1.45),
4181
+ ),
3738
4182
  ),
3739
4183
  ],
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
4184
  ],
3754
- ],
4185
+ ),
3755
4186
  ),
3756
4187
  );
3757
4188
  }
@@ -4203,6 +4634,945 @@ class _RunDetailBlock extends StatelessWidget {
4203
4634
  }
4204
4635
  }
4205
4636
 
4637
+ // ─── Run flow graph (#66) ─────────────────────────────────────────────────
4638
+
4639
+ // ── Data model ──────────────────────────────────────────────────────────────
4640
+
4641
+ class _FlowNode {
4642
+ _FlowNode({
4643
+ required this.id,
4644
+ required this.label,
4645
+ required this.nodeType,
4646
+ required this.status,
4647
+ this.step,
4648
+ });
4649
+
4650
+ final String id;
4651
+ final String label;
4652
+ final String nodeType;
4653
+ final String status;
4654
+ final RunStepItem? step;
4655
+ double x = 0;
4656
+ double y = 0;
4657
+ int lane = 0;
4658
+
4659
+ static const double w = 156.0;
4660
+ static const double h = 64.0;
4661
+ static const double hGap = 34.0;
4662
+ static const double vGap = 80.0;
4663
+
4664
+ Color get nodeColor {
4665
+ if (status == 'failed') return _danger;
4666
+ switch (nodeType) {
4667
+ case 'start':
4668
+ return _accent;
4669
+ case 'end':
4670
+ return _success;
4671
+ case 'fail':
4672
+ return _danger;
4673
+ case 'model':
4674
+ return _info;
4675
+ case 'plan':
4676
+ return _warning;
4677
+ case 'subagent':
4678
+ return _accentHover;
4679
+ case 'verify':
4680
+ return const Color(0xFF8B5CF6);
4681
+ default:
4682
+ return _success;
4683
+ }
4684
+ }
4685
+
4686
+ IconData get nodeIcon {
4687
+ switch (nodeType) {
4688
+ case 'start':
4689
+ return Icons.play_circle_outline;
4690
+ case 'end':
4691
+ return Icons.check_circle_outline;
4692
+ case 'fail':
4693
+ return Icons.error_outline;
4694
+ case 'model':
4695
+ return Icons.psychology_outlined;
4696
+ case 'plan':
4697
+ return Icons.list_alt_outlined;
4698
+ case 'subagent':
4699
+ return Icons.account_tree_outlined;
4700
+ case 'verify':
4701
+ return Icons.fact_check_outlined;
4702
+ case 'note':
4703
+ return Icons.notes_outlined;
4704
+ default:
4705
+ return Icons.build_circle_outlined;
4706
+ }
4707
+ }
4708
+ }
4709
+
4710
+ List<_FlowNode> _buildRunFlowNodes(RunDetailSnapshot detail) {
4711
+ final nodes = <_FlowNode>[];
4712
+
4713
+ nodes.add(
4714
+ _FlowNode(
4715
+ id: '__start__',
4716
+ label: 'Start',
4717
+ nodeType: 'start',
4718
+ status: 'completed',
4719
+ ),
4720
+ );
4721
+
4722
+ int subLaneCounter = 0;
4723
+ for (final step in detail.steps) {
4724
+ final lower = '${step.type} ${step.toolName}'.toLowerCase();
4725
+ String nodeType;
4726
+ int nodeLane = 0;
4727
+ if (lower.contains('model') ||
4728
+ lower.contains('think') ||
4729
+ lower.contains('llm')) {
4730
+ nodeType = 'model';
4731
+ } else if (lower.contains('plan')) {
4732
+ nodeType = 'plan';
4733
+ } else if (lower.contains('subagent') ||
4734
+ lower.contains('helper') ||
4735
+ lower.contains('delegat')) {
4736
+ nodeType = 'subagent';
4737
+ nodeLane = ++subLaneCounter;
4738
+ } else if (lower.contains('verif')) {
4739
+ nodeType = 'verify';
4740
+ } else if (lower.contains('note') || lower.contains('analysis')) {
4741
+ nodeType = 'note';
4742
+ } else {
4743
+ nodeType = 'tool';
4744
+ }
4745
+ nodes.add(
4746
+ _FlowNode(
4747
+ id: step.id,
4748
+ label: step.label,
4749
+ nodeType: nodeType,
4750
+ status: step.status,
4751
+ step: step,
4752
+ )..lane = nodeLane,
4753
+ );
4754
+ }
4755
+
4756
+ nodes.add(
4757
+ _FlowNode(
4758
+ id: '__end__',
4759
+ label: detail.run.isFailure ? 'Failed' : 'Done',
4760
+ nodeType: detail.run.isFailure ? 'fail' : 'end',
4761
+ status: detail.run.isFailure ? 'failed' : 'completed',
4762
+ ),
4763
+ );
4764
+
4765
+ // Layout positions
4766
+ final mainNodes = nodes.where((n) => n.lane == 0).toList();
4767
+ double cx = 0;
4768
+ for (final n in mainNodes) {
4769
+ n.x = cx;
4770
+ n.y = 0;
4771
+ cx += _FlowNode.w + _FlowNode.hGap;
4772
+ }
4773
+
4774
+ final subLanes = <int, List<_FlowNode>>{};
4775
+ for (final n in nodes.where((n) => n.lane > 0)) {
4776
+ subLanes.putIfAbsent(n.lane, () => []).add(n);
4777
+ }
4778
+ int laneRow = 1;
4779
+ for (final laneNodes in subLanes.values) {
4780
+ double subCx = _FlowNode.w + _FlowNode.hGap;
4781
+ for (final n in laneNodes) {
4782
+ n.x = subCx;
4783
+ n.y = laneRow * (_FlowNode.h + _FlowNode.vGap);
4784
+ subCx += _FlowNode.w + _FlowNode.hGap;
4785
+ }
4786
+ laneRow++;
4787
+ }
4788
+
4789
+ return nodes;
4790
+ }
4791
+
4792
+ List<(String, String, bool)> _buildRunFlowEdges(List<_FlowNode> nodes) {
4793
+ final edges = <(String, String, bool)>[];
4794
+ final mainLane = nodes.where((n) => n.lane == 0).toList();
4795
+ for (int i = 0; i + 1 < mainLane.length; i++) {
4796
+ edges.add((mainLane[i].id, mainLane[i + 1].id, false));
4797
+ }
4798
+ // Delegation nodes: connect from start to their first node
4799
+ for (final n in nodes.where((n) => n.lane > 0)) {
4800
+ edges.add(('__start__', n.id, true));
4801
+ }
4802
+ return edges;
4803
+ }
4804
+
4805
+ // ── Edge painter ──────────────────────────────────────────────────────────────
4806
+
4807
+ class _RunGraphEdgePainter extends CustomPainter {
4808
+ const _RunGraphEdgePainter({required this.nodeMap, required this.edges});
4809
+
4810
+ final Map<String, _FlowNode> nodeMap;
4811
+ final List<(String, String, bool)> edges;
4812
+
4813
+ @override
4814
+ void paint(Canvas canvas, Size size) {
4815
+ for (final (fromId, toId, isDelegation) in edges) {
4816
+ final from = nodeMap[fromId];
4817
+ final to = nodeMap[toId];
4818
+ if (from == null || to == null) continue;
4819
+
4820
+ final color = isDelegation
4821
+ ? _accentHover.withValues(alpha: 0.55)
4822
+ : _border.withValues(alpha: 0.8);
4823
+
4824
+ final paint = Paint()
4825
+ ..color = color
4826
+ ..style = PaintingStyle.stroke
4827
+ ..strokeWidth = 1.8
4828
+ ..strokeCap = StrokeCap.round;
4829
+
4830
+ final start = Offset(
4831
+ from.x + _FlowNode.w,
4832
+ from.y + _FlowNode.h / 2,
4833
+ );
4834
+ final end = Offset(to.x, to.y + _FlowNode.h / 2);
4835
+
4836
+ if (from.y != to.y) {
4837
+ final mid = (start.dx + end.dx) / 2;
4838
+ final path = Path()
4839
+ ..moveTo(start.dx, start.dy)
4840
+ ..cubicTo(mid, start.dy, mid, end.dy, end.dx, end.dy);
4841
+ canvas.drawPath(path, paint);
4842
+ } else {
4843
+ canvas.drawLine(start, end, paint);
4844
+ }
4845
+
4846
+ // Arrowhead
4847
+ final arrowPaint = Paint()
4848
+ ..color = color
4849
+ ..style = PaintingStyle.fill;
4850
+ const arrowSize = 6.0;
4851
+ final arrow = Path()
4852
+ ..moveTo(end.dx, end.dy)
4853
+ ..lineTo(end.dx - arrowSize, end.dy - arrowSize * 0.5)
4854
+ ..lineTo(end.dx - arrowSize, end.dy + arrowSize * 0.5)
4855
+ ..close();
4856
+ canvas.drawPath(arrow, arrowPaint);
4857
+ }
4858
+ }
4859
+
4860
+ @override
4861
+ bool shouldRepaint(_RunGraphEdgePainter old) =>
4862
+ old.nodeMap != nodeMap || old.edges != edges;
4863
+ }
4864
+
4865
+ // ── Individual node widget ────────────────────────────────────────────────────
4866
+
4867
+ class _FlowNodeWidget extends StatelessWidget {
4868
+ const _FlowNodeWidget({
4869
+ required this.node,
4870
+ required this.selected,
4871
+ required this.onTap,
4872
+ });
4873
+
4874
+ final _FlowNode node;
4875
+ final bool selected;
4876
+ final VoidCallback onTap;
4877
+
4878
+ @override
4879
+ Widget build(BuildContext context) {
4880
+ final color = node.nodeColor;
4881
+ final isSpecial =
4882
+ node.nodeType == 'start' ||
4883
+ node.nodeType == 'end' ||
4884
+ node.nodeType == 'fail';
4885
+ return GestureDetector(
4886
+ onTap: onTap,
4887
+ child: Container(
4888
+ width: _FlowNode.w,
4889
+ height: _FlowNode.h,
4890
+ decoration: BoxDecoration(
4891
+ color: selected
4892
+ ? color.withValues(alpha: 0.16)
4893
+ : isSpecial
4894
+ ? color.withValues(alpha: 0.10)
4895
+ : _bgCard,
4896
+ borderRadius: BorderRadius.circular(18),
4897
+ border: Border.all(
4898
+ color: selected ? color : color.withValues(alpha: 0.30),
4899
+ width: selected ? 2.0 : 1.2,
4900
+ ),
4901
+ boxShadow: <BoxShadow>[
4902
+ BoxShadow(
4903
+ color: color.withValues(alpha: selected ? 0.22 : 0.07),
4904
+ blurRadius: selected ? 14 : 6,
4905
+ offset: const Offset(0, 3),
4906
+ ),
4907
+ ],
4908
+ ),
4909
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
4910
+ child: Column(
4911
+ mainAxisAlignment: MainAxisAlignment.center,
4912
+ crossAxisAlignment: CrossAxisAlignment.start,
4913
+ children: <Widget>[
4914
+ Row(
4915
+ children: <Widget>[
4916
+ Icon(node.nodeIcon, size: 13, color: color),
4917
+ const SizedBox(width: 6),
4918
+ Expanded(
4919
+ child: Text(
4920
+ node.label,
4921
+ maxLines: 1,
4922
+ overflow: TextOverflow.ellipsis,
4923
+ style: TextStyle(
4924
+ fontSize: 12,
4925
+ fontWeight: FontWeight.w700,
4926
+ color: _textPrimary,
4927
+ ),
4928
+ ),
4929
+ ),
4930
+ ],
4931
+ ),
4932
+ const SizedBox(height: 4),
4933
+ Row(
4934
+ children: <Widget>[
4935
+ Container(
4936
+ width: 5,
4937
+ height: 5,
4938
+ decoration: BoxDecoration(
4939
+ shape: BoxShape.circle,
4940
+ color: color,
4941
+ ),
4942
+ ),
4943
+ const SizedBox(width: 5),
4944
+ Text(
4945
+ isSpecial ? node.nodeType : node.status,
4946
+ style: TextStyle(
4947
+ color: _textSecondary,
4948
+ fontSize: 11,
4949
+ ),
4950
+ ),
4951
+ ],
4952
+ ),
4953
+ ],
4954
+ ),
4955
+ ),
4956
+ );
4957
+ }
4958
+ }
4959
+
4960
+ // ── Flow graph canvas ────────────────────────────────────────────────────────
4961
+
4962
+ class _RunFlowGraphCanvas extends StatefulWidget {
4963
+ const _RunFlowGraphCanvas({
4964
+ required this.run,
4965
+ required this.detail,
4966
+ required this.loading,
4967
+ required this.errorMessage,
4968
+ required this.selectedNodeId,
4969
+ required this.onNodeSelected,
4970
+ });
4971
+
4972
+ final RunSummary? run;
4973
+ final RunDetailSnapshot? detail;
4974
+ final bool loading;
4975
+ final String? errorMessage;
4976
+ final String? selectedNodeId;
4977
+ final ValueChanged<String?> onNodeSelected;
4978
+
4979
+ @override
4980
+ State<_RunFlowGraphCanvas> createState() => _RunFlowGraphCanvasState();
4981
+ }
4982
+
4983
+ class _RunFlowGraphCanvasState extends State<_RunFlowGraphCanvas> {
4984
+ final TransformationController _transform = TransformationController();
4985
+ String? _lastRunId;
4986
+
4987
+ @override
4988
+ void didUpdateWidget(covariant _RunFlowGraphCanvas old) {
4989
+ super.didUpdateWidget(old);
4990
+ final currentId = widget.detail?.run.id ?? widget.run?.id;
4991
+ if (currentId != _lastRunId && currentId != null) {
4992
+ _lastRunId = currentId;
4993
+ _transform.value = Matrix4.identity();
4994
+ }
4995
+ }
4996
+
4997
+ @override
4998
+ void dispose() {
4999
+ _transform.dispose();
5000
+ super.dispose();
5001
+ }
5002
+
5003
+ @override
5004
+ Widget build(BuildContext context) {
5005
+ if (widget.run == null) {
5006
+ return const _EmptyCard(
5007
+ title: 'Select a run',
5008
+ subtitle: 'Pick a run from the list on the left to explore its flow.',
5009
+ );
5010
+ }
5011
+
5012
+ if (widget.loading && widget.detail == null) {
5013
+ return Card(
5014
+ child: Padding(
5015
+ padding: const EdgeInsets.all(40),
5016
+ child: Row(
5017
+ mainAxisAlignment: MainAxisAlignment.center,
5018
+ children: <Widget>[
5019
+ const SizedBox.square(
5020
+ dimension: 20,
5021
+ child: CircularProgressIndicator(strokeWidth: 2),
5022
+ ),
5023
+ const SizedBox(width: 14),
5024
+ Text(
5025
+ 'Loading run flow…',
5026
+ style: TextStyle(color: _textSecondary),
5027
+ ),
5028
+ ],
5029
+ ),
5030
+ ),
5031
+ );
5032
+ }
5033
+
5034
+ if (widget.errorMessage != null) {
5035
+ return _InlineError(message: widget.errorMessage!);
5036
+ }
5037
+
5038
+ final detail = widget.detail;
5039
+ if (detail == null) {
5040
+ return const _EmptyCard(
5041
+ title: 'No detail available',
5042
+ subtitle: 'This run has no recorded step data.',
5043
+ );
5044
+ }
5045
+
5046
+ final nodes = _buildRunFlowNodes(detail);
5047
+ final edges = _buildRunFlowEdges(nodes);
5048
+ final nodeMap = <String, _FlowNode>{for (final n in nodes) n.id: n};
5049
+
5050
+ final canvasW =
5051
+ nodes.fold(0.0, (m, n) => math.max(m, n.x + _FlowNode.w)) + 48;
5052
+ final canvasH =
5053
+ nodes.fold(0.0, (m, n) => math.max(m, n.y + _FlowNode.h)) + 48;
5054
+
5055
+ final stepCount = nodes.length - 2; // subtract start + end virtuals
5056
+
5057
+ return Card(
5058
+ child: Column(
5059
+ crossAxisAlignment: CrossAxisAlignment.start,
5060
+ children: <Widget>[
5061
+ Padding(
5062
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
5063
+ child: Row(
5064
+ children: <Widget>[
5065
+ Expanded(
5066
+ child: Text(
5067
+ '$stepCount step${stepCount == 1 ? '' : 's'} · tap a node to inspect',
5068
+ style: TextStyle(
5069
+ color: _textSecondary,
5070
+ fontSize: 12.5,
5071
+ ),
5072
+ ),
5073
+ ),
5074
+ if (widget.loading)
5075
+ const Padding(
5076
+ padding: EdgeInsets.only(right: 8),
5077
+ child: SizedBox.square(
5078
+ dimension: 14,
5079
+ child: CircularProgressIndicator(strokeWidth: 2),
5080
+ ),
5081
+ ),
5082
+ TextButton.icon(
5083
+ onPressed: () => _transform.value = Matrix4.identity(),
5084
+ style: TextButton.styleFrom(
5085
+ visualDensity: VisualDensity.compact,
5086
+ padding: const EdgeInsets.symmetric(
5087
+ horizontal: 10,
5088
+ vertical: 6,
5089
+ ),
5090
+ ),
5091
+ icon: const Icon(Icons.center_focus_strong_outlined, size: 15),
5092
+ label: const Text('Reset view', style: TextStyle(fontSize: 12)),
5093
+ ),
5094
+ ],
5095
+ ),
5096
+ ),
5097
+ const SizedBox(height: 8),
5098
+ SizedBox(
5099
+ height: math.max(220.0, math.min(canvasH + 24, 380.0)),
5100
+ child: ClipRect(
5101
+ child: InteractiveViewer(
5102
+ transformationController: _transform,
5103
+ constrained: false,
5104
+ minScale: 0.2,
5105
+ maxScale: 3.0,
5106
+ boundaryMargin: const EdgeInsets.all(64),
5107
+ child: Padding(
5108
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
5109
+ child: SizedBox(
5110
+ width: canvasW,
5111
+ height: canvasH,
5112
+ child: Stack(
5113
+ children: <Widget>[
5114
+ Positioned.fill(
5115
+ child: CustomPaint(
5116
+ painter: _RunGraphEdgePainter(
5117
+ nodeMap: nodeMap,
5118
+ edges: edges,
5119
+ ),
5120
+ ),
5121
+ ),
5122
+ ...nodes.map(
5123
+ (node) => Positioned(
5124
+ left: node.x,
5125
+ top: node.y,
5126
+ child: _FlowNodeWidget(
5127
+ node: node,
5128
+ selected: node.id == widget.selectedNodeId,
5129
+ onTap: () => widget.onNodeSelected(
5130
+ node.id == widget.selectedNodeId ? null : node.id,
5131
+ ),
5132
+ ),
5133
+ ),
5134
+ ),
5135
+ ],
5136
+ ),
5137
+ ),
5138
+ ),
5139
+ ),
5140
+ ),
5141
+ ),
5142
+ const SizedBox(height: 8),
5143
+ Padding(
5144
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
5145
+ child: Wrap(
5146
+ spacing: 12,
5147
+ runSpacing: 8,
5148
+ children: <Widget>[
5149
+ _GraphLegendChip(color: _info, label: 'Model turn'),
5150
+ _GraphLegendChip(color: _success, label: 'Tool'),
5151
+ _GraphLegendChip(color: _warning, label: 'Plan'),
5152
+ _GraphLegendChip(color: _accentHover, label: 'Sub-agent'),
5153
+ _GraphLegendChip(color: _danger, label: 'Failed'),
5154
+ ],
5155
+ ),
5156
+ ),
5157
+ ],
5158
+ ),
5159
+ );
5160
+ }
5161
+ }
5162
+
5163
+ class _GraphLegendChip extends StatelessWidget {
5164
+ const _GraphLegendChip({required this.color, required this.label});
5165
+
5166
+ final Color color;
5167
+ final String label;
5168
+
5169
+ @override
5170
+ Widget build(BuildContext context) {
5171
+ return Row(
5172
+ mainAxisSize: MainAxisSize.min,
5173
+ children: <Widget>[
5174
+ Container(
5175
+ width: 8,
5176
+ height: 8,
5177
+ decoration: BoxDecoration(shape: BoxShape.circle, color: color),
5178
+ ),
5179
+ const SizedBox(width: 5),
5180
+ Text(label, style: TextStyle(fontSize: 11, color: _textSecondary)),
5181
+ ],
5182
+ );
5183
+ }
5184
+ }
5185
+
5186
+ // ── Run selector rail ─────────────────────────────────────────────────────────
5187
+
5188
+ class _RunSelectorRail extends StatelessWidget {
5189
+ const _RunSelectorRail({
5190
+ required this.controller,
5191
+ required this.filteredRuns,
5192
+ required this.selectedRunId,
5193
+ required this.searchController,
5194
+ required this.statusFilter,
5195
+ required this.onStatusChanged,
5196
+ required this.onSelect,
5197
+ required this.onRefresh,
5198
+ });
5199
+
5200
+ final NeoAgentController controller;
5201
+ final List<RunSummary> filteredRuns;
5202
+ final String? selectedRunId;
5203
+ final TextEditingController searchController;
5204
+ final String statusFilter;
5205
+ final ValueChanged<String> onStatusChanged;
5206
+ final ValueChanged<String> onSelect;
5207
+ final VoidCallback onRefresh;
5208
+
5209
+ @override
5210
+ Widget build(BuildContext context) {
5211
+ const filters = <String>['all', 'running', 'completed', 'failed'];
5212
+ final totalLoaded = controller.recentRuns.length;
5213
+
5214
+ return Card(
5215
+ child: Padding(
5216
+ padding: const EdgeInsets.all(14),
5217
+ child: Column(
5218
+ crossAxisAlignment: CrossAxisAlignment.start,
5219
+ children: <Widget>[
5220
+ Row(
5221
+ children: <Widget>[
5222
+ Expanded(
5223
+ child: Text(
5224
+ '${filteredRuns.length}${filteredRuns.length != totalLoaded ? '/$totalLoaded' : ''} runs',
5225
+ style: const TextStyle(
5226
+ fontWeight: FontWeight.w700,
5227
+ fontSize: 13,
5228
+ ),
5229
+ ),
5230
+ ),
5231
+ IconButton(
5232
+ tooltip: 'Refresh',
5233
+ icon: const Icon(Icons.refresh, size: 18),
5234
+ onPressed: onRefresh,
5235
+ visualDensity: VisualDensity.compact,
5236
+ ),
5237
+ ],
5238
+ ),
5239
+ const SizedBox(height: 8),
5240
+ TextField(
5241
+ controller: searchController,
5242
+ decoration: InputDecoration(
5243
+ isDense: true,
5244
+ hintText: 'Search runs…',
5245
+ prefixIcon: const Icon(Icons.search, size: 18),
5246
+ suffixIcon: searchController.text.trim().isEmpty
5247
+ ? null
5248
+ : IconButton(
5249
+ icon: const Icon(Icons.close, size: 16),
5250
+ onPressed: searchController.clear,
5251
+ visualDensity: VisualDensity.compact,
5252
+ ),
5253
+ contentPadding: const EdgeInsets.symmetric(
5254
+ horizontal: 10,
5255
+ vertical: 8,
5256
+ ),
5257
+ ),
5258
+ ),
5259
+ const SizedBox(height: 8),
5260
+ Wrap(
5261
+ spacing: 6,
5262
+ runSpacing: 6,
5263
+ children: filters.map((f) {
5264
+ return ChoiceChip(
5265
+ label: Text(
5266
+ _titleCase(f),
5267
+ style: const TextStyle(fontSize: 11),
5268
+ ),
5269
+ selected: statusFilter == f,
5270
+ selectedColor: _accentMuted,
5271
+ backgroundColor: _bgSecondary,
5272
+ side: BorderSide(color: _border),
5273
+ onSelected: (_) => onStatusChanged(f),
5274
+ visualDensity: VisualDensity.compact,
5275
+ );
5276
+ }).toList(),
5277
+ ),
5278
+ const SizedBox(height: 10),
5279
+ if (filteredRuns.isEmpty)
5280
+ Padding(
5281
+ padding: const EdgeInsets.symmetric(vertical: 12),
5282
+ child: Text(
5283
+ 'No matching runs',
5284
+ style: TextStyle(color: _textSecondary, fontSize: 12),
5285
+ ),
5286
+ )
5287
+ else
5288
+ ...<Widget>[
5289
+ ListView.separated(
5290
+ shrinkWrap: true,
5291
+ physics: const NeverScrollableScrollPhysics(),
5292
+ itemCount: math.min(filteredRuns.length, 40),
5293
+ separatorBuilder: (_, __) => const SizedBox(height: 6),
5294
+ itemBuilder: (context, index) {
5295
+ final run = filteredRuns[index];
5296
+ final isSelected = run.id == selectedRunId;
5297
+ return _RunSelectorRow(
5298
+ run: run,
5299
+ selected: isSelected,
5300
+ onTap: () => onSelect(run.id),
5301
+ );
5302
+ },
5303
+ ),
5304
+ if (filteredRuns.length > 40)
5305
+ Padding(
5306
+ padding: const EdgeInsets.only(top: 8),
5307
+ child: Text(
5308
+ 'Showing 40 of ${filteredRuns.length} — use search to narrow results',
5309
+ style: TextStyle(color: _textSecondary, fontSize: 11),
5310
+ ),
5311
+ ),
5312
+ ],
5313
+ ],
5314
+ ),
5315
+ ),
5316
+ );
5317
+ }
5318
+ }
5319
+
5320
+ class _RunSelectorRow extends StatelessWidget {
5321
+ const _RunSelectorRow({
5322
+ required this.run,
5323
+ required this.selected,
5324
+ required this.onTap,
5325
+ });
5326
+
5327
+ final RunSummary run;
5328
+ final bool selected;
5329
+ final VoidCallback onTap;
5330
+
5331
+ @override
5332
+ Widget build(BuildContext context) {
5333
+ final color = run.statusColor;
5334
+ return InkWell(
5335
+ borderRadius: BorderRadius.circular(12),
5336
+ onTap: onTap,
5337
+ child: Container(
5338
+ padding: const EdgeInsets.all(10),
5339
+ decoration: BoxDecoration(
5340
+ color: selected ? _accentMuted : _bgSecondary,
5341
+ borderRadius: BorderRadius.circular(12),
5342
+ border: Border.all(color: selected ? _accent : _border),
5343
+ ),
5344
+ child: Row(
5345
+ crossAxisAlignment: CrossAxisAlignment.start,
5346
+ children: <Widget>[
5347
+ Container(
5348
+ width: 8,
5349
+ height: 8,
5350
+ margin: const EdgeInsets.only(top: 4),
5351
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
5352
+ ),
5353
+ const SizedBox(width: 8),
5354
+ Expanded(
5355
+ child: Column(
5356
+ crossAxisAlignment: CrossAxisAlignment.start,
5357
+ children: <Widget>[
5358
+ Text(
5359
+ run.title,
5360
+ maxLines: 2,
5361
+ overflow: TextOverflow.ellipsis,
5362
+ style: const TextStyle(
5363
+ fontWeight: FontWeight.w600,
5364
+ fontSize: 12,
5365
+ height: 1.3,
5366
+ ),
5367
+ ),
5368
+ const SizedBox(height: 3),
5369
+ Text(
5370
+ '${run.createdAtLabel} · ${run.durationLabel}',
5371
+ style: TextStyle(
5372
+ color: _textSecondary,
5373
+ fontSize: 11,
5374
+ ),
5375
+ ),
5376
+ ],
5377
+ ),
5378
+ ),
5379
+ ],
5380
+ ),
5381
+ ),
5382
+ );
5383
+ }
5384
+ }
5385
+
5386
+ // ── Node detail panel ─────────────────────────────────────────────────────────
5387
+
5388
+ class _RunNodeDetailPanel extends StatelessWidget {
5389
+ const _RunNodeDetailPanel({
5390
+ required this.run,
5391
+ required this.detail,
5392
+ required this.nodeId,
5393
+ required this.loading,
5394
+ required this.onDelete,
5395
+ required this.onCopyResponse,
5396
+ });
5397
+
5398
+ final RunSummary? run;
5399
+ final RunDetailSnapshot? detail;
5400
+ final String? nodeId;
5401
+ final bool loading;
5402
+ final Future<void> Function() onDelete;
5403
+ final Future<void> Function(String) onCopyResponse;
5404
+
5405
+ RunStepItem? get _selectedStep {
5406
+ if (nodeId == null || detail == null) return null;
5407
+ return detail!.steps.cast<RunStepItem?>().firstWhere(
5408
+ (s) => s?.id == nodeId,
5409
+ orElse: () => null,
5410
+ );
5411
+ }
5412
+
5413
+ @override
5414
+ Widget build(BuildContext context) {
5415
+ final r = run;
5416
+ final d = detail;
5417
+ final step = _selectedStep;
5418
+
5419
+ if (r == null) return const SizedBox.shrink();
5420
+
5421
+ return Column(
5422
+ crossAxisAlignment: CrossAxisAlignment.stretch,
5423
+ children: <Widget>[
5424
+ _RunHeroCard(run: r, onDelete: onDelete),
5425
+ const SizedBox(height: 12),
5426
+ if (step != null) ...<Widget>[
5427
+ _RunSelectedStepCard(step: step),
5428
+ const SizedBox(height: 12),
5429
+ ] else if (d != null) ...<Widget>[
5430
+ _RunResponseCard(
5431
+ response: d.response,
5432
+ onCopy: () => onCopyResponse(d.response),
5433
+ ),
5434
+ if (d.run.deliverableType.trim().isNotEmpty) ...<Widget>[
5435
+ const SizedBox(height: 12),
5436
+ _DeliverableSummaryCard(run: d.run),
5437
+ ],
5438
+ const SizedBox(height: 12),
5439
+ Wrap(
5440
+ spacing: 10,
5441
+ runSpacing: 10,
5442
+ children: <Widget>[
5443
+ _RunMetricCard(
5444
+ title: 'Steps',
5445
+ value: '${d.steps.length}',
5446
+ helper: 'Recorded',
5447
+ color: _info,
5448
+ ),
5449
+ _RunMetricCard(
5450
+ title: 'Done',
5451
+ value: '${d.completedTools}',
5452
+ helper: 'Successful',
5453
+ color: _success,
5454
+ ),
5455
+ _RunMetricCard(
5456
+ title: 'Failed',
5457
+ value: '${d.failedTools}',
5458
+ helper: 'Errors',
5459
+ color: _danger,
5460
+ ),
5461
+ ],
5462
+ ),
5463
+ ] else if (loading)
5464
+ const Card(
5465
+ child: Padding(
5466
+ padding: EdgeInsets.all(20),
5467
+ child: Center(child: CircularProgressIndicator()),
5468
+ ),
5469
+ ),
5470
+ ],
5471
+ );
5472
+ }
5473
+ }
5474
+
5475
+ class _RunSelectedStepCard extends StatelessWidget {
5476
+ const _RunSelectedStepCard({required this.step});
5477
+
5478
+ final RunStepItem step;
5479
+
5480
+ @override
5481
+ Widget build(BuildContext context) {
5482
+ final color = step.statusColor;
5483
+ return Card(
5484
+ child: Padding(
5485
+ padding: const EdgeInsets.all(16),
5486
+ child: Column(
5487
+ crossAxisAlignment: CrossAxisAlignment.start,
5488
+ children: <Widget>[
5489
+ Row(
5490
+ children: <Widget>[
5491
+ Container(
5492
+ padding: const EdgeInsets.all(8),
5493
+ decoration: BoxDecoration(
5494
+ color: color.withValues(alpha: 0.14),
5495
+ borderRadius: BorderRadius.circular(10),
5496
+ ),
5497
+ child: Icon(Icons.build_circle_outlined, size: 18, color: color),
5498
+ ),
5499
+ const SizedBox(width: 10),
5500
+ Expanded(
5501
+ child: Column(
5502
+ crossAxisAlignment: CrossAxisAlignment.start,
5503
+ children: <Widget>[
5504
+ Text(
5505
+ step.label,
5506
+ style: const TextStyle(
5507
+ fontWeight: FontWeight.w700,
5508
+ fontSize: 14,
5509
+ ),
5510
+ ),
5511
+ const SizedBox(height: 2),
5512
+ Text(
5513
+ step.typeLabel,
5514
+ style: TextStyle(
5515
+ color: _textSecondary,
5516
+ fontSize: 12,
5517
+ ),
5518
+ ),
5519
+ ],
5520
+ ),
5521
+ ),
5522
+ _StatusPill(label: step.statusLabel, color: color),
5523
+ ],
5524
+ ),
5525
+ const SizedBox(height: 10),
5526
+ Wrap(
5527
+ spacing: 8,
5528
+ runSpacing: 8,
5529
+ children: <Widget>[
5530
+ if (step.startedAt != null)
5531
+ _MetaPill(
5532
+ label: step.startedAtLabel!,
5533
+ icon: Icons.schedule_outlined,
5534
+ ),
5535
+ if (step.durationLabel != null)
5536
+ _MetaPill(
5537
+ label: step.durationLabel!,
5538
+ icon: Icons.timer_outlined,
5539
+ ),
5540
+ if (step.tokensUsed > 0)
5541
+ _MetaPill(
5542
+ label: '${_formatNumber(step.tokensUsed)} tokens',
5543
+ icon: Icons.toll_outlined,
5544
+ ),
5545
+ ],
5546
+ ),
5547
+ if (step.description.trim().isNotEmpty &&
5548
+ step.description.trim() != step.summary.trim()) ...<Widget>[
5549
+ const SizedBox(height: 10),
5550
+ _RunDetailBlock(label: 'Description', value: step.description),
5551
+ ],
5552
+ if (step.inputSummary.trim().isNotEmpty) ...<Widget>[
5553
+ const SizedBox(height: 8),
5554
+ _RunDetailBlock(label: 'Input', value: step.inputSummary),
5555
+ ],
5556
+ if (step.error.trim().isNotEmpty) ...<Widget>[
5557
+ const SizedBox(height: 8),
5558
+ _RunDetailBlock(label: 'Error', value: step.error, monospace: true),
5559
+ ] else if (step.result.trim().isNotEmpty) ...<Widget>[
5560
+ const SizedBox(height: 8),
5561
+ _RunDetailBlock(
5562
+ label: 'Result',
5563
+ value: _truncateRunText(step.result),
5564
+ monospace: true,
5565
+ ),
5566
+ ],
5567
+ ],
5568
+ ),
5569
+ ),
5570
+ );
5571
+ }
5572
+ }
5573
+
5574
+ // ──────────────────────────────────────────────────────────────────────────────
5575
+
4206
5576
  Future<void> openMessagingConfig(
4207
5577
  BuildContext context,
4208
5578
  NeoAgentController controller,