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.
- package/.env.example +19 -0
- package/README.md +20 -3
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +224 -0
- package/docs/configuration.md +22 -0
- package/docs/getting-started.md +10 -8
- package/docs/index.md +1 -0
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +4 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +316 -0
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1594 -224
- package/flutter_app/lib/main_controller.dart +263 -26
- package/flutter_app/lib/main_devices.dart +1 -1
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +12 -0
- package/flutter_app/lib/main_operations.dart +134 -9
- package/flutter_app/lib/main_settings.dart +148 -52
- package/flutter_app/lib/main_shared.dart +1 -0
- package/flutter_app/lib/src/backend_client.dart +86 -1
- package/landing/index.html +2 -2
- package/lib/manager.js +184 -66
- package/lib/schema_migrations.js +84 -0
- package/package.json +10 -4
- package/server/admin/access.js +12 -7
- package/server/admin/admin.css +78 -0
- package/server/admin/admin.js +511 -23
- package/server/admin/billing.js +415 -0
- package/server/admin/index.html +120 -13
- package/server/admin/users.js +15 -15
- package/server/db/database.js +13 -21
- package/server/db/sessions_db.js +8 -0
- package/server/http/middleware.js +4 -4
- package/server/http/routes.js +9 -0
- package/server/http/static.js +4 -2
- package/server/middleware/requireBilling.js +12 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +89347 -85449
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +515 -63
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +127 -0
- package/server/routes/billing_webhook.js +43 -0
- package/server/routes/memory.js +1 -4
- package/server/routes/settings.js +1 -2
- package/server/routes/skills.js +1 -1
- package/server/routes/triggers.js +2 -2
- package/server/services/account/erasure.js +263 -0
- package/server/services/account/sessions.js +1 -9
- package/server/services/ai/loop/agent_engine_core.js +42 -0
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/completion_judge.js +42 -0
- package/server/services/ai/loop/conversation_loop.js +248 -34
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +37 -44
- package/server/services/ai/messagingFallback.js +25 -1
- package/server/services/ai/models.js +18 -0
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/rate_limits.js +28 -4
- package/server/services/ai/systemPrompt.js +23 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/tools.js +231 -20
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/billing_email.js +106 -0
- package/server/services/billing/config.js +17 -0
- package/server/services/billing/plans.js +121 -0
- package/server/services/billing/stripe_client.js +21 -0
- package/server/services/billing/subscriptions.js +462 -0
- package/server/services/billing/trial_guard.js +111 -0
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/desktop/gateway.js +7 -4
- package/server/services/integrations/google/calendar.js +30 -2
- package/server/services/integrations/secrets.js +7 -4
- package/server/services/manager.js +11 -0
- package/server/services/messaging/automation.js +1 -1
- package/server/services/messaging/telnyx.js +12 -11
- package/server/services/messaging/whatsapp.js +1 -1
- package/server/services/recordings/manager.js +13 -7
- package/server/services/runtime/backends/local-vm.js +40 -22
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/tasks/runtime.js +103 -15
- package/server/services/tasks/schedule_utils.js +5 -5
- package/server/services/voice/runtimeManager.js +19 -10
- package/server/services/wearable/gateway.js +8 -5
- package/server/services/websocket.js +26 -8
- 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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
child:
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
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
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
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
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
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
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
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,
|