neoagent 2.4.1-beta.9 → 2.4.1

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 (234) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +36 -4
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +2 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +673 -871
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -9,23 +9,35 @@ class ChatPanel extends StatefulWidget {
9
9
  State<ChatPanel> createState() => _ChatPanelState();
10
10
  }
11
11
 
12
- class _ChatPanelState extends State<ChatPanel> {
12
+ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
13
+ static const double _autoScrollBottomThreshold = 120;
14
+ static const int _autoScrollSettlePasses = 4;
15
+
13
16
  late final TextEditingController _composerController;
14
17
  final ScrollController _scrollController = ScrollController();
15
18
  List<SharedChatAttachment> _pendingSharedAttachments =
16
19
  const <SharedChatAttachment>[];
17
20
  String? _appliedSharedPayloadSignature;
18
- int _lastMessageCount = 0;
19
- int _lastToolCount = 0;
20
- String _lastStream = '';
21
+ String _lastScrollContentSignature = '';
22
+ bool _stickToBottom = true;
23
+ bool _ignoreScrollUpdates = false;
24
+ int _scrollGeneration = 0;
21
25
  bool _isSendingChatMessage = false;
26
+ bool _isDictating = false;
27
+ bool _isTranscribing = false;
28
+ LiveVoiceCapture? _dictationCapture;
29
+ final List<Uint8List> _dictationChunks = [];
22
30
 
23
31
  @override
24
32
  void initState() {
25
33
  super.initState();
34
+ WidgetsBinding.instance.addObserver(this);
26
35
  _composerController = TextEditingController();
36
+ _composerController.addListener(_handleComposerLayoutChanged);
37
+ _scrollController.addListener(_handleScrollPositionChanged);
27
38
  widget.controller.addListener(_consumeQueuedDraft);
28
39
  _consumeQueuedDraft();
40
+ _scheduleScrollToBottom(force: true);
29
41
  }
30
42
 
31
43
  @override
@@ -35,18 +47,98 @@ class _ChatPanelState extends State<ChatPanel> {
35
47
  oldWidget.controller.removeListener(_consumeQueuedDraft);
36
48
  widget.controller.addListener(_consumeQueuedDraft);
37
49
  _appliedSharedPayloadSignature = null;
50
+ _lastScrollContentSignature = '';
51
+ _stickToBottom = true;
38
52
  _consumeQueuedDraft();
53
+ _scheduleScrollToBottom(force: true);
39
54
  }
40
55
  }
41
56
 
42
57
  @override
43
58
  void dispose() {
59
+ WidgetsBinding.instance.removeObserver(this);
44
60
  widget.controller.removeListener(_consumeQueuedDraft);
61
+ _composerController.removeListener(_handleComposerLayoutChanged);
62
+ _scrollController.removeListener(_handleScrollPositionChanged);
45
63
  _composerController.dispose();
46
64
  _scrollController.dispose();
65
+ _dictationCapture?.dispose();
47
66
  super.dispose();
48
67
  }
49
68
 
