neoagent 2.3.1-beta.99 → 2.4.1-beta.10

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 (101) hide show
  1. package/.env.example +6 -3
  2. package/LICENSE +619 -21
  3. package/README.md +1 -1
  4. package/extensions/chrome-browser/background.mjs +19 -7
  5. package/extensions/chrome-browser/icons/icon128.png +0 -0
  6. package/extensions/chrome-browser/icons/icon16.png +0 -0
  7. package/extensions/chrome-browser/icons/icon48.png +0 -0
  8. package/extensions/chrome-browser/icons/logo.svg +12 -0
  9. package/extensions/chrome-browser/manifest.json +13 -2
  10. package/extensions/chrome-browser/popup.css +5 -0
  11. package/extensions/chrome-browser/popup.html +7 -5
  12. package/extensions/chrome-browser/popup.js +16 -7
  13. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +391 -0
  14. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +6 -0
  15. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +1 -1
  16. package/flutter_app/lib/main.dart +2 -0
  17. package/flutter_app/lib/main_controller.dart +156 -3
  18. package/flutter_app/lib/main_devices.dart +485 -119
  19. package/flutter_app/lib/main_integrations.dart +21 -2
  20. package/flutter_app/lib/main_models.dart +60 -0
  21. package/flutter_app/lib/main_settings.dart +289 -30
  22. package/flutter_app/lib/main_theme.dart +31 -2
  23. package/flutter_app/lib/src/backend_client.dart +89 -0
  24. package/flutter_app/lib/src/desktop_companion_actions.dart +144 -0
  25. package/flutter_app/lib/src/desktop_companion_io.dart +145 -4
  26. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  27. package/flutter_app/lib/src/stream_renderer.dart +286 -0
  28. package/flutter_app/macos/Runner/AppDelegate.swift +67 -2
  29. package/flutter_app/macos/Runner/DebugProfile.entitlements +4 -0
  30. package/flutter_app/macos/Runner/Release.entitlements +4 -0
  31. package/flutter_app/pubspec.lock +5 -5
  32. package/lib/manager.js +164 -2
  33. package/package.json +2 -2
  34. package/server/db/database.js +85 -0
  35. package/server/guest_agent.js +19 -1
  36. package/server/http/routes.js +191 -0
  37. package/server/http/socket.js +1 -1
  38. package/server/index.js +4 -1
  39. package/server/public/.last_build_id +1 -1
  40. package/server/public/assets/NOTICES +971 -1066
  41. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  42. package/server/public/assets/shaders/ink_sparkle.frag +1 -1
  43. package/server/public/assets/shaders/stretch_effect.frag +1 -1
  44. package/server/public/canvaskit/canvaskit.js +2 -2
  45. package/server/public/canvaskit/canvaskit.js.symbols +11796 -11733
  46. package/server/public/canvaskit/canvaskit.wasm +0 -0
  47. package/server/public/canvaskit/chromium/canvaskit.js +2 -2
  48. package/server/public/canvaskit/chromium/canvaskit.js.symbols +10706 -10643
  49. package/server/public/canvaskit/chromium/canvaskit.wasm +0 -0
  50. package/server/public/canvaskit/experimental_webparagraph/canvaskit.js +171 -0
  51. package/server/public/canvaskit/experimental_webparagraph/canvaskit.js.symbols +9134 -0
  52. package/server/public/canvaskit/experimental_webparagraph/canvaskit.wasm +0 -0
  53. package/server/public/canvaskit/skwasm.js +14 -14
  54. package/server/public/canvaskit/skwasm.js.symbols +12787 -12676
  55. package/server/public/canvaskit/skwasm.wasm +0 -0
  56. package/server/public/canvaskit/skwasm_heavy.js +14 -14
  57. package/server/public/canvaskit/skwasm_heavy.js.symbols +14400 -14286
  58. package/server/public/canvaskit/skwasm_heavy.wasm +0 -0
  59. package/server/public/canvaskit/wimp.js +94 -95
  60. package/server/public/canvaskit/wimp.js.symbols +11325 -11177
  61. package/server/public/canvaskit/wimp.wasm +0 -0
  62. package/server/public/flutter_bootstrap.js +2 -2
  63. package/server/public/main.dart.js +86174 -83144
  64. package/server/routes/browser.js +14 -0
  65. package/server/routes/browser_extension.js +21 -4
  66. package/server/routes/desktop.js +10 -0
  67. package/server/routes/integrations.js +2 -2
  68. package/server/routes/memory.js +73 -0
  69. package/server/routes/settings.js +4 -0
  70. package/server/routes/stream.js +187 -0
  71. package/server/services/ai/engine.js +65 -26
  72. package/server/services/ai/models.js +21 -0
  73. package/server/services/ai/preModelCompaction.js +191 -0
  74. package/server/services/ai/providers/claudeCode.js +273 -0
  75. package/server/services/ai/providers/openaiCodex.js +212 -40
  76. package/server/services/ai/settings.js +12 -2
  77. package/server/services/ai/tools.js +40 -29
  78. package/server/services/android/controller.js +41 -2
  79. package/server/services/browser/controller.js +34 -0
  80. package/server/services/browser/extension/manifest.js +33 -0
  81. package/server/services/browser/extension/provider.js +12 -6
  82. package/server/services/browser/extension/registry.js +188 -18
  83. package/server/services/desktop/gateway.js +28 -3
  84. package/server/services/desktop/protocol.js +34 -0
  85. package/server/services/desktop/provider.js +25 -0
  86. package/server/services/desktop/registry.js +92 -10
  87. package/server/services/integrations/google/provider.js +78 -0
  88. package/server/services/integrations/manager.js +29 -13
  89. package/server/services/manager.js +44 -2
  90. package/server/services/memory/ingestion.js +486 -0
  91. package/server/services/memory/manager.js +422 -0
  92. package/server/services/memory/openhuman_uplift.test.js +98 -0
  93. package/server/services/runtime/backends/local-vm.js +6 -0
  94. package/server/services/runtime/docker-vm-manager.js +26 -3
  95. package/server/services/runtime/manager.js +36 -5
  96. package/server/services/runtime/settings.js +17 -0
  97. package/server/services/streaming/android-stream.js +298 -0
  98. package/server/services/streaming/browser-stream.js +87 -0
  99. package/server/services/streaming/stream-hub.js +231 -0
  100. package/server/services/websocket.js +73 -0
  101. package/server/services/widgets/focus_widget.js +45 -4
