neoagent 2.4.1-beta.8 → 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 (248) 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 +45 -7
  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 -2
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +15 -2
  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 +3 -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 +338 -46
  52. package/flutter_app/lib/main_devices.dart +851 -122
  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 +764 -887
  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 +79 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +56 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +77 -1
  70. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  71. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  72. package/flutter_app/lib/src/stream_renderer.dart +205 -35
  73. package/flutter_app/lib/src/theme/palette.dart +76 -34
  74. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  75. package/flutter_app/macos/Runner/AppDelegate.swift +44 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  81. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  82. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  83. package/flutter_app/pubspec.lock +2 -2
  84. package/flutter_app/pubspec.yaml +7 -1
  85. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  94. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  95. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  96. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  97. package/flutter_app/web/favicon.png +0 -0
  98. package/flutter_app/web/favicon.svg +40 -9
  99. package/flutter_app/web/favicon_light.svg +43 -0
  100. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-192.png +0 -0
  102. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-512.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  106. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  107. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  108. package/flutter_app/windows/runner/main.cpp +7 -1
  109. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  110. package/lib/manager.js +445 -83
  111. package/package.json +17 -3
  112. package/runtime/paths.js +3 -1
  113. package/server/admin/access.js +198 -0
  114. package/server/admin/admin.css +268 -0
  115. package/server/admin/admin.js +348 -0
  116. package/server/admin/analytics.js +128 -0
  117. package/server/admin/index.html +1015 -0
  118. package/server/admin/login.html +290 -0
  119. package/server/admin/logo.svg +43 -0
  120. package/server/admin/sql.js +134 -0
  121. package/server/admin/users.js +147 -0
  122. package/server/config/origins.js +3 -1
  123. package/server/db/database.js +92 -0
  124. package/server/db/ftsQuery.js +27 -0
  125. package/server/http/routes.js +1 -0
  126. package/server/http/static.js +23 -6
  127. package/server/index.js +1 -1
  128. package/server/middleware/adminAuth.js +48 -0
  129. package/server/middleware/auth.js +1 -40
  130. package/server/public/.last_build_id +1 -1
  131. package/server/public/app_icon.png +0 -0
  132. package/server/public/assets/AssetManifest.bin +1 -1
  133. package/server/public/assets/AssetManifest.bin.json +1 -1
  134. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  138. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  139. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  140. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  141. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  142. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  143. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  144. package/server/public/assets/web/icons/Icon-192.png +0 -0
  145. package/server/public/favicon.png +0 -0
  146. package/server/public/favicon.svg +40 -9
  147. package/server/public/favicon_light.svg +43 -0
  148. package/server/public/flutter_bootstrap.js +2 -2
  149. package/server/public/icons/Icon-192-light.png +0 -0
  150. package/server/public/icons/Icon-192.png +0 -0
  151. package/server/public/icons/Icon-512-light.png +0 -0
  152. package/server/public/icons/Icon-512.png +0 -0
  153. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-192.png +0 -0
  155. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  156. package/server/public/icons/Icon-maskable-512.png +0 -0
  157. package/server/public/main.dart.js +88944 -85720
  158. package/server/routes/admin.js +632 -0
  159. package/server/routes/agent_profiles.js +3 -0
  160. package/server/routes/agents.js +1 -1
  161. package/server/routes/auth.js +3 -1
  162. package/server/routes/browser.js +14 -0
  163. package/server/routes/browser_extension.js +21 -4
  164. package/server/routes/desktop.js +10 -0
  165. package/server/routes/mcp.js +29 -13
  166. package/server/routes/memory.js +23 -1
  167. package/server/routes/messaging.js +2 -0
  168. package/server/routes/screenHistory.js +14 -8
  169. package/server/routes/settings.js +14 -2
  170. package/server/routes/stream.js +12 -3
  171. package/server/routes/triggers.js +4 -4
  172. package/server/routes/voice_assistant.js +36 -1
  173. package/server/routes/workspace.js +86 -0
  174. package/server/services/account/admin_two_factor.js +132 -0
  175. package/server/services/account/password_policy.js +6 -1
  176. package/server/services/ai/completion.js +44 -0
  177. package/server/services/ai/engine.js +321 -500
  178. package/server/services/ai/imageAnalysis.js +9 -5
  179. package/server/services/ai/logFormat.js +46 -0
  180. package/server/services/ai/loopPolicy.js +11 -0
  181. package/server/services/ai/messagingFallback.js +228 -0
  182. package/server/services/ai/models.js +194 -55
  183. package/server/services/ai/providerRetry.js +169 -0
  184. package/server/services/ai/providers/anthropic.js +15 -4
  185. package/server/services/ai/providers/google.js +38 -11
  186. package/server/services/ai/providers/grok.js +19 -68
  187. package/server/services/ai/providers/grokOauth.js +141 -0
  188. package/server/services/ai/providers/nvidia.js +154 -0
  189. package/server/services/ai/providers/ollama.js +76 -39
  190. package/server/services/ai/providers/openai.js +20 -31
  191. package/server/services/ai/providers/openaiCodex.js +6 -2
  192. package/server/services/ai/providers/openaiCompatible.js +70 -0
  193. package/server/services/ai/providers/openrouter.js +162 -0
  194. package/server/services/ai/settings.js +30 -0
  195. package/server/services/ai/systemPrompt.js +51 -29
  196. package/server/services/ai/taskAnalysis.js +60 -1
  197. package/server/services/ai/toolEvidence.js +207 -0
  198. package/server/services/ai/toolSelector.js +8 -1
  199. package/server/services/ai/tools.js +80 -15
  200. package/server/services/android/controller.js +36 -1
  201. package/server/services/browser/controller.js +18 -0
  202. package/server/services/browser/extension/manifest.js +33 -0
  203. package/server/services/browser/extension/provider.js +12 -6
  204. package/server/services/browser/extension/registry.js +229 -18
  205. package/server/services/desktop/protocol.js +1 -0
  206. package/server/services/desktop/provider.js +4 -0
  207. package/server/services/desktop/registry.js +104 -1
  208. package/server/services/desktop/screenRecorder.js +208 -98
  209. package/server/services/desktop/screen_recorder_support.js +46 -0
  210. package/server/services/integrations/google/provider.js +13 -0
  211. package/server/services/integrations/home_assistant/constants.js +88 -0
  212. package/server/services/integrations/home_assistant/network.js +207 -0
  213. package/server/services/integrations/home_assistant/provider.js +249 -0
  214. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  215. package/server/services/integrations/home_assistant/tools.js +101 -0
  216. package/server/services/integrations/manager.js +5 -0
  217. package/server/services/integrations/registry.js +2 -0
  218. package/server/services/integrations/trello/provider.js +8 -2
  219. package/server/services/manager.js +51 -47
  220. package/server/services/mcp/client.js +208 -268
  221. package/server/services/mcp/client_support.js +172 -0
  222. package/server/services/mcp/recovery.js +116 -0
  223. package/server/services/mcp/tool_operations.js +123 -0
  224. package/server/services/memory/ingestion.js +185 -370
  225. package/server/services/memory/ingestion_coverage.js +129 -0
  226. package/server/services/memory/ingestion_documents.js +123 -0
  227. package/server/services/memory/ingestion_support.js +171 -0
  228. package/server/services/memory/intelligence.js +181 -0
  229. package/server/services/memory/manager.js +476 -32
  230. package/server/services/messaging/automation.js +71 -134
  231. package/server/services/messaging/formatting_guides.js +26 -1
  232. package/server/services/messaging/inbound_queue.js +111 -0
  233. package/server/services/messaging/manager.js +25 -8
  234. package/server/services/messaging/typing_keepalive.js +110 -0
  235. package/server/services/runtime/manager.js +7 -3
  236. package/server/services/runtime/settings.js +17 -0
  237. package/server/services/streaming/android-stream.js +296 -32
  238. package/server/services/streaming/stream-hub.js +1 -1
  239. package/server/services/tasks/integration_runtime.js +4 -1
  240. package/server/services/tasks/runtime.js +415 -56
  241. package/server/services/tasks/task_repository.js +48 -6
  242. package/server/services/websocket.js +8 -2
  243. package/server/services/widgets/service.js +17 -1
  244. package/server/services/workspace/manager.js +116 -0
  245. package/server/utils/logger.js +44 -6
  246. package/server/utils/security.js +3 -0
  247. package/server/utils/version.js +29 -19
  248. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -1,5 +1,7 @@