69
+ @override
70
+ void didChangeMetrics() {
71
+ super.didChangeMetrics();
72
+ if (_stickToBottom) {
73
+ _scheduleScrollToBottom();
74
+ }
75
+ }
76
+
77
+ Future<void> _startDictation() async {
78
+ if (_isDictating || _isTranscribing) return;
79
+ final capture = LiveVoiceCapture();
80
+ _dictationCapture = capture;
81
+ _dictationChunks.clear();
82
+ try {
83
+ await capture.start(
84
+ onChunk: (chunk) => _dictationChunks.add(chunk),
85
+ sampleRate: 16000,
86
+ channels: 1,
87
+ );
88
+ if (mounted) setState(() => _isDictating = true);
89
+ } catch (e) {
90
+ await capture.dispose();
91
+ _dictationCapture = null;
92
+ if (mounted) {
93
+ ScaffoldMessenger.of(
94
+ context,
95
+ ).showSnackBar(SnackBar(content: Text('Microphone error: $e')));
96
+ }
97
+ }
98
+ }
99
+
100
+ Future<void> _stopAndTranscribe() async {
101
+ if (!_isDictating) return;
102
+ final capture = _dictationCapture;
103
+ _dictationCapture = null;
104
+ setState(() {
105
+ _isDictating = false;
106
+ _isTranscribing = true;
107
+ });
108
+ try {
109
+ await capture?.stop();
110
+ if (_dictationChunks.isEmpty) return;
111
+ final allBytes = _dictationChunks.fold<List<int>>(
112
+ <int>[],
113
+ (acc, chunk) => acc..addAll(chunk),
114
+ );
115
+ final audioBase64 = base64Encode(Uint8List.fromList(allBytes));
116
+ final transcript = await widget.controller.transcribeDictationAudio(
117
+ audioBase64: audioBase64,
118
+ );
119
+ if (mounted && transcript.isNotEmpty) {
120
+ final current = _composerController.text;
121
+ final separator = current.isNotEmpty && !current.endsWith(' ')
122
+ ? ' '
123
+ : '';
124
+ _composerController.text = '$current$separator$transcript';
125
+ _composerController.selection = TextSelection.collapsed(
126
+ offset: _composerController.text.length,
127
+ );
128
+ }
129
+ } catch (e) {
130
+ if (mounted) {
131
+ ScaffoldMessenger.of(
132
+ context,
133
+ ).showSnackBar(SnackBar(content: Text('Transcription failed: $e')));
134
+ }
135
+ } finally {
136
+ await capture?.dispose();
137
+ _dictationChunks.clear();
138
+ if (mounted) setState(() => _isTranscribing = false);
139
+ }
140
+ }
141
+
50
142
  void _consumeQueuedDraft() {
51
143
  final draft = widget.controller.peekPendingChatDraft();
52
144
  final attachments = widget.controller.peekPendingSharedChatAttachments();
@@ -90,6 +182,9 @@ class _ChatPanelState extends State<ChatPanel> {
90
182
  setState(() {
91
183
  _pendingSharedAttachments = const <SharedChatAttachment>[];
92
184
  });
185
+ if (_stickToBottom) {
186
+ _scheduleScrollToBottom();
187
+ }
93
188
  }
94
189
 
95
190
  String _mimeTypeForFileName(String fileName) {
@@ -136,10 +231,9 @@ class _ChatPanelState extends State<ChatPanel> {
136
231
  }
137
232
  final attachments = result.files
138
233
  .where(
139
- (file) =>
140
- kIsWeb
141
- ? file.bytes != null
142
- : file.path?.trim().isNotEmpty == true,
234
+ (file) => kIsWeb
235
+ ? file.bytes != null
236
+ : file.path?.trim().isNotEmpty == true,
143
237
  )
144
238
  .map(
145
239
  (file) => SharedChatAttachment(
@@ -161,271 +255,621 @@ class _ChatPanelState extends State<ChatPanel> {
161
255
  ...attachments,
162
256
  ];
163
257
  });
258
+ if (_stickToBottom) {
259
+ _scheduleScrollToBottom();
260
+ }
164
261
  }
165
262
 
166
263
  bool get _isNearBottom {
167
264
  if (!_scrollController.hasClients) return true;
168
265
  final pos = _scrollController.position;
169
266
  if (!pos.hasContentDimensions) return true;
170
- return pos.pixels >= pos.maxScrollExtent - 80;
267
+ return pos.pixels >= pos.maxScrollExtent - _autoScrollBottomThreshold;
171
268
  }
172
269
 
173
- void _scrollToBottom() {
174
- WidgetsBinding.instance.addPostFrameCallback((_) {
175
- if (!mounted || !_scrollController.hasClients) return;
176
- final pos = _scrollController.position;
177
- if (pos.hasContentDimensions) {
178
- _scrollController.jumpTo(pos.maxScrollExtent);
179
- }
180
- });
270
+ void _handleScrollPositionChanged() {
271
+ if (_ignoreScrollUpdates || !_scrollController.hasClients) return;
272
+ final nearBottom = _isNearBottom;
273
+ if (_stickToBottom && !nearBottom) {
274
+ _scrollGeneration++;
275
+ }
276
+ if (_stickToBottom != nearBottom) {
277
+ setState(() => _stickToBottom = nearBottom);
278
+ }
279
+ }
280
+
281
+ void _openModelPicker() {
282
+ final controller = widget.controller;
283
+ if (controller.hasLiveRun) return;
284
+ final enabled = controller.enabledModelIds;
285
+ final models = controller.supportedModels
286
+ .where((m) => enabled.contains(m.id))
287
+ .toList();
288
+ final options = _modelPickerOptions(models, allowAuto: true);
289
+ showGeneralDialog<void>(
290
+ context: context,
291
+ barrierDismissible: true,
292
+ barrierLabel: 'Dismiss',
293
+ barrierColor: Colors.black.withValues(alpha: 0.55),
294
+ transitionDuration: const Duration(milliseconds: 230),
295
+ transitionBuilder: (ctx, animation, secondary, child) => FadeTransition(
296
+ opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
297
+ child: SlideTransition(
298
+ position: Tween<Offset>(
299
+ begin: const Offset(0, 0.04),
300
+ end: Offset.zero,
301
+ ).animate(
302
+ CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
303
+ ),
304
+ child: child,
305
+ ),
306
+ ),
307
+ pageBuilder: (dialogContext, _, __) => _ModelPickerDialog(
308
+ title: 'Chat Model',
309
+ options: options,
310
+ currentValue: controller.defaultChatModel,
311
+ onChanged: (v) {
312
+ Navigator.of(dialogContext).pop();
313
+ controller.saveSettingsPayload({'default_chat_model': v});
314
+ },
315
+ ),
316
+ );
317
+ }
318
+
319
+ void _handleComposerLayoutChanged() {
320
+ if (_stickToBottom) {
321
+ _scheduleScrollToBottom();
322
+ }
323
+ }
324
+
325
+ String _scrollContentSignature(
326
+ List<ChatEntry> messages,
327
+ NeoAgentController controller,
328
+ ) {
329
+ final last = messages.isEmpty ? null : messages.last;
330
+ final activeRun = controller.activeRun;
331
+ return <Object?>[
332
+ messages.length,
333
+ last?.id,
334
+ last?.role,
335
+ last?.content.length,
336
+ last?.typing,
337
+ controller.toolEvents.length,
338
+ activeRun?.runId,
339
+ activeRun?.phase,
340
+ activeRun?.iteration,
341
+ controller.streamingAssistant.length,
342
+ controller.isSendingMessage,
343
+ ].join('|');
344
+ }
345
+
346
+ void _scheduleScrollToBottom({bool force = false}) {
347
+ if (!force && !_stickToBottom) return;
348
+ final generation = ++_scrollGeneration;
349
+
350
+ void settle(int remainingPasses) {
351
+ WidgetsBinding.instance.addPostFrameCallback((_) {
352
+ if (!mounted ||
353
+ generation != _scrollGeneration ||
354
+ !_scrollController.hasClients) {
355
+ return;
356
+ }
357
+ final pos = _scrollController.position;
358
+ if (pos.hasContentDimensions) {
359
+ final target = pos.maxScrollExtent;
360
+ if ((pos.pixels - target).abs() > 0.5) {
361
+ _ignoreScrollUpdates = true;
362
+ _scrollController.jumpTo(target);
363
+ _ignoreScrollUpdates = false;
364
+ }
365
+ }
366
+ _stickToBottom = true;
367
+ if (remainingPasses > 0) {
368
+ Timer(const Duration(milliseconds: 24), () {
369
+ if (mounted && generation == _scrollGeneration) {
370
+ settle(remainingPasses - 1);
371
+ }
372
+ });
373
+ }
374
+ });
375
+ }
376
+
377
+ settle(_autoScrollSettlePasses);
378
+ }
379
+
380
+ void _maybeFollowChatContent(
381
+ List<ChatEntry> messages,
382
+ NeoAgentController controller,
383
+ ) {
384
+ final signature = _scrollContentSignature(messages, controller);
385
+ if (_lastScrollContentSignature == signature) return;
386
+ final isInitialContent = _lastScrollContentSignature.isEmpty;
387
+ final shouldFollow = _stickToBottom || isInitialContent;
388
+ _lastScrollContentSignature = signature;
389
+ if (shouldFollow) {
390
+ _scheduleScrollToBottom(force: isInitialContent);
391
+ }
181
392
  }
182
393
 
183
394
  @override
184
395
  Widget build(BuildContext context) {
185
396
  final controller = widget.controller;
186
397
  final messages = controller.visibleChatMessages;
187
- if (_lastMessageCount != messages.length ||
188
- _lastToolCount != controller.toolEvents.length ||
189
- _lastStream != controller.streamingAssistant) {
190
- _lastMessageCount = messages.length;
191
- _lastToolCount = controller.toolEvents.length;
192
- _lastStream = controller.streamingAssistant;
193
- if (_isNearBottom) _scrollToBottom();
398
+ _maybeFollowChatContent(messages, controller);
399
+
400
+ final threadChildren = <Widget>[
401
+ if (controller.errorMessage != null) ...<Widget>[
402
+ _InlineError(message: controller.errorMessage!),
403
+ const SizedBox(height: 16),
404
+ ],
405
+ if (controller.activeRun != null || controller.toolEvents.isNotEmpty)
406
+ Padding(
407
+ padding: const EdgeInsets.only(bottom: 16),
408
+ child: _RunStatusPanel(
409
+ run: controller.activeRun,
410
+ tools: controller.toolEvents,
411
+ ),
412
+ ),
413
+ if (messages.isEmpty)
414
+ Padding(
415
+ padding: const EdgeInsets.only(top: 64),
416
+ child: Center(
417
+ child: _EmptyState(
418
+ title: 'How can I help?',
419
+ subtitle:
420
+ 'Runs, tools, memory, scheduling, skills, and MCP are all available here.',
421
+ ),
422
+ ),
423
+ )
424
+ else
425
+ ...messages.map(
426
+ (entry) => Padding(
427
+ padding: const EdgeInsets.only(bottom: 18),
428
+ child: _ChatBubble(
429
+ entry: entry,
430
+ onLoadRunDetail: controller.fetchRunDetail,
431
+ onSendMessage: controller.sendMessage,
432
+ ),
433
+ ),
434
+ ),
435
+ ];
436
+
437
+ Future<void> sendComposerMessage() async {
438
+ final task = _composerController.text;
439
+ if ((task.trim().isEmpty && _pendingSharedAttachments.isEmpty) ||
440
+ _isSendingChatMessage) {
441
+ return;
442
+ }
443
+ setState(() {
444
+ _isSendingChatMessage = true;
445
+ });
446
+ _composerController.clear();
447
+ final outgoingAttachments = _pendingSharedAttachments;
448
+ _clearSharedPayload();
449
+ try {
450
+ await controller.sendMessage(
451
+ task,
452
+ sharedAttachments: outgoingAttachments,
453
+ );
454
+ } finally {
455
+ if (mounted) {
456
+ setState(() {
457
+ _isSendingChatMessage = false;
458
+ });
459
+ }
460
+ }
194
461
  }
195
462
 
463
+ final sidePadding = _chatSidePadding(context);
464
+
196
465
  return Column(
197
466
  children: <Widget>[
467
+ _ChatTopBar(controller: controller),
198
468
  Expanded(
199
- child: SelectionArea(
200
- child: ListView(
201
- controller: _scrollController,
202
- padding: _pagePadding(context),
469
+ child: Stack(
203
470
  children: <Widget>[
204
- _PageTitle(
205
- title: 'Chat',
206
- subtitle: 'Live agent chat with tool and stream status.',
207
- trailing: Wrap(
208
- spacing: 10,
209
- runSpacing: 10,
210
- crossAxisAlignment: WrapCrossAlignment.center,
471
+ SelectionArea(
472
+ child: ListView(
473
+ controller: _scrollController,
474
+ padding:
475
+ EdgeInsets.fromLTRB(sidePadding, 30, sidePadding, 18),
211
476
  children: <Widget>[
212
- FilledButton.icon(
213
- onPressed: () => controller.setSelectedSection(
214
- AppSection.voiceAssistant,
477
+ Center(
478
+ child: ConstrainedBox(
479
+ constraints: const BoxConstraints(maxWidth: 860),
480
+ child: Column(
481
+ crossAxisAlignment: CrossAxisAlignment.stretch,
482
+ children: threadChildren,
483
+ ),
215
484
  ),
216
- icon: Icon(Icons.call),
217
- label: Text('Call'),
218
- ),
219
- _MetaPill(
220
- label: controller.modelIndicator,
221
- icon: Icons.memory_outlined,
222
- ),
223
- _MetaPill(
224
- label: 'Agent: ${controller.activeAgentLabel}',
225
- icon: Icons.smart_toy_outlined,
226
485
  ),
227
486
  ],
228
487
  ),
229
488
  ),
230
- if (controller.errorMessage != null) ...<Widget>[
231
- _InlineError(message: controller.errorMessage!),
232
- const SizedBox(height: 16),
233
- ],
234
- if (controller.activeRun != null ||
235
- controller.toolEvents.isNotEmpty)
236
- Padding(
237
- padding: const EdgeInsets.only(bottom: 16),
238
- child: _RunStatusPanel(
239
- run: controller.activeRun,
240
- tools: controller.toolEvents,
241
- ),
242
- ),
243
- if (messages.isEmpty)
244
- Padding(
245
- padding: EdgeInsets.only(top: 64),
246
- child: Center(
247
- child: _EmptyState(
248
- title: 'How can I help?',
249
- subtitle:
250
- 'Runs, tools, memory, scheduling, skills, and MCP are all available here.',
251
- ),
252
- ),
253
- )
254
- else
255
- ...messages.map(
256
- (entry) => Padding(
257
- padding: const EdgeInsets.only(bottom: 18),
258
- child: _ChatBubble(
259
- entry: entry,
260
- onLoadRunDetail: controller.fetchRunDetail,
261
- ),
489
+ if (!_stickToBottom)
490
+ Positioned(
491
+ bottom: 14,
492
+ right: 16,
493
+ child: _ScrollToBottomButton(
494
+ onTap: () {
495
+ setState(() => _stickToBottom = true);
496
+ _scheduleScrollToBottom(force: true);
497
+ },
262
498
  ),
263
499
  ),
264
500
  ],
265
501
  ),
266
- ),
267
502
  ),
268
503
  Container(
269
- padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
504
+ padding: EdgeInsets.fromLTRB(sidePadding, 12, sidePadding, 16),
270
505
  decoration: BoxDecoration(
271
- color: _bgPrimary,
506
+ color: _bgPrimary.withValues(alpha: 0.88),
272
507
  border: Border(top: BorderSide(color: _border)),
273
508
  ),
274
- child: Column(
275
- children: <Widget>[
276
- if (_pendingSharedAttachments.isNotEmpty)
277
- Padding(
278
- padding: const EdgeInsets.only(bottom: 10),
279
- child: _SharedAttachmentTray(
280
- attachments: _pendingSharedAttachments,
281
- onRemoveAt: (index) {
282
- setState(() {
283
- _pendingSharedAttachments = _pendingSharedAttachments
284
- .asMap()
285
- .entries
286
- .where((entry) => entry.key != index)
287
- .map((entry) => entry.value)
288
- .toList(growable: false);
289
- });
290
- if (_pendingSharedAttachments.isEmpty) {
291
- _clearSharedPayload();
292
- }
293
- },
294
- onClear: () {
295
- _clearSharedPayload();
296
- },
297
- ),
298
- ),
299
- Container(
300
- padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
301
- decoration: BoxDecoration(
302
- color: _bgTertiary,
303
- borderRadius: BorderRadius.circular(14),
304
- border: Border.all(color: _border),
305
- ),
306
- child: Row(
307
- crossAxisAlignment: CrossAxisAlignment.end,
308
- children: <Widget>[
309
- Expanded(
310
- child: TextField(
311
- controller: _composerController,
312
- minLines: 1,
313
- maxLines: 6,
314
- keyboardType: TextInputType.multiline,
315
- textInputAction: TextInputAction.newline,
316
- decoration: InputDecoration(
317
- hintText: controller.chatComposerHint,
318
- isDense: true,
319
- filled: false,
320
- border: InputBorder.none,
321
- enabledBorder: InputBorder.none,
322
- focusedBorder: InputBorder.none,
323
- ),
509
+ child: Center(
510
+ child: ConstrainedBox(
511
+ constraints: const BoxConstraints(maxWidth: 860),
512
+ child: Column(
513
+ children: <Widget>[
514
+ if (_pendingSharedAttachments.isNotEmpty)
515
+ Padding(
516
+ padding: const EdgeInsets.only(bottom: 10),
517
+ child: _SharedAttachmentTray(
518
+ attachments: _pendingSharedAttachments,
519
+ onRemoveAt: (index) {
520
+ setState(() {
521
+ _pendingSharedAttachments =
522
+ _pendingSharedAttachments
523
+ .asMap()
524
+ .entries
525
+ .where((entry) => entry.key != index)
526
+ .map((entry) => entry.value)
527
+ .toList(growable: false);
528
+ });
529
+ if (_stickToBottom) {
530
+ _scheduleScrollToBottom();
531
+ }
532
+ if (_pendingSharedAttachments.isEmpty) {
533
+ _clearSharedPayload();
534
+ }
535
+ },
536
+ onClear: _clearSharedPayload,
324
537
  ),
325
538
  ),
326
- const SizedBox(width: 8),
327
- IconButton(
328
- tooltip: 'Attach files',
329
- onPressed: _attachFiles,
330
- icon: const Icon(Icons.attach_file_rounded),
331
- color: _textSecondary,
332
- ),
333
- const SizedBox(width: 2),
334
- FilledButton(
335
- onPressed: () => controller.setSelectedSection(
336
- AppSection.voiceAssistant,
337
- ),
338
- style: FilledButton.styleFrom(
339
- minimumSize: const Size(46, 42),
340
- padding: const EdgeInsets.symmetric(horizontal: 12),
341
- backgroundColor: _success,
342
- shape: RoundedRectangleBorder(
343
- borderRadius: BorderRadius.circular(10),
539
+ Container(
540
+ padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
541
+ decoration: BoxDecoration(
542
+ color: _bgCard,
543
+ borderRadius: BorderRadius.circular(21),
544
+ border: Border.all(color: _borderLight),
545
+ boxShadow: <BoxShadow>[
546
+ BoxShadow(
547
+ color: Colors.black.withValues(alpha: 0.08),
548
+ blurRadius: 18,
549
+ offset: const Offset(0, 6),
344
550
  ),
345
- ),
346
- child: Icon(Icons.call_rounded, color: Colors.white),
551
+ ],
347
552
  ),
348
- const SizedBox(width: 8),
349
- Tooltip(
350
- message: 'Send (⌘↩)',
351
- child: FilledButton(
352
- onPressed: _isSendingChatMessage
353
- ? null
354
- : () async {
355
- final task = _composerController.text;
356
- if ((task.trim().isEmpty &&
357
- _pendingSharedAttachments.isEmpty) ||
358
- _isSendingChatMessage) {
359
- return;
360
- }
361
- setState(() {
362
- _isSendingChatMessage = true;
363
- });
364
- _composerController.clear();
365
- final outgoingAttachments =
366
- _pendingSharedAttachments;
367
- _clearSharedPayload();
368
- try {
369
- await controller.sendMessage(
370
- task,
371
- sharedAttachments: outgoingAttachments,
372
- );
373
- } finally {
374
- if (mounted) {
375
- setState(() {
376
- _isSendingChatMessage = false;
377
- });
378
- }
379
- }
380
- },
381
- style: FilledButton.styleFrom(
382
- minimumSize: const Size(46, 42),
383
- padding: const EdgeInsets.symmetric(horizontal: 12),
384
- backgroundColor: _accent,
385
- shape: RoundedRectangleBorder(
386
- borderRadius: BorderRadius.circular(10),
553
+ child: Row(
554
+ crossAxisAlignment: CrossAxisAlignment.end,
555
+ children: <Widget>[
556
+ _ChatComposerIconButton(
557
+ tooltip: 'Attach files',
558
+ icon: Icons.attach_file_rounded,
559
+ onPressed: _attachFiles,
560
+ ),
561
+ const SizedBox(width: 8),
562
+ Expanded(
563
+ child: TextField(
564
+ controller: _composerController,
565
+ minLines: 1,
566
+ maxLines: 6,
567
+ keyboardType: TextInputType.multiline,
568
+ textInputAction: TextInputAction.newline,
569
+ decoration: InputDecoration(
570
+ hintText: controller.chatComposerHint,
571
+ isDense: true,
572
+ filled: false,
573
+ border: InputBorder.none,
574
+ enabledBorder: InputBorder.none,
575
+ focusedBorder: InputBorder.none,
576
+ contentPadding: const EdgeInsets.symmetric(
577
+ vertical: 10,
578
+ ),
579
+ ),
387
580
  ),
388
581
  ),
389
- child: Icon(
390
- controller.hasLiveRun
582
+ const SizedBox(width: 8),
583
+ _isTranscribing
584
+ ? const SizedBox(
585
+ width: 40,
586
+ height: 40,
587
+ child: Padding(
588
+ padding: EdgeInsets.all(10),
589
+ child: CircularProgressIndicator(
590
+ strokeWidth: 2,
591
+ ),
592
+ ),
593
+ )
594
+ : _ChatComposerIconButton(
595
+ tooltip: _isDictating
596
+ ? 'Stop & transcribe'
597
+ : 'Dictate',
598
+ icon: _isDictating
599
+ ? Icons.stop_circle_outlined
600
+ : Icons.mic_none_rounded,
601
+ color: _isDictating
602
+ ? Theme.of(context).colorScheme.error
603
+ : null,
604
+ onPressed: _isDictating
605
+ ? _stopAndTranscribe
606
+ : _startDictation,
607
+ ),
608
+ const SizedBox(width: 6),
609
+ _ChatComposerIconButton(
610
+ tooltip: 'Call agent',
611
+ icon: Icons.call_rounded,
612
+ color: Colors.white,
613
+ backgroundColor: _success,
614
+ onPressed: () => controller.setSelectedSection(
615
+ AppSection.voiceAssistant,
616
+ ),
617
+ ),
618
+ const SizedBox(width: 6),
619
+ _ChatComposerIconButton(
620
+ tooltip: 'Send',
621
+ icon: controller.hasLiveRun
391
622
  ? Icons.alt_route_rounded
392
623
  : Icons.north_east_rounded,
393
624
  color: Colors.white,
625
+ backgroundColor: _accent,
626
+ onPressed: _isSendingChatMessage
627
+ ? null
628
+ : sendComposerMessage,
629
+ ),
630
+ ],
631
+ ),
632
+ ),
633
+ const SizedBox(height: 10),
634
+ Row(
635
+ children: <Widget>[
636
+ Expanded(
637
+ child: Row(
638
+ children: <Widget>[
639
+ Container(
640
+ width: 6,
641
+ height: 6,
642
+ decoration: BoxDecoration(
643
+ shape: BoxShape.circle,
644
+ color: controller.hasLiveRun
645
+ ? _success
646
+ : _textMuted,
647
+ ),
648
+ ),
649
+ const SizedBox(width: 7),
650
+ Expanded(
651
+ child: Text(
652
+ controller.chatStatusLabel,
653
+ maxLines: 1,
654
+ overflow: TextOverflow.ellipsis,
655
+ style: GoogleFonts.geistMono(
656
+ fontSize: 11.5,
657
+ color: _textMuted,
658
+ ),
659
+ ),
660
+ ),
661
+ ],
394
662
  ),
395
663
  ),
664
+ const SizedBox(width: 12),
665
+ GestureDetector(
666
+ onTap: controller.hasLiveRun ? null : _openModelPicker,
667
+ child: Container(
668
+ padding: const EdgeInsets.symmetric(
669
+ horizontal: 9,
670
+ vertical: 4,
671
+ ),
672
+ constraints: const BoxConstraints(maxWidth: 200),
673
+ decoration: BoxDecoration(
674
+ color: _bgCard,
675
+ borderRadius: BorderRadius.circular(8),
676
+ border: Border.all(color: _border),
677
+ ),
678
+ child: Row(
679
+ mainAxisSize: MainAxisSize.min,
680
+ children: <Widget>[
681
+ Flexible(
682
+ child: Text(
683
+ controller.hasLiveRun
684
+ ? 'Steering mode'
685
+ : controller.modelIndicator,
686
+ maxLines: 1,
687
+ overflow: TextOverflow.ellipsis,
688
+ style: GoogleFonts.geistMono(
689
+ fontSize: 11.5,
690
+ color: _textSecondary,
691
+ ),
692
+ ),
693
+ ),
694
+ const SizedBox(width: 5),
695
+ Icon(
696
+ Icons.keyboard_arrow_down_rounded,
697
+ size: 13,
698
+ color: controller.hasLiveRun
699
+ ? _textMuted
700
+ : _textSecondary,
701
+ ),
702
+ ],
703
+ ),
704
+ ),
705
+ ),
706
+ ],
707
+ ),
708
+ ],
709
+ ),
710
+ ),
711
+ ),
712
+ ),
713
+ ],
714
+ );
715
+ }
716
+ }
717
+
718
+ double _chatSidePadding(BuildContext context) {
719
+ final width = MediaQuery.sizeOf(context).width;
720
+ if (width >= 1280) return 40;
721
+ if (width >= 900) return 30;
722
+ return 20;
723
+ }
724
+
725
+ class _ChatTopBar extends StatelessWidget {
726
+ const _ChatTopBar({required this.controller});
727
+
728
+ final NeoAgentController controller;
729
+
730
+ @override
731
+ Widget build(BuildContext context) {
732
+ return Container(
733
+ height: 58,
734
+ padding: EdgeInsets.symmetric(horizontal: _chatSidePadding(context)),
735
+ decoration: BoxDecoration(
736
+ color: _bgPrimary.withValues(alpha: 0.72),
737
+ border: Border(bottom: BorderSide(color: _border)),
738
+ ),
739
+ child: Row(
740
+ children: <Widget>[
741
+ Text(
742
+ 'Chat',
743
+ style: GoogleFonts.geist(
744
+ fontSize: 15,
745
+ fontWeight: FontWeight.w600,
746
+ color: _textPrimary,
747
+ ),
748
+ ),
749
+ const SizedBox(width: 12),
750
+ Expanded(
751
+ child: Text(
752
+ '/ ${controller.activeAgentLabel} / ${controller.modelIndicator}',
753
+ maxLines: 1,
754
+ overflow: TextOverflow.ellipsis,
755
+ style: GoogleFonts.geistMono(fontSize: 11, color: _textMuted),
756
+ ),
757
+ ),
758
+ const SizedBox(width: 12),
759
+ Row(
760
+ mainAxisSize: MainAxisSize.min,
761
+ children: <Widget>[
762
+ Container(
763
+ width: 7,
764
+ height: 7,
765
+ decoration: BoxDecoration(
766
+ shape: BoxShape.circle,
767
+ color: controller.hasLiveRun ? _success : _accentAlt,
768
+ boxShadow: <BoxShadow>[
769
+ BoxShadow(
770
+ color: (controller.hasLiveRun ? _success : _accentAlt)
771
+ .withValues(alpha: 0.18),
772
+ blurRadius: 0,
773
+ spreadRadius: 3,
396
774
  ),
397
775
  ],
398
776
  ),
399
777
  ),
400
- const SizedBox(height: 8),
401
- Row(
402
- children: <Widget>[
403
- Expanded(
404
- child: Text(
405
- controller.chatStatusLabel,
406
- maxLines: 1,
407
- overflow: TextOverflow.ellipsis,
408
- style: TextStyle(fontSize: 12, color: _textSecondary),
409
- ),
410
- ),
411
- const SizedBox(width: 12),
412
- Flexible(
413
- child: Text(
414
- controller.hasLiveRun
415
- ? 'Steering mode'
416
- : controller.modelIndicator,
417
- maxLines: 1,
418
- overflow: TextOverflow.ellipsis,
419
- textAlign: TextAlign.right,
420
- style: TextStyle(fontSize: 12, color: _textSecondary),
421
- ),
422
- ),
423
- ],
778
+ const SizedBox(width: 7),
779
+ Text(
780
+ controller.hasLiveRun ? 'live' : 'idle',
781
+ style: GoogleFonts.geistMono(
782
+ fontSize: 12,
783
+ color: _textSecondary,
784
+ ),
424
785
  ),
425
786
  ],
426
787
  ),
788
+ ],
789
+ ),
790
+ );
791
+ }
792
+ }
793
+
794
+ class _ChatComposerIconButton extends StatelessWidget {
795
+ const _ChatComposerIconButton({
796
+ required this.tooltip,
797
+ required this.icon,
798
+ required this.onPressed,
799
+ this.color,
800
+ this.backgroundColor,
801
+ });
802
+
803
+ final String tooltip;
804
+ final IconData icon;
805
+ final VoidCallback? onPressed;
806
+ final Color? color;
807
+ final Color? backgroundColor;
808
+
809
+ @override
810
+ Widget build(BuildContext context) {
811
+ final foreground = color ?? _textSecondary;
812
+ return Tooltip(
813
+ message: tooltip,
814
+ child: Material(
815
+ color: Colors.transparent,
816
+ borderRadius: BorderRadius.circular(11),
817
+ child: InkWell(
818
+ borderRadius: BorderRadius.circular(11),
819
+ onTap: onPressed,
820
+ child: Container(
821
+ width: 40,
822
+ height: 40,
823
+ decoration: BoxDecoration(
824
+ color: onPressed == null
825
+ ? _bgTertiary.withValues(alpha: 0.52)
826
+ : backgroundColor ?? Colors.transparent,
827
+ borderRadius: BorderRadius.circular(11),
828
+ ),
829
+ child: Icon(
830
+ icon,
831
+ size: 19,
832
+ color: onPressed == null ? _textMuted : foreground,
833
+ ),
834
+ ),
427
835
  ),
428
- ],
836
+ ),
837
+ );
838
+ }
839
+ }
840
+
841
+ class _ScrollToBottomButton extends StatelessWidget {
842
+ const _ScrollToBottomButton({required this.onTap});
843
+
844
+ final VoidCallback onTap;
845
+
846
+ @override
847
+ Widget build(BuildContext context) {
848
+ return Tooltip(
849
+ message: 'Scroll to bottom',
850
+ child: Material(
851
+ color: _bgCard,
852
+ borderRadius: BorderRadius.circular(20),
853
+ elevation: 4,
854
+ shadowColor: Colors.black.withValues(alpha: 0.22),
855
+ child: InkWell(
856
+ onTap: onTap,
857
+ borderRadius: BorderRadius.circular(20),
858
+ child: Container(
859
+ width: 38,
860
+ height: 38,
861
+ decoration: BoxDecoration(
862
+ borderRadius: BorderRadius.circular(20),
863
+ border: Border.all(color: _border),
864
+ ),
865
+ child: Icon(
866
+ Icons.keyboard_arrow_down_rounded,
867
+ size: 22,
868
+ color: _textSecondary,
869
+ ),
870
+ ),
871
+ ),
872
+ ),
429
873
  );
430
874
  }
431
875
  }
@@ -1700,9 +2144,17 @@ class _MessagingCard extends StatelessWidget {
1700
2144
  Widget build(BuildContext context) {
1701
2145
  final connected = status?.isConnected ?? false;
1702
2146
  final configured = status != null && status!.status != 'not_configured';
2147
+ final disabled = status?.status == 'disabled';
2148
+ final canDisconnect = configured && !connected && !disabled;
2149
+ final isDisconnecting = controller.isMessagingPlatformBusy(
2150
+ platform.id,
2151
+ 'disconnect',
2152
+ );
1703
2153
  final accent = platform.accent;
1704
2154
  final actionLabel = connected
1705
2155
  ? 'Connected'
2156
+ : disabled
2157
+ ? 'Disabled'
1706
2158
  : configured
1707
2159
  ? 'Reconnect'
1708
2160
  : 'Connect';
@@ -1767,11 +2219,15 @@ class _MessagingCard extends StatelessWidget {
1767
2219
  _StatusPill(
1768
2220
  label: connected
1769
2221
  ? 'Live'
2222
+ : disabled
2223
+ ? 'Disabled'
1770
2224
  : configured
1771
2225
  ? 'Ready'
1772
2226
  : 'Setup',
1773
2227
  color: connected
1774
2228
  ? _success
2229
+ : disabled
2230
+ ? _textMuted
1775
2231
  : configured
1776
2232
  ? _warning
1777
2233
  : _textMuted,
@@ -1812,23 +2268,48 @@ class _MessagingCard extends StatelessWidget {
1812
2268
  Expanded(
1813
2269
  child: connected
1814
2270
  ? OutlinedButton.icon(
1815
- onPressed: onDisconnect,
1816
- icon: Icon(Icons.link_off_rounded, size: 18),
2271
+ onPressed: isDisconnecting ? null : onDisconnect,
2272
+ icon: isDisconnecting
2273
+ ? SizedBox.square(
2274
+ dimension: 18,
2275
+ child: CircularProgressIndicator(
2276
+ strokeWidth: 2,
2277
+ ),
2278
+ )
2279
+ : Icon(Icons.link_off_rounded, size: 18),
1817
2280
  label: Text(
1818
2281
  'Disconnect',
1819
2282
  overflow: TextOverflow.ellipsis,
1820
2283
  ),
1821
2284
  )
1822
2285
  : FilledButton.icon(
1823
- onPressed: onConnect,
2286
+ onPressed: disabled || isDisconnecting
2287
+ ? null
2288
+ : onConnect,
1824
2289
  icon: Icon(Icons.power_settings_new_rounded, size: 18),
1825
2290
  label: Text(
1826
2291
  actionLabel,
1827
2292
  overflow: TextOverflow.ellipsis,
1828
2293
  ),
1829
- style: FilledButton.styleFrom(backgroundColor: accent),
2294
+ style: FilledButton.styleFrom(
2295
+ backgroundColor: accent,
2296
+ foregroundColor: Colors.white,
2297
+ ),
1830
2298
  ),
1831
2299
  ),
2300
+ if (canDisconnect) ...[
2301
+ const SizedBox(width: 8),
2302
+ IconButton.outlined(
2303
+ tooltip: 'Disconnect platform',
2304
+ onPressed: isDisconnecting ? null : onDisconnect,
2305
+ icon: isDisconnecting
2306
+ ? SizedBox.square(
2307
+ dimension: 18,
2308
+ child: CircularProgressIndicator(strokeWidth: 2),
2309
+ )
2310
+ : Icon(Icons.link_off_rounded),
2311
+ ),
2312
+ ],
1832
2313
  const SizedBox(width: 8),
1833
2314
  IconButton.outlined(
1834
2315
  tooltip: 'Access policy',
@@ -3173,7 +3654,7 @@ class _RunResponseCard extends StatelessWidget {
3173
3654
  height: 1.6,
3174
3655
  ),
3175
3656
  code: Theme.of(context).textTheme.bodyMedium?.copyWith(
3176
- fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
3657
+ fontFamily: GoogleFonts.geistMono().fontFamily,
3177
3658
  backgroundColor: _bgSecondary,
3178
3659
  color: _textPrimary,
3179
3660
  ),
@@ -3256,8 +3737,7 @@ class _DeliverableSummaryCard extends StatelessWidget {
3256
3737
  style: TextStyle(
3257
3738
  color: _textSecondary,
3258
3739
  fontSize: 12,
3259
- fontFamily:
3260
- GoogleFonts.jetBrainsMono().fontFamily,
3740
+ fontFamily: GoogleFonts.geistMono().fontFamily,
3261
3741
  ),
3262
3742
  ),
3263
3743
  ],
@@ -3568,7 +4048,7 @@ class _RunDetailBlock extends StatelessWidget {
3568
4048
  fontSize: 12.5,
3569
4049
  color: _textPrimary,
3570
4050
  fontFamily: monospace
3571
- ? GoogleFonts.jetBrainsMono().fontFamily
4051
+ ? GoogleFonts.geistMono().fontFamily
3572
4052
  : null,
3573
4053
  ),
3574
4054
  ),