@@ -175,6 +175,12 @@ class OfficialIntegrationsTab extends StatelessWidget {
175
175
  label: '${item.availableToolCount} tools',
176
176
  icon: Icons.build_outlined,
177
177
  ),
178
+ _MetaPill(
179
+ label: item.memoryCoverage.supported
180
+ ? 'Memory ${item.memoryCoverage.statusLabel}'
181
+ : 'No memory sync',
182
+ icon: Icons.psychology_alt_outlined,
183
+ ),
178
184
  ],
179
185
  ),
180
186
  const SizedBox(height: 10),
@@ -255,6 +261,7 @@ Future<void> _showTrelloSetupDialog(
255
261
  final apiKeyController = TextEditingController(text: savedApiKey);
256
262
  final tokenInputController = TextEditingController();
257
263
 
264
+ if (!context.mounted) return;
258
265
  await showDialog<void>(
259
266
  context: context,
260
267
  barrierDismissible: false,
@@ -426,8 +433,7 @@ Future<void> _showTrelloSetupDialog(
426
433
  }
427
434
  final url = authorizeUrl.isNotEmpty
428
435
  ? authorizeUrl
429
- : 'https://trello.com/1/authorize?expiration=never&scope=read,write,account&response_type=token&key=' +
430
- Uri.encodeComponent(effectiveApiKey);
436
+ : 'https://trello.com/1/authorize?expiration=never&scope=read,write,account&response_type=token&key=${Uri.encodeComponent(effectiveApiKey)}';
431
437
  final result = await controller._oauthLauncher
432
438
  .openExternal(url: url, label: 'Trello');
433
439
  if (!result.launched) {
@@ -659,6 +665,12 @@ class _OfficialIntegrationAppCard extends StatelessWidget {
659
665
  label: '${app.availableToolCount} tools',
660
666
  icon: Icons.build_circle_outlined,
661
667
  ),
668
+ _MetaPill(
669
+ label: app.memoryCoverage.supported
670
+ ? 'Memory ${app.memoryCoverage.statusLabel}'
671
+ : 'No memory sync',
672
+ icon: Icons.psychology_alt_outlined,
673
+ ),
662
674
  ],
663
675
  ),
664
676
  ],