1
1
  part of 'main.dart';
2
2
 
3
+ // ignore_for_file: unused_element
4
+
3
5
  class _OptimizedScreenshotPayload {
4
6
  const _OptimizedScreenshotPayload({
5
7
  required this.bytes,
@@ -26,6 +28,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
26
28
  late final TextEditingController _androidLaunchController;
27
29
  late final TextEditingController _desktopLaunchController;
28
30
  late final TextEditingController _textEntryController;
31
+ late final TextEditingController _workspaceEditorController;
29
32
  Timer? _surfaceFrameTimer;
30
33
  _DeviceSurface _surface = _DeviceSurface.browser;
31
34
  _DeviceSurface? _runningSurface;
@@ -39,6 +42,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
39
42
  );
40
43
  _desktopLaunchController = TextEditingController();
41
44
  _textEntryController = TextEditingController();
45
+ _workspaceEditorController = TextEditingController();
42
46
  WidgetsBinding.instance.addPostFrameCallback((_) {
43
47
  if (!mounted) {
44
48
  return;
@@ -57,6 +61,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
57
61
  _androidLaunchController,
58
62
  _desktopLaunchController,
59
63
  _textEntryController,
64
+ _workspaceEditorController,
60
65
  ]) {
61
66
  controller.dispose();
62
67
  }
@@ -66,6 +71,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
66
71
 
67
72
  bool get _isBrowser => _surface == _DeviceSurface.browser;
68
73
  bool get _isDesktop => _surface == _DeviceSurface.desktop;
74
+ bool get _isFiles => _surface == _DeviceSurface.files;
69
75
 
70
76
  bool get _isCurrentSurfaceBusy =>
71
77
  widget.controller.isRunningDeviceAction &&
@@ -90,6 +96,29 @@ class _DevicesPanelState extends State<DevicesPanel> {
90
96
  bool get _androidStarting =>
91
97
  widget.controller.androidRuntime['starting'] == true;
92
98
 
99
+ String? get _androidDeviceId {
100
+ final status = widget.controller.androidRuntime;
101
+ final direct = status['adbSerial']?.toString().trim();
102
+ if (direct != null && direct.isNotEmpty) {
103
+ return direct;
104
+ }
105
+ final devices = _jsonMapList(status['devices'], fallbackToMapValues: true);
106
+ for (final device in devices) {
107
+ if (device['status']?.toString() != 'device') {
108
+ continue;
109
+ }
110
+ final serial = device['serial']?.toString().trim();
111
+ if (serial != null && serial.isNotEmpty) {
112
+ return serial;
113
+ }
114
+ final id = device['deviceId']?.toString().trim();
115
+ if (id != null && id.isNotEmpty) {
116
+ return id;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
93
122
  List<Map<String, dynamic>> get _onlineDesktopDevices => widget
94
123
  .controller
95
124
  .desktopDevices
@@ -107,6 +136,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
107
136
  widget.controller.browserBackend == 'extension' &&
108
137
  !widget.controller.browserExtensionConnected;
109
138
 
139
+ List<Map<String, dynamic>> get _browserExtensionDevices =>
140
+ widget.controller.browserExtensionTokens;
141
+
110
142
  String? get _activeScreenshotPath {
111
143
  if (_isBrowser) {
112
144
  if (_extensionPreferredButOffline) return null;
@@ -154,6 +186,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
154
186
  return;
155
187
  }
156
188
 
189
+ if (_isFiles) {
190
+ if (controller.workspaceEntries.isEmpty) {
191
+ await controller.refreshWorkspaceFiles();
192
+ }
193
+ return;
194
+ }
195
+
157
196
  if (_androidOnline && (controller.androidScreenshotPath ?? '').isEmpty) {
158
197
  await controller.screenshotAndroidRuntime();
159
198
  }
@@ -175,6 +214,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
175
214
  await widget.controller.refreshDesktopFrameRuntime();
176
215
  return;
177
216
  }
217
+ if (_isFiles) {
218
+ return;
219
+ }
178
220
  if (_androidStarting) {
179
221
  await widget.controller.refreshDevices();
180
222
  if (_androidOnline &&
@@ -232,6 +274,11 @@ class _DevicesPanelState extends State<DevicesPanel> {
232
274
  return;
233
275
  }
234
276
 
277
+ if (_isFiles) {
278
+ await controller.refreshWorkspaceFiles();
279
+ return;
280
+ }
281
+
235
282
  if (!_androidOnline) {
236
283
  await controller.startAndroidRuntime();
237
284
  await widget.controller.refreshDevices();
@@ -273,6 +320,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
273
320
  }
274
321
  return;
275
322
  }
323
+ if (_isFiles) {
324
+ return;
325
+ }
276
326
  if (!_androidOnline) {
277
327
  return;
278
328
  }
@@ -288,6 +338,8 @@ class _DevicesPanelState extends State<DevicesPanel> {
288
338
  await widget.controller.typeBrowserTextRuntime(text, pressEnter: true);
289
339
  } else if (_isDesktop) {
290
340
  await widget.controller.typeDesktopRuntime(text, pressEnter: true);
341
+ } else if (_isFiles) {
342
+ return;
291
343
  } else {
292
344
  await widget.controller.typeAndroidRuntime(<String, dynamic>{
293
345
  'text': text,
@@ -296,6 +348,23 @@ class _DevicesPanelState extends State<DevicesPanel> {
296
348
  }
297
349
  });
298
350
 
351
+ void _handleHover(Offset point) {
352
+ if (_isBrowser) {
353
+ unawaited(widget.controller.hoverBrowserPointRuntime(
354
+ x: point.dx.round(),
355
+ y: point.dy.round(),
356
+ ));
357
+ } else if (_isDesktop) {
358
+ if (_desktopRequiresSelection) {
359
+ return;
360
+ }
361
+ unawaited(widget.controller.hoverDesktopRuntime(
362
+ x: point.dx.round(),
363
+ y: point.dy.round(),
364
+ ));
365
+ }
366
+ }
367
+
299
368
  Future<void> _handleTap(Offset point) => _runOnSurface(() async {
300
369
  if (_isBrowser) {
301
370
  await widget.controller.clickBrowserPointRuntime(
@@ -324,36 +393,37 @@ class _DevicesPanelState extends State<DevicesPanel> {
324
393
  });
325
394
  });
326
395
 
327
- Future<void> _handleSwipe(Offset start, Offset end) => _runOnSurface(() async {
328
- if (_isBrowser) {
329
- await widget.controller.scrollBrowserRuntime(
330
- deltaY: (start.dy - end.dy).round(),
331
- );
332
- return;
333
- }
334
- if (_isDesktop) {
335
- if (_desktopRequiresSelection) {
336
- return;
337
- }
338
- await widget.controller.dragDesktopRuntime(
339
- x1: start.dx.round(),
340
- y1: start.dy.round(),
341
- x2: end.dx.round(),
342
- y2: end.dy.round(),
343
- );
344
- return;
345
- }
346
- if (!_androidOnline) {
347
- return;
348
- }
349
- await widget.controller.swipeAndroidRuntime(<String, dynamic>{
350
- 'x1': start.dx.round(),
351
- 'y1': start.dy.round(),
352
- 'x2': end.dx.round(),
353
- 'y2': end.dy.round(),
354
- 'durationMs': 280,
355
- });
356
- });
396
+ Future<void> _handleSwipe(Offset start, Offset end) =>
397
+ _runOnSurface(() async {
398
+ if (_isBrowser) {
399
+ await widget.controller.scrollBrowserRuntime(
400
+ deltaY: (start.dy - end.dy).round(),
401
+ );
402
+ return;
403
+ }
404
+ if (_isDesktop) {
405
+ if (_desktopRequiresSelection) {
406
+ return;
407
+ }
408
+ await widget.controller.dragDesktopRuntime(
409
+ x1: start.dx.round(),
410
+ y1: start.dy.round(),
411
+ x2: end.dx.round(),
412
+ y2: end.dy.round(),
413
+ );
414
+ return;
415
+ }
416
+ if (!_androidOnline) {
417
+ return;
418
+ }
419
+ await widget.controller.swipeAndroidRuntime(<String, dynamic>{
420
+ 'x1': start.dx.round(),
421
+ 'y1': start.dy.round(),
422
+ 'x2': end.dx.round(),
423
+ 'y2': end.dy.round(),
424
+ 'durationMs': 280,
425
+ });
426
+ });
357
427
 
358
428
  Future<void> _runQuickAction(String action) => _runOnSurface(() async {
359
429
  final controller = widget.controller;
@@ -398,6 +468,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
398
468
  final prefersExtension = controller.browserBackend == 'extension';
399
469
  final extensionConnected = controller.browserExtensionConnected;
400
470
  final usingExtension = prefersExtension && extensionConnected;
471
+ final selectedBrowserExtension = controller.browserExtensionTokens
472
+ .where(
473
+ (device) =>
474
+ device['tokenId'] == controller.selectedBrowserExtensionTokenId,
475
+ )
476
+ .cast<Map<String, dynamic>?>()
477
+ .firstWhere((device) => device != null, orElse: () => null);
401
478
  final browserFallbackLabel = 'cloud browser runtime';
402
479
  final browserPageInfo = browserStatus['pageInfo'] is Map<dynamic, dynamic>
403
480
  ? Map<String, dynamic>.from(browserStatus['pageInfo'] as Map)
@@ -454,12 +531,75 @@ class _DevicesPanelState extends State<DevicesPanel> {
454
531
  desktopDevices: controller.desktopDevices,
455
532
  selectedDesktopDeviceId:
456
533
  controller.selectedDesktopDeviceId,
534
+ browserExtensionDevices:
535
+ controller.browserExtensionTokens,
536
+ selectedBrowserExtensionTokenId:
537
+ controller.selectedBrowserExtensionTokenId,
457
538
  browserExtensionPreferred: prefersExtension,
458
539
  browserExtensionActive: usingExtension,
459
540
  browserFallbackLabel: browserFallbackLabel,
460
541
  ),
542
+ if (_isFiles) ...<Widget>[
543
+ const SizedBox(height: 14),
544
+ _WorkspaceExplorer(
545
+ controller: controller,
546
+ editorController: _workspaceEditorController,
547
+ ),
548
+ const SizedBox(height: 14),
549
+ _SurfaceSwitcher(
550
+ surface: _surface,
551
+ onSelect: _selectSurface,
552
+ ),
553
+ ] else ...<Widget>[
461
554
  if (_isBrowser && prefersExtension) ...<Widget>[
462
555
  const SizedBox(height: 14),
556
+ if (_browserExtensionDevices.isNotEmpty) ...<Widget>[
557
+ DropdownButtonFormField<String>(
558
+ isExpanded: true,
559
+ initialValue: selectedBrowserExtension?['tokenId']
560
+ ?.toString(),
561
+ decoration: const InputDecoration(
562
+ labelText: 'Chrome extension device',
563
+ prefixIcon: Icon(Icons.extension_outlined),
564
+ ),
565
+ hint: const Text('Select a paired extension'),
566
+ items: _browserExtensionDevices.map((device) {
567
+ final tokenId = device['tokenId']?.toString() ?? '';
568
+ final label =
569
+ device['name']?.toString().trim().isNotEmpty ==
570
+ true
571
+ ? device['name'].toString()
572
+ : tokenId;
573
+ final state =
574
+ device['online'] == true ||
575
+ device['connected'] == true
576
+ ? 'online'
577
+ : 'offline';
578
+ return DropdownMenuItem<String>(
579
+ value: tokenId,
580
+ child: Text(
581
+ '$label · $state',
582
+ maxLines: 1,
583
+ overflow: TextOverflow.ellipsis,
584
+ softWrap: false,
585
+ ),
586
+ );
587
+ }).toList(),
588
+ onChanged: _isCurrentSurfaceBusy
589
+ ? null
590
+ : (value) {
591
+ if (value == null || value.isEmpty) {
592
+ return;
593
+ }
594
+ unawaited(
595
+ controller.selectBrowserExtensionRuntime(
596
+ value,
597
+ ),
598
+ );
599
+ },
600
+ ),
601
+ const SizedBox(height: 10),
602
+ ],
463
603
  _ExtensionStatusBar(
464
604
  connected: extensionConnected,
465
605
  onDownload: controller.downloadBrowserExtension,
@@ -570,6 +710,21 @@ class _DevicesPanelState extends State<DevicesPanel> {
570
710
  surface: _surface,
571
711
  controller: controller,
572
712
  screenshotPath: _activeScreenshotPath,
713
+ streamPlatform: _isBrowser && browserStatus['launched'] == true
714
+ ? 'browser'
715
+ : (_isDesktop && _desktopOnline
716
+ ? 'desktop'
717
+ : (!_isBrowser && !_isDesktop && _androidOnline
718
+ ? 'android'
719
+ : null)),
720
+ streamDeviceId: _isBrowser && browserStatus['launched'] == true
721
+ ? 'browser'
722
+ : (_isDesktop && _desktopOnline
723
+ ? (widget.controller.selectedDesktopDeviceId ??
724
+ (_onlineDesktopDevices.isNotEmpty ? _onlineDesktopDevices.first['deviceId']?.toString() : null))
725
+ : (!_isBrowser && !_isDesktop && _androidOnline
726
+ ? _androidDeviceId
727
+ : null)),
573
728
  busy: _isCurrentSurfaceBusy,
574
729
  wakingUp: !_isBrowser && !_isDesktop && _androidStarting,
575
730
  enabled: _isBrowser || _isDesktop || _androidOnline,
@@ -578,6 +733,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
578
733
  : _desktopRequiresSelection,
579
734
  onTapPoint: _handleTap,
580
735
  onSwipe: _handleSwipe,
736
+ onHover: _handleHover,
581
737
  onWakeRequested: _openPrimary,
582
738
  ),
583
739
  if (!_isBrowser && !_isDesktop) ...<Widget>[
@@ -588,8 +744,8 @@ class _DevicesPanelState extends State<DevicesPanel> {
588
744
  onAction: _runQuickAction,
589
745
  ),
590
746
  if (kIsWeb) ...<Widget>[
591
- const SizedBox(height: 14),
592
- AndroidApkDropZone(
747
+ const SizedBox(height: 12),
748
+ _AndroidActionsBox(
593
749
  enabled: _androidOnline,
594
750
  busy: _isCurrentSurfaceBusy,
595
751
  onInstall: ({required filename, required bytes}) {
@@ -622,6 +778,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
622
778
  surface: _surface,
623
779
  onSelect: _selectSurface,
624
780
  ),
781
+ ],
625
782
  ],
626
783
  ),
627
784
  ),
@@ -633,13 +790,14 @@ class _DevicesPanelState extends State<DevicesPanel> {
633
790
  }
634
791
  }
635
792
 
636
- enum _DeviceSurface { browser, android, desktop }
793
+ enum _DeviceSurface { browser, android, desktop, files }
637
794
 
638
795
  extension _DeviceSurfaceX on _DeviceSurface {
639
796
  String get label => switch (this) {
640
797
  _DeviceSurface.browser => 'Browser',
641
798
  _DeviceSurface.android => 'Phone',
642
799
  _DeviceSurface.desktop => 'Desktop',
800
+ _DeviceSurface.files => 'Files',
643
801
  };
644
802
 
645
803
  String get helper => switch (this) {
@@ -647,12 +805,14 @@ extension _DeviceSurfaceX on _DeviceSurface {
647
805
  _DeviceSurface.android => 'Tap to touch. Drag to swipe.',
648
806
  _DeviceSurface.desktop =>
649
807
  'Tap to click. Drag to drag windows or selections.',
808
+ _DeviceSurface.files => 'Browse files created by the agent.',
650
809
  };
651
810
 
652
811
  IconData get icon => switch (this) {
653
812
  _DeviceSurface.browser => Icons.language_outlined,
654
813
  _DeviceSurface.android => Icons.smartphone_outlined,
655
814
  _DeviceSurface.desktop => Icons.computer_outlined,
815
+ _DeviceSurface.files => Icons.folder_outlined,
656
816
  };
657
817
  }
658
818
 
@@ -666,6 +826,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
666
826
  required this.desktopRuntime,
667
827
  required this.desktopDevices,
668
828
  required this.selectedDesktopDeviceId,
829
+ required this.browserExtensionDevices,
830
+ required this.selectedBrowserExtensionTokenId,
669
831
  required this.browserExtensionPreferred,
670
832
  required this.browserExtensionActive,
671
833
  required this.browserFallbackLabel,
@@ -679,6 +841,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
679
841
  final Map<String, dynamic> desktopRuntime;
680
842
  final List<Map<String, dynamic>> desktopDevices;
681
843
  final String? selectedDesktopDeviceId;
844
+ final List<Map<String, dynamic>> browserExtensionDevices;
845
+ final String? selectedBrowserExtensionTokenId;
682
846
  final bool browserExtensionPreferred;
683
847
  final bool browserExtensionActive;
684
848
  final String browserFallbackLabel;
@@ -694,12 +858,29 @@ class _DeviceSurfaceHeader extends StatelessWidget {
694
858
  .where((device) => device['deviceId'] == selectedDesktopDeviceId)
695
859
  .cast<Map<String, dynamic>?>()
696
860
  .firstWhere((device) => device != null, orElse: () => null);
861
+ final selectedExtension = browserExtensionDevices
862
+ .where(
863
+ (device) => device['tokenId'] == selectedBrowserExtensionTokenId,
864
+ )
865
+ .cast<Map<String, dynamic>?>()
866
+ .firstWhere((device) => device != null, orElse: () => null);
867
+ final extensionOnlineCount = browserExtensionDevices
868
+ .where(
869
+ (device) =>
870
+ device['online'] == true || device['connected'] == true,
871
+ )
872
+ .length;
697
873
  final desktopOnlineCount = desktopDevices
698
874
  .where((device) => device['online'] == true)
699
875
  .length;
700
876
  final title = switch (surface) {
701
877
  _DeviceSurface.browser =>
702
- (browserPageInfo['title']?.toString().trim().isNotEmpty ?? false)
878
+ browserExtensionPreferred &&
879
+ selectedExtension?['name']?.toString().trim().isNotEmpty ==
880
+ true
881
+ ? selectedExtension!['name'].toString()
882
+ : (browserPageInfo['title']?.toString().trim().isNotEmpty ??
883
+ false)
703
884
  ? browserPageInfo['title'].toString()
704
885
  : 'Live Browser',
705
886
  _DeviceSurface.android => 'Android Phone',
@@ -707,11 +888,16 @@ class _DeviceSurfaceHeader extends StatelessWidget {
707
888
  selectedDesktop?['label']?.toString().trim().isNotEmpty == true
708
889
  ? selectedDesktop!['label'].toString()
709
890
  : 'Desktop Companion',
891
+ _DeviceSurface.files => 'File Explorer',
710
892
  };
711
893
  final subtitle = switch (surface) {
712
- _DeviceSurface.browser =>
713
- browserExtensionPreferred && !browserExtensionActive
714
- ? 'No extension device is active. Using the $browserFallbackLabel.'
894
+ _DeviceSurface.browser =>
895
+ browserExtensionPreferred && selectedExtension == null
896
+ ? extensionOnlineCount > 1
897
+ ? 'Multiple extension devices are online. Pick the browser you want to control.'
898
+ : 'No extension device is active. Using the $browserFallbackLabel.'
899
+ : browserExtensionPreferred && !browserExtensionActive
900
+ ? 'Selected extension is offline. Using the $browserFallbackLabel.'
715
901
  : (browserPageInfo['url']?.toString() ??
716
902
  'Ready for navigation'),
717
903
  _DeviceSurface.android =>
@@ -744,9 +930,13 @@ class _DeviceSurfaceHeader extends StatelessWidget {
744
930
  ? 'One desktop companion is online. Open the surface to fetch the latest frame.'
745
931
  : 'No desktop companion is online. Enable Companion Mode on a signed-in desktop app.'
746
932
  : '${selectedDesktop['platform'] ?? 'desktop'} · ${selectedDesktop['hostname'] ?? 'unknown host'}',
933
+ _DeviceSurface.files =>
934
+ 'Secure per-user workspace inside the isolated VM.',
747
935
  };
748
936
  final statusLabel = surface == _DeviceSurface.browser
749
- ? browserExtensionPreferred && !browserExtensionActive
937
+ ? browserExtensionPreferred && selectedExtension == null
938
+ ? (extensionOnlineCount > 0 ? 'Select Device' : 'Fallback')
939
+ : browserExtensionPreferred && !browserExtensionActive
750
940
  ? 'Fallback'
751
941
  : browserExtensionActive
752
942
  ? 'Extension'
@@ -759,6 +949,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
759
949
  : (selectedDesktop['online'] == true
760
950
  ? 'Live'
761
951
  : 'Offline'))
952
+ : surface == _DeviceSurface.files
953
+ ? 'Isolated'
762
954
  : (androidOnline
763
955
  ? 'Live'
764
956
  : androidStarting
@@ -770,6 +962,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
770
962
  ? (selectedDesktop?['paused'] == true
771
963
  ? _warning
772
964
  : (selectedDesktop?['online'] == true ? _success : _warning))
965
+ : surface == _DeviceSurface.files
966
+ ? _success
773
967
  : (androidOnline
774
968
  ? _success
775
969
  : (androidStarting ? _accent : _warning));
@@ -876,16 +1070,19 @@ class _DeviceLaunchBar extends StatelessWidget {
876
1070
  _DeviceSurface.android => _packageOrUrlHint,
877
1071
  _DeviceSurface.desktop =>
878
1072
  'Launch an app on the selected desktop (optional)',
1073
+ _DeviceSurface.files => 'Workspace path',
879
1074
  };
880
1075
  final buttonLabel = switch (surface) {
881
1076
  _DeviceSurface.browser => 'Open',
882
1077
  _DeviceSurface.android => starting ? 'Starting...' : 'Launch',
883
1078
  _DeviceSurface.desktop => 'Refresh',
1079
+ _DeviceSurface.files => 'Refresh',
884
1080
  };
885
1081
  final sleepLabel = switch (surface) {
886
1082
  _DeviceSurface.browser => 'Sleep Browser',
887
1083
  _DeviceSurface.android => 'Sleep Phone',
888
1084
  _DeviceSurface.desktop => 'Pause Desktop',
1085
+ _DeviceSurface.files => 'Close',
889
1086
  };
890
1087
  final narrow = MediaQuery.sizeOf(context).width < 720;
891
1088
 
@@ -899,6 +1096,8 @@ class _DeviceLaunchBar extends StatelessWidget {
899
1096
  ? Icons.travel_explore
900
1097
  : surface == _DeviceSurface.desktop
901
1098
  ? Icons.apps_outlined
1099
+ : surface == _DeviceSurface.files
1100
+ ? Icons.folder_outlined
902
1101
  : Icons.open_in_new,
903
1102
  ),
904
1103
  ),
@@ -911,6 +1110,8 @@ class _DeviceLaunchBar extends StatelessWidget {
911
1110
  ? Icons.arrow_forward
912
1111
  : surface == _DeviceSurface.desktop
913
1112
  ? Icons.desktop_windows_outlined
1113
+ : surface == _DeviceSurface.files
1114
+ ? Icons.refresh_rounded
914
1115
  : Icons.play_arrow,
915
1116
  ),
916
1117
  label: Text(buttonLabel),
@@ -968,6 +1169,7 @@ class _DeviceTypeDock extends StatelessWidget {
968
1169
  _DeviceSurface.browser => 'Type into the currently focused field',
969
1170
  _DeviceSurface.android => 'Type into the current phone field',
970
1171
  _DeviceSurface.desktop => 'Type into the focused desktop field',
1172
+ _DeviceSurface.files => 'Edit the selected file',
971
1173
  };
972
1174
  final narrow = MediaQuery.sizeOf(context).width < 720;
973
1175
 
@@ -1041,6 +1243,9 @@ class _DeviceQuickActions extends StatelessWidget {
1041
1243
  _DeviceSurface.android => const <MapEntry<String, IconData>>[
1042
1244
  MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
1043
1245
  ],
1246
+ _DeviceSurface.files => const <MapEntry<String, IconData>>[
1247
+ MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
1248
+ ],
1044
1249
  };
1045
1250
 
1046
1251
  return Wrap(
@@ -1119,12 +1324,80 @@ class _AndroidNavDock extends StatelessWidget {
1119
1324
  }
1120
1325
  }
1121
1326
 
1122
- class _SurfaceSwitcher extends StatelessWidget {
1123
- const _SurfaceSwitcher({
1124
- required this.surface,
1125
- required this.onSelect,
1327
+ /// Tiny pill shown in the top-right corner of the preview to indicate no audio.
1328
+ class _MutedBadge extends StatelessWidget {
1329
+ const _MutedBadge();
1330
+
1331
+ @override
1332
+ Widget build(BuildContext context) {
1333
+ return Container(
1334
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4),
1335
+ decoration: BoxDecoration(
1336
+ color: Colors.black54,
1337
+ borderRadius: BorderRadius.circular(8),
1338
+ ),
1339
+ child: const Icon(Icons.volume_off_rounded, size: 11, color: Colors.white),
1340
+ );
1341
+ }
1342
+ }
1343
+
1344
+ /// Compact expandable actions box shown beneath the Android nav dock.
1345
+ /// Starts with APK install; more actions can be added as tiles.
1346
+ class _AndroidActionsBox extends StatelessWidget {
1347
+ const _AndroidActionsBox({
1348
+ required this.enabled,
1349
+ required this.busy,
1350
+ required this.onInstall,
1126
1351
  });
1127
1352
 
1353
+ final bool enabled;
1354
+ final bool busy;
1355
+ final AndroidApkInstallCallback onInstall;
1356
+
1357
+ @override
1358
+ Widget build(BuildContext context) {
1359
+ return Container(
1360
+ width: double.infinity,
1361
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1362
+ decoration: BoxDecoration(
1363
+ color: _bgSecondary,
1364
+ borderRadius: BorderRadius.circular(18),
1365
+ border: Border.all(color: _borderLight),
1366
+ ),
1367
+ child: Column(
1368
+ crossAxisAlignment: CrossAxisAlignment.start,
1369
+ mainAxisSize: MainAxisSize.min,
1370
+ children: <Widget>[
1371
+ Text(
1372
+ 'ACTIONS',
1373
+ style: TextStyle(
1374
+ fontSize: 10,
1375
+ fontWeight: FontWeight.w700,
1376
+ letterSpacing: 0.8,
1377
+ color: _textSecondary,
1378
+ ),
1379
+ ),
1380
+ const SizedBox(height: 8),
1381
+ Wrap(
1382
+ spacing: 8,
1383
+ runSpacing: 8,
1384
+ children: <Widget>[
1385
+ AndroidApkTile(
1386
+ enabled: enabled,
1387
+ busy: busy,
1388
+ onInstall: onInstall,
1389
+ ),
1390
+ ],
1391
+ ),
1392
+ ],
1393
+ ),
1394
+ );
1395
+ }
1396
+ }
1397
+
1398
+ class _SurfaceSwitcher extends StatelessWidget {
1399
+ const _SurfaceSwitcher({required this.surface, required this.onSelect});
1400
+
1128
1401
  final _DeviceSurface surface;
1129
1402
  final Future<void> Function(_DeviceSurface) onSelect;
1130
1403
 
@@ -1157,8 +1430,7 @@ class _SurfaceSwitcher extends StatelessWidget {
1157
1430
  ),
1158
1431
  labelStyle: TextStyle(
1159
1432
  color: selected ? _textPrimary : _textSecondary,
1160
- fontWeight:
1161
- selected ? FontWeight.w700 : FontWeight.w500,
1433
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
1162
1434
  ),
1163
1435
  );
1164
1436
  }).toList(),
@@ -1179,24 +1451,30 @@ class _InteractiveSurfacePreview extends StatefulWidget {
1179
1451
  required this.surface,
1180
1452
  required this.controller,
1181
1453
  required this.screenshotPath,
1454
+ required this.streamPlatform,
1455
+ required this.streamDeviceId,
1182
1456
  required this.busy,
1183
1457
  required this.wakingUp,
1184
1458
  required this.enabled,
1185
1459
  required this.connectRequired,
1186
1460
  required this.onTapPoint,
1187
1461
  required this.onSwipe,
1462
+ this.onHover,
1188
1463
  required this.onWakeRequested,
1189
1464
  });
1190
1465
 
1191
1466
  final _DeviceSurface surface;
1192
1467
  final NeoAgentController controller;
1193
1468
  final String? screenshotPath;
1469
+ final String? streamPlatform;
1470
+ final String? streamDeviceId;
1194
1471
  final bool busy;
1195
1472
  final bool wakingUp;
1196
1473
  final bool enabled;
1197
1474
  final bool connectRequired;
1198
1475
  final Future<void> Function(Offset point) onTapPoint;
1199
1476
  final Future<void> Function(Offset start, Offset end) onSwipe;
1477
+ final void Function(Offset point)? onHover;
1200
1478
  final Future<void> Function() onWakeRequested;
1201
1479
 
1202
1480
  @override
@@ -1213,11 +1491,15 @@ class _InteractiveSurfacePreviewState
1213
1491
  Object? _imageError;
1214
1492
  Offset? _dragStart;
1215
1493
  Offset? _dragEnd;
1494
+ bool _streamStarting = false;
1495
+ String? _activeStreamKey;
1496
+ String? _streamFailedKey;
1216
1497
 
1217
1498
  @override
1218
1499
  void initState() {
1219
1500
  super.initState();
1220
1501
  unawaited(_loadImage());
1502
+ unawaited(_syncStream());
1221
1503
  }
1222
1504
 
1223
1505
  @override
@@ -1226,14 +1508,131 @@ class _InteractiveSurfacePreviewState
1226
1508
  if (oldWidget.screenshotPath != widget.screenshotPath) {
1227
1509
  unawaited(_loadImage());
1228
1510
  }
1511
+ if (oldWidget.streamPlatform != widget.streamPlatform ||
1512
+ oldWidget.streamDeviceId != widget.streamDeviceId ||
1513
+ oldWidget.controller.streamSocket != widget.controller.streamSocket) {
1514
+ unawaited(_syncStream());
1515
+ }
1229
1516
  }
1230
1517
 
1231
1518
  @override
1232
1519
  void dispose() {
1520
+ unawaited(_stopActiveStream());
1233
1521
  _detachImageListener();
1234
1522
  super.dispose();
1235
1523
  }
1236
1524
 
1525
+ String? get _requestedStreamKey {
1526
+ final platform = widget.streamPlatform?.trim();
1527
+ final deviceId = widget.streamDeviceId?.trim();
1528
+ if (platform == null ||
1529
+ platform.isEmpty ||
1530
+ deviceId == null ||
1531
+ deviceId.isEmpty ||
1532
+ widget.controller.streamSocket == null) {
1533
+ return null;
1534
+ }
1535
+ return '$platform:$deviceId';
1536
+ }
1537
+
1538
+ Future<void> _syncStream() async {
1539
+ final requested = _requestedStreamKey;
1540
+ if (_activeStreamKey == requested || _streamStarting) {
1541
+ return;
1542
+ }
1543
+ if (requested != _streamFailedKey) {
1544
+ _streamFailedKey = null;
1545
+ }
1546
+ await _stopActiveStream();
1547
+ if (requested == null) {
1548
+ return;
1549
+ }
1550
+ final parts = requested.split(':');
1551
+ _streamStarting = true;
1552
+ try {
1553
+ await widget.controller.startStreamRuntime(
1554
+ platform: parts[0],
1555
+ deviceId: parts.sublist(1).join(':'),
1556
+ fps: 10,
1557
+ quality: 70,
1558
+ );
1559
+ if (mounted && _requestedStreamKey == requested) {
1560
+ _activeStreamKey = requested;
1561
+ _streamFailedKey = null;
1562
+ } else {
1563
+ await widget.controller.stopStreamRuntime(
1564
+ platform: parts[0],
1565
+ deviceId: parts.sublist(1).join(':'),
1566
+ );
1567
+ }
1568
+ } catch (_) {
1569
+ if (mounted) {
1570
+ _streamFailedKey = requested;
1571
+ }
1572
+ if (mounted && (widget.screenshotPath ?? '').isEmpty) {
1573
+ unawaited(_loadImage());
1574
+ }
1575
+ } finally {
1576
+ _streamStarting = false;
1577
+ if (mounted &&
1578
+ _activeStreamKey != _requestedStreamKey &&
1579
+ _streamFailedKey != _requestedStreamKey) {
1580
+ unawaited(_syncStream());
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ Future<void> _stopActiveStream() async {
1586
+ final active = _activeStreamKey;
1587
+ _activeStreamKey = null;
1588
+ if (active == null) {
1589
+ return;
1590
+ }
1591
+ final parts = active.split(':');
1592
+ try {
1593
+ await widget.controller.stopStreamRuntime(
1594
+ platform: parts[0],
1595
+ deviceId: parts.sublist(1).join(':'),
1596
+ );
1597
+ } catch (_) {}
1598
+ }
1599
+
1600
+ void _handleStreamFirstFrame(String streamKey) {
1601
+ if (!mounted || _requestedStreamKey != streamKey) {
1602
+ return;
1603
+ }
1604
+ if (_streamFailedKey == streamKey) {
1605
+ setState(() => _streamFailedKey = null);
1606
+ }
1607
+ }
1608
+
1609
+ void _handleStreamFrameTimeout(String streamKey) {
1610
+ if (!mounted || _requestedStreamKey != streamKey) {
1611
+ return;
1612
+ }
1613
+ setState(() => _streamFailedKey = streamKey);
1614
+ unawaited(_stopActiveStream());
1615
+ if ((widget.screenshotPath ?? '').isEmpty) {
1616
+ unawaited(_refreshStaticFrame());
1617
+ }
1618
+ }
1619
+
1620
+ Future<void> _refreshStaticFrame() async {
1621
+ switch (widget.surface) {
1622
+ case _DeviceSurface.browser:
1623
+ await widget.controller.screenshotBrowserRuntime();
1624
+ break;
1625
+ case _DeviceSurface.android:
1626
+ await widget.controller.screenshotAndroidRuntime();
1627
+ break;
1628
+ case _DeviceSurface.desktop:
1629
+ await widget.controller.screenshotDesktopRuntime();
1630
+ break;
1631
+ case _DeviceSurface.files:
1632
+ break;
1633
+ }
1634
+ }
1635
+
1237
1636
  void _detachImageListener() {
1238
1637
  if (_imageStream != null && _imageListener != null) {
1239
1638
  _imageStream!.removeListener(_imageListener!);
@@ -1339,6 +1738,7 @@ class _InteractiveSurfacePreviewState
1339
1738
  _DeviceSurface.browser => 16 / 10,
1340
1739
  _DeviceSurface.android => 10 / 16,
1341
1740
  _DeviceSurface.desktop => 16 / 10,
1741
+ _DeviceSurface.files => 16 / 10,
1342
1742
  };
1343
1743
 
1344
1744
  return Container(
@@ -1368,6 +1768,81 @@ class _InteractiveSurfacePreviewState
1368
1768
  constraints.maxWidth,
1369
1769
  constraints.maxHeight,
1370
1770
  );
1771
+ final socket = widget.controller.streamSocket;
1772
+ final streamPlatform = widget.streamPlatform;
1773
+ final streamDeviceId = widget.streamDeviceId;
1774
+ final streamKey = _requestedStreamKey;
1775
+ if (socket != null &&
1776
+ streamPlatform != null &&
1777
+ streamPlatform.isNotEmpty &&
1778
+ streamDeviceId != null &&
1779
+ streamDeviceId.isNotEmpty &&
1780
+ streamKey != _streamFailedKey) {
1781
+ final activeStreamKey = streamKey!;
1782
+ return Stack(
1783
+ fit: StackFit.expand,
1784
+ children: <Widget>[
1785
+ Container(color: _bgSecondary),
1786
+ StreamRenderer(
1787
+ socket: socket,
1788
+ deviceId: streamDeviceId,
1789
+ platform: streamPlatform,
1790
+ remoteResolution: _pixelSize,
1791
+ onFirstFrame: () =>
1792
+ _handleStreamFirstFrame(activeStreamKey),
1793
+ onFrameTimeout: () =>
1794
+ _handleStreamFrameTimeout(activeStreamKey),
1795
+ onTap: widget.busy
1796
+ ? null
1797
+ : (x, y) => unawaited(
1798
+ widget.onTapPoint(Offset(x, y)),
1799
+ ),
1800
+ onSwipe: widget.busy
1801
+ ? null
1802
+ : (x1, y1, x2, y2) => unawaited(
1803
+ widget.onSwipe(
1804
+ Offset(x1, y1),
1805
+ Offset(x2, y2),
1806
+ ),
1807
+ ),
1808
+ onHover: widget.busy
1809
+ ? null
1810
+ : (x, y) => widget.onHover?.call(Offset(x, y)),
1811
+ ),
1812
+ const Positioned(
1813
+ top: 8,
1814
+ right: 8,
1815
+ child: Opacity(
1816
+ opacity: 0.45,
1817
+ child: _MutedBadge(),
1818
+ ),
1819
+ ),
1820
+ Positioned(
1821
+ left: 12,
1822
+ right: 12,
1823
+ bottom: 12,
1824
+ child: Container(
1825
+ padding: const EdgeInsets.symmetric(
1826
+ horizontal: 12,
1827
+ vertical: 10,
1828
+ ),
1829
+ decoration: BoxDecoration(
1830
+ color: const Color(0xB205080D),
1831
+ borderRadius: BorderRadius.circular(14),
1832
+ border: Border.all(color: _borderLight),
1833
+ ),
1834
+ child: Text(
1835
+ widget.surface.helper,
1836
+ textAlign: TextAlign.center,
1837
+ style: TextStyle(color: _textPrimary),
1838
+ ),
1839
+ ),
1840
+ ),
1841
+ if (widget.busy)
1842
+ const Center(child: CircularProgressIndicator()),
1843
+ ],
1844
+ );
1845
+ }
1371
1846
  if (!hasImage) {
1372
1847
  return _EmptySurfaceState(
1373
1848
  surface: widget.surface,
@@ -1393,84 +1868,96 @@ class _InteractiveSurfacePreviewState
1393
1868
  }
1394
1869
  return Semantics(
1395
1870
  button: true,
1396
- label: 'Device surface preview — tap to interact, swipe to scroll',
1871
+ label:
1872
+ 'Device surface preview — tap to interact, swipe to scroll',
1397
1873
  child: GestureDetector(
1398
- onTapUp: widget.busy
1399
- ? null
1400
- : (details) async {
1401
- final point = _mapToPixels(
1402
- details.localPosition,
1403
- boxSize,
1404
- );
1405
- if (point != null) {
1406
- await widget.onTapPoint(point);
1407
- }
1408
- },
1409
- onPanStart: widget.busy
1410
- ? null
1411
- : (details) {
1412
- _dragStart = details.localPosition;
1413
- _dragEnd = details.localPosition;
1414
- },
1415
- onPanUpdate: widget.busy
1416
- ? null
1417
- : (details) {
1418
- _dragEnd = details.localPosition;
1419
- },
1420
- onPanEnd: widget.busy
1421
- ? null
1422
- : (_) async {
1423
- final start = _dragStart;
1424
- final end = _dragEnd;
1425
- _dragStart = null;
1426
- _dragEnd = null;
1427
- if (start == null || end == null) {
1428
- return;
1429
- }
1430
- if ((start - end).distance < 12) {
1431
- return;
1432
- }
1433
- final mappedStart = _mapToPixels(start, boxSize);
1434
- final mappedEnd = _mapToPixels(end, boxSize);
1435
- if (mappedStart != null && mappedEnd != null) {
1436
- await widget.onSwipe(mappedStart, mappedEnd);
1437
- }
1438
- },
1439
- child: Stack(
1440
- fit: StackFit.expand,
1441
- children: <Widget>[
1442
- Container(color: _bgSecondary),
1443
- Image.memory(
1444
- imageBytes,
1445
- fit: BoxFit.contain,
1446
- gaplessPlayback: true,
1447
- ),
1448
- Positioned(
1449
- left: 12,
1450
- right: 12,
1451
- bottom: 12,
1452
- child: Container(
1453
- padding: const EdgeInsets.symmetric(
1454
- horizontal: 12,
1455
- vertical: 10,
1456
- ),
1457
- decoration: BoxDecoration(
1458
- color: const Color(0xB205080D),
1459
- borderRadius: BorderRadius.circular(14),
1460
- border: Border.all(color: _borderLight),
1874
+ onTapUp: widget.busy
1875
+ ? null
1876
+ : (details) async {
1877
+ final point = _mapToPixels(
1878
+ details.localPosition,
1879
+ boxSize,
1880
+ );
1881
+ if (point != null) {
1882
+ await widget.onTapPoint(point);
1883
+ }
1884
+ },
1885
+ onPanStart: widget.busy
1886
+ ? null
1887
+ : (details) {
1888
+ _dragStart = details.localPosition;
1889
+ _dragEnd = details.localPosition;
1890
+ },
1891
+ onPanUpdate: widget.busy
1892
+ ? null
1893
+ : (details) {
1894
+ _dragEnd = details.localPosition;
1895
+ },
1896
+ onPanEnd: widget.busy
1897
+ ? null
1898
+ : (_) async {
1899
+ final start = _dragStart;
1900
+ final end = _dragEnd;
1901
+ _dragStart = null;
1902
+ _dragEnd = null;
1903
+ if (start == null || end == null) {
1904
+ return;
1905
+ }
1906
+ if ((start - end).distance < 12) {
1907
+ return;
1908
+ }
1909
+ final mappedStart = _mapToPixels(
1910
+ start,
1911
+ boxSize,
1912
+ );
1913
+ final mappedEnd = _mapToPixels(end, boxSize);
1914
+ if (mappedStart != null && mappedEnd != null) {
1915
+ await widget.onSwipe(mappedStart, mappedEnd);
1916
+ }
1917
+ },
1918
+ child: Stack(
1919
+ fit: StackFit.expand,
1920
+ children: <Widget>[
1921
+ Container(color: _bgSecondary),
1922
+ Image.memory(
1923
+ imageBytes,
1924
+ fit: BoxFit.contain,
1925
+ gaplessPlayback: true,
1926
+ ),
1927
+ const Positioned(
1928
+ top: 8,
1929
+ right: 8,
1930
+ child: Opacity(
1931
+ opacity: 0.45,
1932
+ child: _MutedBadge(),
1461
1933
  ),
1462
- child: Text(
1463
- widget.surface.helper,
1464
- textAlign: TextAlign.center,
1465
- style: TextStyle(color: _textPrimary),
1934
+ ),
1935
+ Positioned(
1936
+ left: 12,
1937
+ right: 12,
1938
+ bottom: 12,
1939
+ child: Container(
1940
+ padding: const EdgeInsets.symmetric(
1941
+ horizontal: 12,
1942
+ vertical: 10,
1943
+ ),
1944
+ decoration: BoxDecoration(
1945
+ color: const Color(0xB205080D),
1946
+ borderRadius: BorderRadius.circular(14),
1947
+ border: Border.all(color: _borderLight),
1948
+ ),
1949
+ child: Text(
1950
+ widget.surface.helper,
1951
+ textAlign: TextAlign.center,
1952
+ style: TextStyle(color: _textPrimary),
1953
+ ),
1466
1954
  ),
1467
1955
  ),
1468
- ),
1469
- if (widget.busy)
1470
- const Center(child: CircularProgressIndicator()),
1471
- ],
1956
+ if (widget.busy)
1957
+ const Center(child: CircularProgressIndicator()),
1958
+ ],
1959
+ ),
1472
1960
  ),
1473
- ),
1474
1961
  );
1475
1962
  },
1476
1963
  ),
@@ -1517,6 +2004,7 @@ class _EmptySurfaceState extends StatelessWidget {
1517
2004
  connectRequired
1518
2005
  ? 'Select Desktop'
1519
2006
  : (busy ? 'Refreshing Desktop...' : 'Refresh Desktop'),
2007
+ _DeviceSurface.files => busy ? 'Loading Files...' : 'Refresh Files',
1520
2008
  };
1521
2009
  final message = switch ((surface, busy, isLoadingPreview)) {
1522
2010
  (_DeviceSurface.browser, true, _) =>
@@ -1531,6 +2019,8 @@ class _EmptySurfaceState extends StatelessWidget {
1531
2019
  'Waking the phone and downloading the first preview. This can take a little while.',
1532
2020
  (_DeviceSurface.android, false, true) =>
1533
2021
  'Downloading the latest phone preview...',
2022
+ (_DeviceSurface.files, true, _) => 'Loading workspace files...',
2023
+ (_DeviceSurface.files, false, true) => 'Loading workspace files...',
1534
2024
  _ =>
1535
2025
  surface == _DeviceSurface.browser
1536
2026
  ? connectRequired
@@ -1592,9 +2082,6 @@ class _EmptySurfaceState extends StatelessWidget {
1592
2082
  }
1593
2083
  }
1594
2084
 
1595
-
1596
-
1597
-
1598
2085
  class _DeviceFieldRow extends StatelessWidget {
1599
2086
  const _DeviceFieldRow({required this.children});
1600
2087
 
@@ -1741,7 +2228,9 @@ class _ExtensionStatusBar extends StatelessWidget {
1741
2228
  child: Row(
1742
2229
  children: <Widget>[
1743
2230
  _DotStatus(
1744
- label: connected ? 'Extension connected' : 'Extension not connected',
2231
+ label: connected
2232
+ ? 'Extension connected'
2233
+ : 'Extension not connected',
1745
2234
  color: connected ? _success : _warning,
1746
2235
  ),
1747
2236
  const Spacer(),
@@ -1770,6 +2259,246 @@ class _ExtensionStatusBar extends StatelessWidget {
1770
2259
  }
1771
2260
  }
1772
2261
 
2262
+ class _WorkspaceExplorer extends StatefulWidget {
2263
+ const _WorkspaceExplorer({
2264
+ required this.controller,
2265
+ required this.editorController,
2266
+ });
2267
+
2268
+ final NeoAgentController controller;
2269
+ final TextEditingController editorController;
2270
+
2271
+ @override
2272
+ State<_WorkspaceExplorer> createState() => _WorkspaceExplorerState();
2273
+ }
2274
+
2275
+ class _WorkspaceExplorerState extends State<_WorkspaceExplorer> {
2276
+ String? _syncedPath;
2277
+
2278
+ @override
2279
+ void initState() {
2280
+ super.initState();
2281
+ WidgetsBinding.instance.addPostFrameCallback((_) {
2282
+ if (!mounted || widget.controller.workspaceEntries.isNotEmpty) {
2283
+ return;
2284
+ }
2285
+ unawaited(widget.controller.refreshWorkspaceFiles());
2286
+ });
2287
+ }
2288
+
2289
+ @override
2290
+ void didUpdateWidget(covariant _WorkspaceExplorer oldWidget) {
2291
+ super.didUpdateWidget(oldWidget);
2292
+ final selectedPath = widget.controller.workspaceSelectedFilePath;
2293
+ if (selectedPath != _syncedPath) {
2294
+ _syncedPath = selectedPath;
2295
+ widget.editorController.text = widget.controller.workspaceEditorContent;
2296
+ }
2297
+ }
2298
+
2299
+ Future<void> _openEntry(Map<String, dynamic> entry) async {
2300
+ final path = entry['path']?.toString() ?? '';
2301
+ if (path.isEmpty && entry['name']?.toString() != '') {
2302
+ return;
2303
+ }
2304
+ if (entry['type']?.toString() == 'directory') {
2305
+ await widget.controller.openWorkspaceDirectory(path);
2306
+ return;
2307
+ }
2308
+ await widget.controller.openWorkspaceFile(path);
2309
+ }
2310
+
2311
+ @override
2312
+ Widget build(BuildContext context) {
2313
+ final controller = widget.controller;
2314
+ final entries = controller.workspaceEntries;
2315
+ final currentPath = controller.workspaceCurrentPath;
2316
+ final selectedPath = controller.workspaceSelectedFilePath;
2317
+ final busy =
2318
+ controller.isLoadingWorkspaceFiles || controller.isSavingWorkspaceFile;
2319
+ final canGoUp = currentPath.trim().isNotEmpty;
2320
+ final parentPath = currentPath.contains('/')
2321
+ ? currentPath.substring(0, currentPath.lastIndexOf('/'))
2322
+ : '';
2323
+
2324
+ return Container(
2325
+ width: double.infinity,
2326
+ padding: const EdgeInsets.all(14),
2327
+ decoration: BoxDecoration(
2328
+ color: _bgSecondary,
2329
+ borderRadius: BorderRadius.circular(18),
2330
+ border: Border.all(color: _borderLight),
2331
+ ),
2332
+ child: Column(
2333
+ crossAxisAlignment: CrossAxisAlignment.start,
2334
+ children: <Widget>[
2335
+ Row(
2336
+ children: <Widget>[
2337
+ Expanded(
2338
+ child: Text(
2339
+ currentPath.isEmpty ? '/' : '/$currentPath',
2340
+ maxLines: 1,
2341
+ overflow: TextOverflow.ellipsis,
2342
+ style: TextStyle(
2343
+ color: _textPrimary,
2344
+ fontWeight: FontWeight.w700,
2345
+ ),
2346
+ ),
2347
+ ),
2348
+ IconButton.filledTonal(
2349
+ tooltip: 'Parent folder',
2350
+ onPressed: busy || !canGoUp
2351
+ ? null
2352
+ : () => controller.openWorkspaceDirectory(parentPath),
2353
+ icon: const Icon(Icons.arrow_upward_rounded),
2354
+ ),
2355
+ const SizedBox(width: 8),
2356
+ IconButton.filledTonal(
2357
+ tooltip: 'Refresh',
2358
+ onPressed: busy ? null : controller.refreshWorkspaceFiles,
2359
+ icon: const Icon(Icons.refresh_rounded),
2360
+ ),
2361
+ ],
2362
+ ),
2363
+ const SizedBox(height: 12),
2364
+ Container(
2365
+ constraints: const BoxConstraints(maxHeight: 260),
2366
+ decoration: BoxDecoration(
2367
+ color: _bgCard,
2368
+ borderRadius: BorderRadius.circular(14),
2369
+ border: Border.all(color: _borderLight),
2370
+ ),
2371
+ child: entries.isEmpty
2372
+ ? Center(
2373
+ child: Padding(
2374
+ padding: const EdgeInsets.all(24),
2375
+ child: Text(
2376
+ controller.isLoadingWorkspaceFiles
2377
+ ? 'Loading files...'
2378
+ : 'No files in this folder.',
2379
+ style: TextStyle(color: _textSecondary),
2380
+ ),
2381
+ ),
2382
+ )
2383
+ : ListView.separated(
2384
+ shrinkWrap: true,
2385
+ itemCount: entries.length,
2386
+ separatorBuilder: (_, __) => Divider(
2387
+ height: 1,
2388
+ color: _borderLight,
2389
+ ),
2390
+ itemBuilder: (context, index) {
2391
+ final entry = entries[index];
2392
+ final type = entry['type']?.toString() ?? 'file';
2393
+ final path = entry['path']?.toString() ?? '';
2394
+ final selected = selectedPath == path;
2395
+ return ListTile(
2396
+ dense: true,
2397
+ selected: selected,
2398
+ leading: Icon(
2399
+ type == 'directory'
2400
+ ? Icons.folder_outlined
2401
+ : Icons.insert_drive_file_outlined,
2402
+ color: type == 'directory' ? _accent : _textSecondary,
2403
+ ),
2404
+ title: Text(
2405
+ entry['name']?.toString() ?? path,
2406
+ maxLines: 1,
2407
+ overflow: TextOverflow.ellipsis,
2408
+ ),
2409
+ subtitle: type == 'file'
2410
+ ? Text(_formatWorkspaceBytes(entry['size']))
2411
+ : null,
2412
+ trailing: type == 'file'
2413
+ ? IconButton(
2414
+ tooltip: 'Download',
2415
+ onPressed: busy || path.isEmpty
2416
+ ? null
2417
+ : () => controller.downloadWorkspaceFile(
2418
+ path,
2419
+ ),
2420
+ icon: const Icon(Icons.download_outlined),
2421
+ )
2422
+ : const Icon(Icons.chevron_right_rounded),
2423
+ onTap: busy ? null : () => _openEntry(entry),
2424
+ );
2425
+ },
2426
+ ),
2427
+ ),
2428
+ const SizedBox(height: 14),
2429
+ Row(
2430
+ children: <Widget>[
2431
+ Expanded(
2432
+ child: Text(
2433
+ selectedPath ?? 'No file selected',
2434
+ maxLines: 1,
2435
+ overflow: TextOverflow.ellipsis,
2436
+ style: TextStyle(
2437
+ color: selectedPath == null ? _textSecondary : _textPrimary,
2438
+ fontWeight: FontWeight.w700,
2439
+ ),
2440
+ ),
2441
+ ),
2442
+ OutlinedButton.icon(
2443
+ onPressed: busy || selectedPath == null
2444
+ ? null
2445
+ : () => controller.downloadWorkspaceFile(selectedPath),
2446
+ icon: const Icon(Icons.download_outlined, size: 18),
2447
+ label: const Text('Download'),
2448
+ ),
2449
+ const SizedBox(width: 8),
2450
+ FilledButton.icon(
2451
+ onPressed: busy || selectedPath == null
2452
+ ? null
2453
+ : () => controller.saveWorkspaceFile(
2454
+ widget.editorController.text,
2455
+ ),
2456
+ icon: controller.isSavingWorkspaceFile
2457
+ ? const SizedBox(
2458
+ width: 16,
2459
+ height: 16,
2460
+ child: CircularProgressIndicator(strokeWidth: 2),
2461
+ )
2462
+ : const Icon(Icons.save_outlined, size: 18),
2463
+ label: const Text('Save'),
2464
+ ),
2465
+ ],
2466
+ ),
2467
+ const SizedBox(height: 10),
2468
+ TextField(
2469
+ controller: widget.editorController,
2470
+ enabled: selectedPath != null && !controller.isSavingWorkspaceFile,
2471
+ minLines: 12,
2472
+ maxLines: 18,
2473
+ style: const TextStyle(
2474
+ fontFamily: 'monospace',
2475
+ fontSize: 13,
2476
+ height: 1.35,
2477
+ ),
2478
+ decoration: InputDecoration(
2479
+ hintText: selectedPath == null
2480
+ ? 'Select a file to view or edit it.'
2481
+ : 'File contents',
2482
+ alignLabelWithHint: true,
2483
+ ),
2484
+ ),
2485
+ ],
2486
+ ),
2487
+ );
2488
+ }
2489
+ }
2490
+
2491
+ String _formatWorkspaceBytes(Object? value) {
2492
+ final bytes = value is num ? value.toDouble() : double.tryParse('$value') ?? 0;
2493
+ if (bytes >= 1024 * 1024) {
2494
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
2495
+ }
2496
+ if (bytes >= 1024) {
2497
+ return '${(bytes / 1024).toStringAsFixed(1)} KB';
2498
+ }
2499
+ return '${bytes.round()} B';
2500
+ }
2501
+
1773
2502
  class _ResultBlock extends StatelessWidget {
1774
2503
  const _ResultBlock({required this.label, required this.value});
1775
2504
 
@@ -1794,7 +2523,7 @@ class _ResultBlock extends StatelessWidget {
1794
2523
  SelectableText(
1795
2524
  value,
1796
2525
  style: TextStyle(
1797
- fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
2526
+ fontFamily: GoogleFonts.geistMono().fontFamily,
1798
2527
  fontSize: 12,
1799
2528
  color: _textSecondary,
1800
2529
  height: 1.5,