@@ -715,6 +727,13 @@ class _OfficialIntegrationAppCard extends StatelessWidget {
715
727
  'Access: ${account.accessModeLabel}',
716
728
  style: TextStyle(color: _textSecondary),
717
729
  ),
730
+ if (account.memoryCoverage.supported) ...<Widget>[
731
+ const SizedBox(height: 4),
732
+ Text(
733
+ 'Memory: ${account.memoryCoverage.statusLabel}',
734
+ style: TextStyle(color: _textSecondary),
735
+ ),
736
+ ],
718
737
  const SizedBox(height: 12),
719
738
  Wrap(
720
739
  spacing: 8,
@@ -2565,6 +2565,7 @@ class OfficialIntegrationAppItem {
2565
2565
  ),
2566
2566
  this.accounts = const <OfficialIntegrationAccountItem>[],
2567
2567
  this.availableToolCount = 0,
2568
+ this.memoryCoverage = const OfficialIntegrationMemoryCoverage(),
2568
2569
  });
2569
2570
 
2570
2571
  factory OfficialIntegrationAppItem.fromJson(Map<dynamic, dynamic> json) {
@@ -2583,6 +2584,9 @@ class OfficialIntegrationAppItem {
2583
2584
  .toList()
2584
2585
  : const <OfficialIntegrationAccountItem>[],
2585
2586
  availableToolCount: _asInt(json['availableToolCount']),
2587
+ memoryCoverage: OfficialIntegrationMemoryCoverage.fromJson(
2588
+ _jsonMap(json['memoryCoverage']),
2589
+ ),
2586
2590
  );
2587
2591
  }
2588
2592
 
@@ -2592,6 +2596,7 @@ class OfficialIntegrationAppItem {
2592
2596
  final OfficialIntegrationConnectionStatus connection;
2593
2597
  final List<OfficialIntegrationAccountItem> accounts;
2594
2598
  final int availableToolCount;
2599
+ final OfficialIntegrationMemoryCoverage memoryCoverage;
2595
2600
 
2596
2601
  bool get isConnected => connection.connected;
2597
2602
 
@@ -2676,6 +2681,51 @@ class OfficialIntegrationConnectionStatus {
2676
2681
  }
2677
2682
  }
2678
2683
 
2684
+ class OfficialIntegrationMemoryCoverage {
2685
+ const OfficialIntegrationMemoryCoverage({
2686
+ this.supported = false,
2687
+ this.contributesToMemory = false,
2688
+ this.contributesToTaskExecution = false,
2689
+ this.status = 'not_supported',
2690
+ this.dataDomains = const <String>[],
2691
+ this.documentCount = 0,
2692
+ this.lastRefreshAt,
2693
+ this.nextRefreshAt,
2694
+ this.error,
2695
+ });
2696
+
2697
+ factory OfficialIntegrationMemoryCoverage.fromJson(
2698
+ Map<dynamic, dynamic> json,
2699
+ ) {
2700
+ final domainsRaw = json['dataDomains'];
2701
+ return OfficialIntegrationMemoryCoverage(
2702
+ supported: json['supported'] == true,
2703
+ contributesToMemory: json['contributesToMemory'] == true,
2704
+ contributesToTaskExecution: json['contributesToTaskExecution'] == true,
2705
+ status: json['status']?.toString() ?? 'not_supported',
2706
+ dataDomains: domainsRaw is List
2707
+ ? domainsRaw.map((item) => item.toString()).toList()
2708
+ : const <String>[],
2709
+ documentCount: _asInt(json['documentCount']),
2710
+ lastRefreshAt: _parseOptionalTimestamp(json['lastRefreshAt']?.toString()),
2711
+ nextRefreshAt: _parseOptionalTimestamp(json['nextRefreshAt']?.toString()),
2712
+ error: json['error']?.toString(),
2713
+ );
2714
+ }
2715
+
2716
+ final bool supported;
2717
+ final bool contributesToMemory;
2718
+ final bool contributesToTaskExecution;
2719
+ final String status;
2720
+ final List<String> dataDomains;
2721
+ final int documentCount;
2722
+ final DateTime? lastRefreshAt;
2723
+ final DateTime? nextRefreshAt;
2724
+ final String? error;
2725
+
2726
+ String get statusLabel => _titleCase(status.replaceAll('_', ' '));
2727
+ }
2728
+
2679
2729
  class OfficialIntegrationAccountItem {
2680
2730
  const OfficialIntegrationAccountItem({
2681
2731
  required this.id,
@@ -2684,6 +2734,7 @@ class OfficialIntegrationAccountItem {
2684
2734
  this.accountEmail,
2685
2735
  this.lastConnectedAt,
2686
2736
  this.accessMode = 'read_write',
2737
+ this.memoryCoverage = const OfficialIntegrationMemoryCoverage(),
2687
2738
  });
2688
2739
 
2689
2740
  factory OfficialIntegrationAccountItem.fromJson(Map<dynamic, dynamic> json) {
@@ -2696,6 +2747,9 @@ class OfficialIntegrationAccountItem {
2696
2747
  json['lastConnectedAt']?.toString(),
2697
2748
  ),
2698
2749
  accessMode: json['accessMode']?.toString() ?? 'read_write',
2750
+ memoryCoverage: OfficialIntegrationMemoryCoverage.fromJson(
2751
+ _jsonMap(json['memoryCoverage']),
2752
+ ),
2699
2753
  );
2700
2754
  }
2701
2755
 
@@ -2705,6 +2759,7 @@ class OfficialIntegrationAccountItem {
2705
2759
  final String? accountEmail;
2706
2760
  final DateTime? lastConnectedAt;
2707
2761
  final String accessMode;
2762
+ final OfficialIntegrationMemoryCoverage memoryCoverage;
2708
2763
 
2709
2764
  bool get isExpired => status == 'expired';
2710
2765
 
@@ -2733,6 +2788,7 @@ class OfficialIntegrationItem {
2733
2788
  this.connectPrompt,
2734
2789
  this.supportsMultipleAccounts = true,
2735
2790
  this.connectionMethod = 'oauth',
2791
+ this.memoryCoverage = const OfficialIntegrationMemoryCoverage(),
2736
2792
  });
2737
2793
 
2738
2794
  factory OfficialIntegrationItem.fromJson(Map<dynamic, dynamic> json) {
@@ -2756,6 +2812,9 @@ class OfficialIntegrationItem {
2756
2812
  connectPrompt: json['connectPrompt']?.toString(),
2757
2813
  supportsMultipleAccounts: json['supportsMultipleAccounts'] != false,
2758
2814
  connectionMethod: json['connectionMethod']?.toString() ?? 'oauth',
2815
+ memoryCoverage: OfficialIntegrationMemoryCoverage.fromJson(
2816
+ _jsonMap(json['memoryCoverage']),
2817
+ ),
2759
2818
  );
2760
2819
  }
2761
2820
 
@@ -2770,6 +2829,7 @@ class OfficialIntegrationItem {
2770
2829
  final String? connectPrompt;
2771
2830
  final bool supportsMultipleAccounts;
2772
2831
  final String connectionMethod;
2832
+ final OfficialIntegrationMemoryCoverage memoryCoverage;
2773
2833
 
2774
2834
  bool get isConnected => connection.connected;
2775
2835
 
@@ -170,7 +170,9 @@ const List<_SettingsSection> _settingsSearchSections = <_SettingsSection>[
170
170
  class _SettingsPanelState extends State<SettingsPanel> {
171
171
  late final TextEditingController _searchController;
172
172
  late String _browserBackend;
173
+ String? _browserExtensionTokenId;
173
174
  late String _cliBackend;
175
+ String? _cliDesktopDeviceId;
174
176
  late bool _smarterSelector;
175
177
  late Set<String> _enabledModels;
176
178
  late String _defaultChatModel;
@@ -187,6 +189,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
187
189
  <String, TextEditingController>{};
188
190
  final Set<String> _expandedProviderIds = <String>{};
189
191
 
192
+ // Inline runtime test state — ephemeral, not stored in controller.
193
+ bool _cliTestRunning = false;
194
+ Map<String, dynamic>? _cliTestResult;
195
+ bool _extensionTestRunning = false;
196
+ Map<String, dynamic>? _extensionTestResult;
197
+ bool _desktopTestRunning = false;
198
+ Map<String, dynamic>? _desktopTestResult;
199
+
190
200
  @override
191
201
  void initState() {
192
202
  super.initState();
@@ -224,7 +234,11 @@ class _SettingsPanelState extends State<SettingsPanel> {
224
234
  .map((model) => model.id)
225
235
  .toSet();
226
236
  _browserBackend = _normalizeBrowserBackend(controller.browserBackend);
237
+ _browserExtensionTokenId =
238
+ controller.browserExtensionTokenId ??
239
+ controller.selectedBrowserExtensionTokenId;
227
240
  _cliBackend = _normalizeCliBackend(controller.cliBackend);
241
+ _cliDesktopDeviceId = controller.cliDesktopDeviceId;
228
242
  _smarterSelector = controller.smarterSelector;
229
243
  _enabledModels = controller.enabledModelIds
230
244
  .where((id) => knownModels.contains(id))
@@ -466,7 +480,11 @@ class _SettingsPanelState extends State<SettingsPanel> {
466
480
  browserBackend: _browserBackend == 'extension'
467
481
  ? 'extension'
468
482
  : 'vm',
483
+ browserExtensionTokenId: _browserBackend == 'extension'
484
+ ? _browserExtensionTokenId
485
+ : null,
469
486
  cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
487
+ cliDesktopDeviceId: _cliDesktopDeviceId,
470
488
  smarterSelector: _smarterSelector,
471
489
  enabledModels: _enabledModels.toList(),
472
490
  defaultChatModel: _defaultChatModel,
@@ -631,36 +649,107 @@ class _SettingsPanelState extends State<SettingsPanel> {
631
649
  ],
632
650
  onChanged: (value) {
633
651
  if (value != null) {
634
- setState(() => _browserBackend = value);
652
+ setState(() {
653
+ _browserBackend = value;
654
+ _browserExtensionTokenId ??=
655
+ controller.selectedBrowserExtensionTokenId;
656
+ });
635
657
  }
636
658
  },
637
659
  ),
638
660
  const SizedBox(height: 10),
639
- Text(
640
- _browserBackend == 'extension'
641
- ? (controller.browserExtensionConnected
642
- ? 'Chrome extension connected.'
643
- : 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
644
- : 'Cloud browser runtime is active.',
645
- style: TextStyle(color: _textSecondary, height: 1.4),
646
- ),
647
- const SizedBox(height: 10),
648
- Wrap(
649
- spacing: 10,
650
- runSpacing: 10,
651
- children: <Widget>[
652
- OutlinedButton.icon(
653
- onPressed: controller.downloadBrowserExtension,
654
- icon: Icon(Icons.download_outlined),
655
- label: Text('Download extension'),
656
- ),
657
- OutlinedButton.icon(
658
- onPressed: controller.refreshBrowserExtensionStatus,
659
- icon: Icon(Icons.sync),
660
- label: Text('Refresh status'),
661
+ if (_browserBackend == 'extension') ...<Widget>[
662
+ if (controller.browserExtensionTokens.isNotEmpty) ...<Widget>[
663
+ DropdownButtonFormField<String>(
664
+ initialValue: controller.browserExtensionTokens.any(
665
+ (token) => token['tokenId']?.toString() == _browserExtensionTokenId,
666
+ )
667
+ ? _browserExtensionTokenId
668
+ : null,
669
+ decoration: const InputDecoration(
670
+ labelText: 'Default extension',
671
+ helperText: 'Choose which paired Chrome extension controls browser actions.',
672
+ ),
673
+ items: controller.browserExtensionTokens.map((token) {
674
+ final tokenId = token['tokenId']?.toString() ?? '';
675
+ final label = token['name']?.toString().trim().isNotEmpty == true
676
+ ? token['name'].toString()
677
+ : tokenId;
678
+ final online = token['online'] == true || token['connected'] == true;
679
+ return DropdownMenuItem<String>(
680
+ value: tokenId,
681
+ child: Row(
682
+ children: <Widget>[
683
+ Icon(
684
+ online ? Icons.circle : Icons.circle_outlined,
685
+ size: 10,
686
+ color: online ? Colors.green : Colors.grey,
687
+ ),
688
+ const SizedBox(width: 8),
689
+ Flexible(
690
+ child: Text(
691
+ label,
692
+ maxLines: 1,
693
+ overflow: TextOverflow.ellipsis,
694
+ ),
695
+ ),
696
+ ],
697
+ ),
698
+ );
699
+ }).toList(),
700
+ onChanged: (value) {
701
+ if (value != null) {
702
+ setState(() => _browserExtensionTokenId = value);
703
+ }
704
+ },
661
705
  ),
706
+ const SizedBox(height: 10),
662
707
  ],
663
- ),
708
+ _buildInlineTestRow(
709
+ label: 'Chrome extension',
710
+ running: _extensionTestRunning,
711
+ result: _extensionTestResult,
712
+ note: controller.browserExtensionConnected
713
+ ? 'Connected — tap Test to verify the live link.'
714
+ : 'Not connected — download the extension, load it in Chrome, then pair after login.',
715
+ onTest: () async {
716
+ setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
717
+ try {
718
+ final r = await controller.testBrowserExtension();
719
+ if (mounted) {
720
+ setState(() => _extensionTestResult = r);
721
+ }
722
+ } catch (e) {
723
+ if (mounted) {
724
+ setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
725
+ }
726
+ } finally {
727
+ if (mounted) {
728
+ setState(() => _extensionTestRunning = false);
729
+ }
730
+ }
731
+ },
732
+ ),
733
+ const SizedBox(height: 10),
734
+ Wrap(
735
+ spacing: 10,
736
+ runSpacing: 10,
737
+ children: <Widget>[
738
+ OutlinedButton.icon(
739
+ onPressed: controller.downloadBrowserExtension,
740
+ icon: Icon(Icons.download_outlined),
741
+ label: Text('Download extension'),
742
+ ),
743
+ OutlinedButton.icon(
744
+ onPressed: controller.refreshBrowserExtensionStatus,
745
+ icon: Icon(Icons.sync),
746
+ label: Text('Refresh'),
747
+ ),
748
+ ],
749
+ ),
750
+ ] else ...<Widget>[
751
+ Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
752
+ ],
664
753
  const Divider(height: 32),
665
754
  Text(
666
755
  'CLI Runtime',
@@ -691,14 +780,73 @@ class _SettingsPanelState extends State<SettingsPanel> {
691
780
  }
692
781
  },
693
782
  ),
783
+ if (_cliBackend == 'desktop' && controller.desktopDevices.length > 1) ...<Widget>[
784
+ const SizedBox(height: 12),
785
+ DropdownButtonFormField<String>(
786
+ initialValue: controller.desktopDevices.any(
787
+ (d) => d['deviceId']?.toString() == _cliDesktopDeviceId,
788
+ )
789
+ ? _cliDesktopDeviceId
790
+ : null,
791
+ decoration: const InputDecoration(
792
+ labelText: 'Desktop device',
793
+ helperText: 'Choose which desktop companion runs CLI commands.',
794
+ ),
795
+ items: controller.desktopDevices.map((device) {
796
+ final deviceId = device['deviceId']?.toString() ?? '';
797
+ final label = device['hostname']?.toString().isNotEmpty == true
798
+ ? device['hostname']!.toString()
799
+ : deviceId;
800
+ final online = device['online'] == true;
801
+ return DropdownMenuItem<String>(
802
+ value: deviceId,
803
+ child: Row(
804
+ children: <Widget>[
805
+ Icon(
806
+ online ? Icons.circle : Icons.circle_outlined,
807
+ size: 10,
808
+ color: online ? Colors.green : Colors.grey,
809
+ ),
810
+ const SizedBox(width: 8),
811
+ Text(label),
812
+ ],
813
+ ),
814
+ );
815
+ }).toList(),
816
+ onChanged: (value) {
817
+ if (value != null) {
818
+ setState(() => _cliDesktopDeviceId = value);
819
+ }
820
+ },
821
+ ),
822
+ ],
694
823
  const SizedBox(height: 10),
695
- Text(
696
- _cliBackend == 'desktop'
824
+ _buildInlineTestRow(
825
+ label: 'CLI',
826
+ running: _cliTestRunning,
827
+ result: _cliTestResult,
828
+ note: _cliBackend == 'desktop'
697
829
  ? (controller.desktopCompanionConnected
698
- ? 'Desktop app connected.'
699
- : 'Desktop app selected. Make sure the desktop companion is running and connected on your machine.')
700
- : 'Cloud CLI runtime is active.',
701
- style: TextStyle(color: _textSecondary, height: 1.4),
830
+ ? 'Desktop app connected — commands route locally through the companion.'
831
+ : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
832
+ : 'Cloud VM commands run in an isolated container.',
833
+ onTest: () async {
834
+ setState(() { _cliTestRunning = true; _cliTestResult = null; });
835
+ try {
836
+ final r = await controller.testCliRuntime();
837
+ if (mounted) {
838
+ setState(() => _cliTestResult = r);
839
+ }
840
+ } catch (e) {
841
+ if (mounted) {
842
+ setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
843
+ }
844
+ } finally {
845
+ if (mounted) {
846
+ setState(() => _cliTestRunning = false);
847
+ }
848
+ }
849
+ },
702
850
  ),
703
851
  const Divider(height: 32),
704
852
  Text(
@@ -1344,6 +1492,53 @@ class _SettingsPanelState extends State<SettingsPanel> {
1344
1492
  const SizedBox(height: 12),
1345
1493
  _InlineError(message: message),
1346
1494
  ],
1495
+ const SizedBox(height: 12),
1496
+ _buildInlineTestRow(
1497
+ label: 'Desktop companion',
1498
+ running: _desktopTestRunning,
1499
+ result: _desktopTestResult != null
1500
+ ? <String, dynamic>{
1501
+ 'passed': _desktopTestResult!['passed'] == true,
1502
+ 'detail': _desktopTestResult!['detail']?.toString() ?? '',
1503
+ }
1504
+ : null,
1505
+ note: controller.desktopCompanionConnected
1506
+ ? 'Connected — tap Test to fetch live device status from the server.'
1507
+ : 'Not connected. Make sure the desktop app is running on the target machine.',
1508
+ onTest: () async {
1509
+ setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
1510
+ try {
1511
+ final r = await controller.testDesktopCompanion();
1512
+ final active = r['activeDevice'];
1513
+ final multi = r['multipleOnline'] == true;
1514
+ String detail = r['detail']?.toString() ?? '';
1515
+ if (r['passed'] == true && active != null) {
1516
+ final label = active['label']?.toString() ?? 'Device';
1517
+ final plat = active['platform']?.toString() ?? '';
1518
+ final sc = active['permissions']?['screenCapture'] == true;
1519
+ final ic = active['permissions']?['inputControl'] == true;
1520
+ detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
1521
+ ' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
1522
+ } else if (multi) {
1523
+ detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
1524
+ }
1525
+ if (mounted) {
1526
+ setState(() => _desktopTestResult = <String, dynamic>{
1527
+ ...r,
1528
+ 'detail': detail,
1529
+ });
1530
+ }
1531
+ } catch (e) {
1532
+ if (mounted) {
1533
+ setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1534
+ }
1535
+ } finally {
1536
+ if (mounted) {
1537
+ setState(() => _desktopTestRunning = false);
1538
+ }
1539
+ }
1540
+ },
1541
+ ),
1347
1542
  const SizedBox(height: 14),
1348
1543
  Builder(
1349
1544
  builder: (context) {
@@ -1999,6 +2194,70 @@ class _SettingsPanelState extends State<SettingsPanel> {
1999
2194
  )
2000
2195
  .toList();
2001
2196
  }
2197
+
2198
+ // Shared helper: small "Test" button + inline result row.
2199
+ Widget _buildInlineTestRow({
2200
+ required String label,
2201
+ required bool running,
2202
+ required Map<String, dynamic>? result,
2203
+ required VoidCallback onTest,
2204
+ String? note,
2205
+ }) {
2206
+ final passed = result?['passed'] == true;
2207
+ final detail = result?['detail']?.toString() ?? '';
2208
+ return Row(
2209
+ crossAxisAlignment: CrossAxisAlignment.start,
2210
+ children: <Widget>[
2211
+ Expanded(
2212
+ child: Column(
2213
+ crossAxisAlignment: CrossAxisAlignment.start,
2214
+ children: <Widget>[
2215
+ if (result != null)
2216
+ Row(
2217
+ children: <Widget>[
2218
+ Icon(
2219
+ passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
2220
+ size: 15,
2221
+ color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
2222
+ ),
2223
+ const SizedBox(width: 6),
2224
+ Expanded(
2225
+ child: Text(
2226
+ passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
2227
+ style: TextStyle(
2228
+ fontSize: 13,
2229
+ color: passed ? null : const Color(0xFFEF4444),
2230
+ ),
2231
+ ),
2232
+ ),
2233
+ ],
2234
+ )
2235
+ else if (note != null)
2236
+ Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
2237
+ ],
2238
+ ),
2239
+ ),
2240
+ const SizedBox(width: 12),
2241
+ SizedBox(
2242
+ width: 80,
2243
+ child: OutlinedButton(
2244
+ onPressed: running ? null : onTest,
2245
+ style: OutlinedButton.styleFrom(
2246
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
2247
+ textStyle: const TextStyle(fontSize: 12),
2248
+ ),
2249
+ child: running
2250
+ ? const SizedBox(
2251
+ width: 13,
2252
+ height: 13,
2253
+ child: CircularProgressIndicator(strokeWidth: 2),
2254
+ )
2255
+ : const Text('Test'),
2256
+ ),
2257
+ ),
2258
+ ],
2259
+ );
2260
+ }
2002
2261
  }
2003
2262
 
2004
2263
  class _AiProviderCard extends StatelessWidget {
@@ -224,8 +224,8 @@ ThemeData _buildNeoAgentTheme(NeoAgentPalette palette, Brightness brightness) {
224
224
  pageTransitionsTheme: const PageTransitionsTheme(
225
225
  builders: <TargetPlatform, PageTransitionsBuilder>{
226
226
  TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
227
- TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
228
- TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
227
+ TargetPlatform.iOS: _NeoAgentCupertinoPageTransitionsBuilder(),
228
+ TargetPlatform.macOS: _NeoAgentCupertinoPageTransitionsBuilder(),
229
229
  TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(),
230
230
  TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(),
231
231
  },
@@ -274,3 +274,32 @@ ThemeData _buildNeoAgentTheme(NeoAgentPalette palette, Brightness brightness) {
274
274
  ),
275
275
  );
276
276
  }
277
+
278
+ class _NeoAgentCupertinoPageTransitionsBuilder extends PageTransitionsBuilder {
279
+ const _NeoAgentCupertinoPageTransitionsBuilder();
280
+
281
+ @override
282
+ Duration get transitionDuration =>
283
+ cupertino.CupertinoRouteTransitionMixin.kTransitionDuration;
284
+
285
+ @override
286
+ DelegatedTransitionBuilder? get delegatedTransition =>
287
+ cupertino.CupertinoPageTransition.delegatedTransition;
288
+
289
+ @override
290
+ Widget buildTransitions<T>(
291
+ PageRoute<T> route,
292
+ BuildContext context,
293
+ Animation<double> animation,
294
+ Animation<double> secondaryAnimation,
295
+ Widget child,
296
+ ) {
297
+ return cupertino.CupertinoRouteTransitionMixin.buildPageTransitions<T>(
298
+ route,
299
+ context,
300
+ animation,
301
+ secondaryAnimation,
302
+ child,
303
+ );
304
+ }
305
+ }