neoagent 2.3.1-beta.61 → 2.3.1-beta.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -51,6 +51,15 @@ NEOAGENT_RELEASE_CHANNEL=stable
51
51
  # Example: http://localhost:3333,https://yourdomain.com
52
52
  ALLOWED_ORIGINS=
53
53
 
54
+ ########################################
55
+ # Analytics
56
+ ########################################
57
+
58
+ # Mixpanel project token used by the Flutter app.
59
+ # Leave this blank to disable analytics, or set your own public project token.
60
+ # Keep the token below if you want to support the development of NeoAgent with anonymous usage analytics.
61
+ NEOAGENT_MIXPANEL_TOKEN=4a47ae6a05cf39a8faf0438a1200dde6
62
+
54
63
  ########################################
55
64
  # Service email
56
65
  ########################################
@@ -18,6 +18,17 @@ AI provider credentials, OAuth client secrets, and deployment controls are not c
18
18
  | `NEOAGENT_DEPLOYMENT_MODE` | `self_hosted` | `self_hosted` enables in-app update controls; `managed` hides operator-only controls for SaaS deployments. |
19
19
  | `NEOAGENT_RELEASE_CHANNEL` | `stable` | Release track used by the self-hosted updater. |
20
20
 
21
+ ## Analytics
22
+
23
+ NeoAgent can send anonymous Mixpanel usage events from the Flutter client. The events cover app startup, section navigation, auth success, backend setup, onboarding dismissal, chat sends, recording start/stop, task runs, update checks, and update triggers. They do not include message content, credentials, or other app data.
24
+
25
+ Set `NEOAGENT_MIXPANEL_TOKEN` to your own public Mixpanel project token to enable analytics. Leave it blank to disable analytics entirely.
26
+ When analytics is enabled, the web client shows a dismissible cookie-consent banner until the user accepts or declines.
27
+
28
+ | Variable | Default | Description |
29
+ |---|---:|---|
30
+ | `NEOAGENT_MIXPANEL_TOKEN` | optional | Public Mixpanel project token used by the Flutter client. |
31
+
21
32
  ## Service Email
22
33
 
23
34
  Service email is optional. When `NEOAGENT_EMAIL_FROM` and `NEOAGENT_EMAIL_SMTP_HOST` are set, NeoAgent uses SMTP for account security flows: signup confirmation, password reset, unusual login notifications, password change notifications, and email change notifications. Confirmation and reset links use the same `PUBLIC_URL` base as the other server-generated links.
@@ -26,6 +26,7 @@ import 'src/android_apk_drop_zone.dart';
26
26
  import 'src/android_launcher_bridge.dart';
27
27
  import 'src/app_launch_bridge.dart';
28
28
  import 'src/app_release_updater.dart' as app_release_updater;
29
+ import 'src/analytics_service.dart';
29
30
  import 'src/backend_client.dart';
30
31
  import 'src/desktop_companion.dart';
31
32
  import 'src/desktop_screen_capture.dart';
@@ -37,6 +37,7 @@ class NeoAgentController extends ChangeNotifier {
37
37
  final OAuthLauncher _oauthLauncher;
38
38
  final app_release_updater.AppReleaseUpdater _appReleaseUpdater =
39
39
  app_release_updater.AppReleaseUpdater();
40
+ final AppAnalytics _analytics = AppAnalytics();
40
41
  final LiveVoiceCapture _liveVoiceCapture = LiveVoiceCapture();
41
42
  final DesktopScreenCapture _desktopScreenCapture =
42
43
  createDesktopScreenCapture();
@@ -51,6 +52,7 @@ class NeoAgentController extends ChangeNotifier {
51
52
  static const String _configuredBackendUrl = String.fromEnvironment(
52
53
  'NEOAGENT_BACKEND_URL',
53
54
  );
55
+ static const String _selectedSectionPrefsKey = 'ui.selectedSection';
54
56
 
55
57
  SharedPreferences? _prefs;
56
58
  final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -97,6 +99,10 @@ class NeoAgentController extends ChangeNotifier {
97
99
  bool networkStatusKnown = false;
98
100
  bool _desktopFloatingToolbarPopupRequested = false;
99
101
  bool _voiceAssistantIncludeScreenContext = false;
102
+ bool _didTrackAppOpen = false;
103
+ bool _analyticsConfigured = false;
104
+ bool _analyticsConsentResolved = false;
105
+ bool _analyticsConsentGranted = false;
100
106
 
101
107
  bool hasUser = true;
102
108
  bool registrationOpen = false;
@@ -318,6 +324,7 @@ class NeoAgentController extends ChangeNotifier {
318
324
  _diagnosticLogSubscription?.cancel();
319
325
  _connectivitySubscription?.cancel();
320
326
  _appReleaseUpdater.dispose();
327
+ unawaited(_analytics.dispose());
321
328
  _desktopCompanion.removeListener(notifyListeners);
322
329
  unawaited(_desktopCompanion.disconnect());
323
330
  _recordingBridge.removeListener(_handleRecordingBridgeChanged);
@@ -348,6 +355,12 @@ class NeoAgentController extends ChangeNotifier {
348
355
  bool get canCaptureVoiceAssistantScreenContext =>
349
356
  _desktopScreenCapture.isSupported;
350
357
 
358
+ bool get showAnalyticsConsentBanner =>
359
+ kIsWeb && _analyticsConfigured && !_analyticsConsentResolved;
360
+
361
+ bool get analyticsConsentGranted =>
362
+ _analyticsConsentResolved && _analyticsConsentGranted;
363
+
351
364
  bool get isLiveVoiceCaptureEngaged =>
352
365
  _isStartingLiveVoice || _liveVoiceCaptureActive;
353
366
 
@@ -689,6 +702,7 @@ class NeoAgentController extends ChangeNotifier {
689
702
  _prefs?.getBool('desktop.autoShowFloatingToolbar') ?? true;
690
703
  _desktopAssistantHotkeyEnabled =
691
704
  _prefs?.getBool('desktop.assistantHotkeyEnabled') ?? true;
705
+ _restoreSelectedSectionFromPrefs();
692
706
  _voiceAssistantIncludeScreenContext =
693
707
  (_prefs?.getBool('voiceAssistant.includeScreenContext') ?? false) &&
694
708
  canCaptureVoiceAssistantScreenContext;
@@ -699,6 +713,7 @@ class NeoAgentController extends ChangeNotifier {
699
713
  appUpdateAutoCheckEnabled =
700
714
  _prefs?.getBool('app.update.autoCheckEnabled') ?? true;
701
715
  installedAppVersion = await _safeLoadInstalledAppVersion();
716
+ await _initializeAnalytics();
702
717
  await refreshConnectivityStatus();
703
718
  if (_connectivityPluginAvailable && _connectivitySubscription == null) {
704
719
  try {
@@ -860,6 +875,101 @@ class NeoAgentController extends ChangeNotifier {
860
875
  }
861
876
  }
862
877
 
878
+ Future<void> _initializeAnalytics() async {
879
+ try {
880
+ final runtimeConfig = await _backendClient.fetchRuntimeConfig(backendUrl);
881
+ final analyticsConfig = runtimeConfig['analytics'];
882
+ final mixpanelConfig = analyticsConfig is Map
883
+ ? analyticsConfig['mixpanel']
884
+ : null;
885
+ final token = mixpanelConfig is Map
886
+ ? mixpanelConfig['token']?.toString()
887
+ : null;
888
+ _analyticsConfigured = token != null && token.trim().isNotEmpty;
889
+ final consentState = _prefs?.getBool('analytics.cookieConsent');
890
+ _analyticsConsentResolved = consentState != null;
891
+ _analyticsConsentGranted = consentState == true;
892
+ await _analytics.initialize(
893
+ token: token,
894
+ consentGranted: _analyticsConsentGranted,
895
+ );
896
+ _trackAppOpenedIfNeeded();
897
+ } catch (_) {
898
+ _analyticsConfigured = false;
899
+ _analyticsConsentResolved = false;
900
+ _analyticsConsentGranted = false;
901
+ await _analytics.initialize(token: null, consentGranted: false);
902
+ }
903
+ }
904
+
905
+ void _trackAppOpenedIfNeeded({String? consentSource}) {
906
+ if (!_analytics.enabled || _didTrackAppOpen) {
907
+ return;
908
+ }
909
+ _didTrackAppOpen = true;
910
+ unawaited(
911
+ _analytics.trackAppOpened(
912
+ appMode: appMode.name,
913
+ platform: _analyticsPlatformLabel(),
914
+ backendMode: backendUrl.trim().isEmpty ? 'same_origin' : 'custom',
915
+ selectedSection: selectedSection.label,
916
+ deploymentProfile: deploymentProfile,
917
+ authenticated: isAuthenticated,
918
+ ),
919
+ );
920
+ if (consentSource != null) {
921
+ unawaited(
922
+ _analytics.track(
923
+ 'analytics_consent_granted',
924
+ properties: <String, Object?>{'consent_source': consentSource},
925
+ ),
926
+ );
927
+ }
928
+ }
929
+
930
+ String _analyticsPlatformLabel() {
931
+ if (kIsWeb) {
932
+ return 'web';
933
+ }
934
+ switch (defaultTargetPlatform) {
935
+ case TargetPlatform.android:
936
+ return 'android';
937
+ case TargetPlatform.iOS:
938
+ return 'ios';
939
+ case TargetPlatform.macOS:
940
+ return 'macos';
941
+ case TargetPlatform.windows:
942
+ return 'windows';
943
+ case TargetPlatform.linux:
944
+ return 'linux';
945
+ case TargetPlatform.fuchsia:
946
+ return 'fuchsia';
947
+ }
948
+ }
949
+
950
+ Future<void> acceptAnalyticsConsent() async {
951
+ if (!_analyticsConfigured) {
952
+ return;
953
+ }
954
+ _analyticsConsentResolved = true;
955
+ _analyticsConsentGranted = true;
956
+ await _prefs?.setBool('analytics.cookieConsent', true);
957
+ await _analytics.setConsentGranted(true);
958
+ _trackAppOpenedIfNeeded(consentSource: 'banner');
959
+ notifyListeners();
960
+ }
961
+
962
+ Future<void> declineAnalyticsConsent() async {
963
+ if (!_analyticsConfigured) {
964
+ return;
965
+ }
966
+ _analyticsConsentResolved = true;
967
+ _analyticsConsentGranted = false;
968
+ await _prefs?.setBool('analytics.cookieConsent', false);
969
+ await _analytics.setConsentGranted(false);
970
+ notifyListeners();
971
+ }
972
+
863
973
  Future<void> setAppUpdateChannel(String channel) async {
864
974
  final normalized = channel.trim().toLowerCase() == 'beta'
865
975
  ? 'beta'
@@ -884,6 +994,7 @@ class NeoAgentController extends ChangeNotifier {
884
994
  if (isCheckingAppUpdate) {
885
995
  return;
886
996
  }
997
+ unawaited(_analytics.trackAppUpdateCheck(silent: silent));
887
998
  if (!appUpdaterConfigured) {
888
999
  appUpdateErrorMessage = kIsWeb
889
1000
  ? 'Client app update checks are unavailable in the web app.'
@@ -973,6 +1084,11 @@ class NeoAgentController extends ChangeNotifier {
973
1084
  isBooting = true;
974
1085
  notifyListeners();
975
1086
  await bootstrap();
1087
+ unawaited(
1088
+ _analytics.trackBackendUrlSaved(
1089
+ backendMode: backendUrl.trim().isEmpty ? 'same_origin' : 'custom',
1090
+ ),
1091
+ );
976
1092
  return true;
977
1093
  } catch (error) {
978
1094
  errorMessage = _friendlyErrorMessage(error);
@@ -1145,6 +1261,7 @@ class NeoAgentController extends ChangeNotifier {
1145
1261
  response,
1146
1262
  retentionErrorMessage:
1147
1263
  'QR login completed, but NeoAgent could not keep the session. Please try again.',
1264
+ authMethod: 'qr',
1148
1265
  );
1149
1266
  } catch (error) {
1150
1267
  final message = _friendlyErrorMessage(error);
@@ -1197,6 +1314,7 @@ class NeoAgentController extends ChangeNotifier {
1197
1314
  String? fallbackUsername,
1198
1315
  String? retentionErrorMessage,
1199
1316
  bool isRegistration = false,
1317
+ String authMethod = 'password',
1200
1318
  }) async {
1201
1319
  user = Map<String, dynamic>.from(
1202
1320
  response['user'] as Map<dynamic, dynamic>? ??
@@ -1218,6 +1336,14 @@ class NeoAgentController extends ChangeNotifier {
1218
1336
  _clearQrLoginChallenge();
1219
1337
  await _persistCredentials();
1220
1338
  await refresh();
1339
+ if (isAuthenticated) {
1340
+ unawaited(
1341
+ _analytics.trackSignedIn(
1342
+ authMethod: authMethod,
1343
+ isRegistration: isRegistration,
1344
+ ),
1345
+ );
1346
+ }
1221
1347
  if (!isAuthenticated && retentionErrorMessage != null) {
1222
1348
  errorMessage = retentionErrorMessage;
1223
1349
  }
@@ -1287,6 +1413,7 @@ class NeoAgentController extends ChangeNotifier {
1287
1413
  await _completeAuthenticatedResponse(
1288
1414
  response,
1289
1415
  isRegistration: register,
1416
+ authMethod: 'oauth',
1290
1417
  retentionErrorMessage:
1291
1418
  'Sign-in completed, but NeoAgent could not keep the browser session. Please sign in again. If this keeps happening, check backend session cookie settings.',
1292
1419
  );
@@ -1313,6 +1440,7 @@ class NeoAgentController extends ChangeNotifier {
1313
1440
  await _completeAuthenticatedResponse(
1314
1441
  response,
1315
1442
  fallbackUsername: pendingTwoFactorUsername,
1443
+ authMethod: 'two_factor',
1316
1444
  retentionErrorMessage:
1317
1445
  'Two-factor sign-in completed, but NeoAgent could not keep the browser session. Please sign in again.',
1318
1446
  );
@@ -1403,6 +1531,7 @@ class NeoAgentController extends ChangeNotifier {
1403
1531
  response,
1404
1532
  fallbackUsername: username,
1405
1533
  isRegistration: register,
1534
+ authMethod: 'password',
1406
1535
  retentionErrorMessage:
1407
1536
  'Sign-in completed, but NeoAgent could not keep the browser session. Please sign in again. If this keeps happening, the backend session cookie is likely not being retained.',
1408
1537
  );
@@ -1433,6 +1562,7 @@ class NeoAgentController extends ChangeNotifier {
1433
1562
  final logoutFuture = _backendClient.logout(backendUrl);
1434
1563
  _authCycle += 1;
1435
1564
  _clearAuthenticatedState();
1565
+ unawaited(_analytics.trackSignedOut());
1436
1566
  isAuthenticating = true;
1437
1567
  notifyListeners();
1438
1568
 
@@ -1449,6 +1579,7 @@ class NeoAgentController extends ChangeNotifier {
1449
1579
  notifyListeners();
1450
1580
  try {
1451
1581
  await _backendClient.completeOnboarding(backendUrl);
1582
+ unawaited(_analytics.trackOnboardingDismissed());
1452
1583
  if (isAuthenticated && user != null) {
1453
1584
  user!['hasCompletedOnboarding'] = true;
1454
1585
  }
@@ -1529,6 +1660,9 @@ class NeoAgentController extends ChangeNotifier {
1529
1660
  toolEvents = const <ToolEventItem>[];
1530
1661
  streamingAssistant = '';
1531
1662
  selectedSection = AppSection.chat;
1663
+ unawaited(
1664
+ _prefs?.setString(_selectedSectionPrefsKey, AppSection.chat.name),
1665
+ );
1532
1666
  _selectedWidgetId = null;
1533
1667
  _pendingChatDraft = null;
1534
1668
  _runDetailsCache.clear();
@@ -1576,6 +1710,20 @@ class NeoAgentController extends ChangeNotifier {
1576
1710
  unawaited(_syncHomeWidgetConfig());
1577
1711
  }
1578
1712
 
1713
+ void _restoreSelectedSectionFromPrefs() {
1714
+ final rawSection =
1715
+ _prefs?.getString(_selectedSectionPrefsKey)?.trim() ?? '';
1716
+ if (rawSection.isEmpty) {
1717
+ return;
1718
+ }
1719
+
1720
+ final restoredSection = AppSection.values.firstWhere(
1721
+ (section) => section.name == rawSection,
1722
+ orElse: () => AppSection.chat,
1723
+ );
1724
+ selectedSection = restoredSection;
1725
+ }
1726
+
1579
1727
  Future<void> _syncDesktopCompanionSession() {
1580
1728
  return _desktopCompanion.updateSession(
1581
1729
  backendUrl: backendUrl,
@@ -1585,13 +1733,23 @@ class NeoAgentController extends ChangeNotifier {
1585
1733
  }
1586
1734
 
1587
1735
  void setSelectedSection(AppSection section) {
1736
+ final previousSection = selectedSection;
1588
1737
  selectedSection = section;
1738
+ unawaited(_prefs?.setString(_selectedSectionPrefsKey, section.name));
1589
1739
  if (section == AppSection.devices) {
1590
1740
  unawaited(refreshDevices());
1591
1741
  }
1592
1742
  if (section == AppSection.accountSettings) {
1593
1743
  unawaited(refreshAccountSettings());
1594
1744
  }
1745
+ if (previousSection != section) {
1746
+ unawaited(
1747
+ _analytics.trackSectionChanged(
1748
+ section: section.label,
1749
+ previousSection: previousSection.label,
1750
+ ),
1751
+ );
1752
+ }
1595
1753
  notifyListeners();
1596
1754
  }
1597
1755
 
@@ -2402,6 +2560,9 @@ class NeoAgentController extends ChangeNotifier {
2402
2560
  }
2403
2561
 
2404
2562
  Future<void> refreshWidgets({bool all = false}) async {
2563
+ if (all) {
2564
+ unawaited(_analytics.trackWidgetRefreshRequested(all: all));
2565
+ }
2405
2566
  widgets = _decodeModelList(
2406
2567
  'widgets',
2407
2568
  await _backendClient.fetchWidgets(
@@ -3204,6 +3365,7 @@ class NeoAgentController extends ChangeNotifier {
3204
3365
  }
3205
3366
 
3206
3367
  Future<void> startBackgroundRecording() async {
3368
+ unawaited(_analytics.trackRecordingStarted(kind: 'background'));
3207
3369
  await _startRecordingCapture(
3208
3370
  logKey: 'start_background',
3209
3371
  payload: buildAndroidBackgroundRecordingPayload(),
@@ -3230,6 +3392,7 @@ class NeoAgentController extends ChangeNotifier {
3230
3392
  }
3231
3393
  return;
3232
3394
  }
3395
+ unawaited(_analytics.trackRecordingStarted(kind: 'desktop'));
3233
3396
  await _startRecordingCapture(
3234
3397
  logKey: 'start_desktop',
3235
3398
  payload: buildDesktopRecordingPayload(),
@@ -3613,6 +3776,15 @@ class NeoAgentController extends ChangeNotifier {
3613
3776
  notifyListeners();
3614
3777
  try {
3615
3778
  _desktopFloatingToolbarPopupRequested = false;
3779
+ unawaited(
3780
+ _analytics.trackRecordingStopped(
3781
+ kind: recordingRuntime.supportsBackgroundMic &&
3782
+ !recordingRuntime.supportsScreenAndMic
3783
+ ? 'background'
3784
+ : 'desktop',
3785
+ stopReason: stopReason,
3786
+ ),
3787
+ );
3616
3788
  await _recordingBridge.stopActiveRecording();
3617
3789
  if (isAndroidBackgroundStop) {
3618
3790
  await Future<void>.delayed(const Duration(milliseconds: 600));
@@ -4241,6 +4413,12 @@ class NeoAgentController extends ChangeNotifier {
4241
4413
  if (trimmed.isEmpty || (isSendingMessage && !canSteerLiveRun)) {
4242
4414
  return;
4243
4415
  }
4416
+ unawaited(
4417
+ _analytics.trackChatMessageSent(
4418
+ length: trimmed.length,
4419
+ steeringLiveRun: canSteerLiveRun,
4420
+ ),
4421
+ );
4244
4422
 
4245
4423
  final optimistic = ChatEntry(
4246
4424
  id: '',
@@ -4662,6 +4840,7 @@ class NeoAgentController extends ChangeNotifier {
4662
4840
  errorMessage = null;
4663
4841
  notifyListeners();
4664
4842
  try {
4843
+ unawaited(_analytics.trackAppUpdateTriggered());
4665
4844
  await _backendClient.triggerUpdate(backendUrl);
4666
4845
  await refreshUpdateStatus();
4667
4846
  } catch (error) {
@@ -5392,8 +5571,7 @@ class NeoAgentController extends ChangeNotifier {
5392
5571
  return;
5393
5572
  }
5394
5573
  _pendingChatDraft = normalized;
5395
- selectedSection = AppSection.chat;
5396
- notifyListeners();
5574
+ setSelectedSection(AppSection.chat);
5397
5575
  }
5398
5576
 
5399
5577
  String? takePendingChatDraft() {
@@ -5412,17 +5590,15 @@ class NeoAgentController extends ChangeNotifier {
5412
5590
 
5413
5591
  void openWidgetSurface(String widgetId) {
5414
5592
  final normalized = widgetId.trim();
5415
- selectedSection = AppSection.widgets;
5593
+ setSelectedSection(AppSection.widgets);
5416
5594
  if (normalized.isNotEmpty) {
5417
5595
  _selectedWidgetId = normalized;
5418
5596
  unawaited(refreshWidgets(all: true));
5419
5597
  }
5420
- notifyListeners();
5421
5598
  }
5422
5599
 
5423
5600
  void openVoiceAssistantSurface() {
5424
- selectedSection = AppSection.voiceAssistant;
5425
- notifyListeners();
5601
+ setSelectedSection(AppSection.voiceAssistant);
5426
5602
  }
5427
5603
 
5428
5604
  String? get selectedWidgetId => _selectedWidgetId;
@@ -5455,6 +5631,7 @@ class NeoAgentController extends ChangeNotifier {
5455
5631
  return;
5456
5632
  }
5457
5633
  _startManualRunCooldown('task', '$id');
5634
+ unawaited(_analytics.trackTaskRunRequested(taskId: id));
5458
5635
  await _backendClient.runSavedTask(backendUrl, id);
5459
5636
  await refreshTasks();
5460
5637
  await refreshRunsOnly();
@@ -627,6 +627,28 @@ class _NeoAgentAppState extends State<NeoAgentApp>
627
627
  ),
628
628
  ),
629
629
  ),
630
+ if (!_desktopToolbarWindowMode &&
631
+ !_desktopAssistantPopupWindowMode &&
632
+ _controller.showAnalyticsConsentBanner)
633
+ Positioned(
634
+ left: 0,
635
+ right: 0,
636
+ bottom: 0,
637
+ child: SafeArea(
638
+ top: false,
639
+ child: Padding(
640
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
641
+ child: Center(
642
+ child: ConstrainedBox(
643
+ constraints: const BoxConstraints(maxWidth: 980),
644
+ child: _GlobalAnalyticsConsentBanner(
645
+ controller: _controller,
646
+ ),
647
+ ),
648
+ ),
649
+ ),
650
+ ),
651
+ ),
630
652
  if (_supportsDesktopShell &&
631
653
  !_desktopToolbarWindowMode &&
632
654
  !_desktopAssistantPopupWindowMode)
@@ -195,11 +195,21 @@ class _SettingsPanelState extends State<SettingsPanel> {
195
195
  final modelChoices = <DropdownMenuItem<String>>[
196
196
  const DropdownMenuItem<String>(
197
197
  value: 'auto',
198
- child: Text('Smart Selector (Auto)'),
198
+ child: Text(
199
+ 'Smart Selector (Auto)',
200
+ maxLines: 1,
201
+ overflow: TextOverflow.ellipsis,
202
+ ),
199
203
  ),
200
204
  ...routingModels.map(
201
- (model) =>
202
- DropdownMenuItem<String>(value: model.id, child: Text(model.label)),
205
+ (model) => DropdownMenuItem<String>(
206
+ value: model.id,
207
+ child: Text(
208
+ model.label,
209
+ maxLines: 1,
210
+ overflow: TextOverflow.ellipsis,
211
+ ),
212
+ ),
203
213
  ),
204
214
  ];
205
215
  final enabledSmartModels = _enabledModels
@@ -634,7 +644,11 @@ class _SettingsPanelState extends State<SettingsPanel> {
634
644
  .map(
635
645
  (model) => DropdownMenuItem<String>(
636
646
  value: model.id,
637
- child: Text(model.label),
647
+ child: Text(
648
+ model.label,
649
+ maxLines: 1,
650
+ overflow: TextOverflow.ellipsis,
651
+ ),
638
652
  ),
639
653
  )
640
654
  .toList(),
@@ -2030,6 +2044,7 @@ class _RoutingSelectCard extends StatelessWidget {
2030
2044
  DropdownButtonFormField<String>(
2031
2045
  initialValue: value,
2032
2046
  items: items,
2047
+ isExpanded: true,
2033
2048
  decoration: const InputDecoration(isDense: true),
2034
2049
  onChanged: onChanged,
2035
2050
  ),
@@ -1826,6 +1826,167 @@ class _GlobalWebUpdateBanner extends StatelessWidget {
1826
1826
  }
1827
1827
  }
1828
1828
 
1829
+ class _GlobalAnalyticsConsentBanner extends StatelessWidget {
1830
+ const _GlobalAnalyticsConsentBanner({required this.controller});
1831
+
1832
+ final NeoAgentController controller;
1833
+
1834
+ @override
1835
+ Widget build(BuildContext context) {
1836
+ return LayoutBuilder(
1837
+ builder: (context, constraints) {
1838
+ final compact = constraints.maxWidth < 700;
1839
+ final body = Text(
1840
+ 'NeoAgent uses anonymous analytics to understand startup, navigation, and setup flows. No messages, credentials, or personal content are collected.',
1841
+ style: TextStyle(color: _textSecondary, height: 1.35),
1842
+ );
1843
+ return Material(
1844
+ color: Colors.transparent,
1845
+ child: _GlassSurface(
1846
+ borderRadius: BorderRadius.circular(22),
1847
+ blurSigma: 26,
1848
+ fillColor: _bgCard.withValues(alpha: 0.94),
1849
+ borderColor: _accent.withValues(alpha: 0.18),
1850
+ overlayGradient: LinearGradient(
1851
+ colors: <Color>[
1852
+ _accent.withValues(alpha: 0.12),
1853
+ _bgCard.withValues(alpha: 0.88),
1854
+ _bgSecondary.withValues(alpha: 0.86),
1855
+ ],
1856
+ stops: const <double>[0, 0.22, 1],
1857
+ begin: const Alignment(-1, -1),
1858
+ end: const Alignment(1, 1),
1859
+ ),
1860
+ boxShadow: <BoxShadow>[
1861
+ BoxShadow(
1862
+ color: Colors.black.withValues(alpha: 0.22),
1863
+ blurRadius: 34,
1864
+ offset: const Offset(0, 18),
1865
+ ),
1866
+ BoxShadow(
1867
+ color: _accent.withValues(alpha: 0.1),
1868
+ blurRadius: 24,
1869
+ offset: const Offset(0, 8),
1870
+ ),
1871
+ ],
1872
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
1873
+ child: compact
1874
+ ? Column(
1875
+ crossAxisAlignment: CrossAxisAlignment.start,
1876
+ children: <Widget>[
1877
+ Row(
1878
+ crossAxisAlignment: CrossAxisAlignment.start,
1879
+ children: <Widget>[
1880
+ Container(
1881
+ padding: const EdgeInsets.all(10),
1882
+ decoration: BoxDecoration(
1883
+ color: _accent.withValues(alpha: 0.16),
1884
+ shape: BoxShape.circle,
1885
+ border: Border.all(
1886
+ color: _accent.withValues(alpha: 0.24),
1887
+ ),
1888
+ ),
1889
+ child: Icon(
1890
+ Icons.cookie_outlined,
1891
+ color: _accent,
1892
+ size: 22,
1893
+ ),
1894
+ ),
1895
+ const SizedBox(width: 12),
1896
+ Expanded(
1897
+ child: Column(
1898
+ crossAxisAlignment: CrossAxisAlignment.start,
1899
+ children: <Widget>[
1900
+ Text(
1901
+ 'Analytics cookies',
1902
+ style: TextStyle(
1903
+ color: _textPrimary,
1904
+ fontSize: 15,
1905
+ fontWeight: FontWeight.w700,
1906
+ ),
1907
+ ),
1908
+ const SizedBox(height: 6),
1909
+ body,
1910
+ ],
1911
+ ),
1912
+ ),
1913
+ ],
1914
+ ),
1915
+ const SizedBox(height: 14),
1916
+ Row(
1917
+ children: <Widget>[
1918
+ Expanded(
1919
+ child: OutlinedButton(
1920
+ onPressed: controller.declineAnalyticsConsent,
1921
+ child: const Text('Decline'),
1922
+ ),
1923
+ ),
1924
+ const SizedBox(width: 10),
1925
+ Expanded(
1926
+ child: FilledButton(
1927
+ onPressed: controller.acceptAnalyticsConsent,
1928
+ child: const Text('Allow analytics'),
1929
+ ),
1930
+ ),
1931
+ ],
1932
+ ),
1933
+ ],
1934
+ )
1935
+ : Row(
1936
+ crossAxisAlignment: CrossAxisAlignment.center,
1937
+ children: <Widget>[
1938
+ Container(
1939
+ padding: const EdgeInsets.all(10),
1940
+ decoration: BoxDecoration(
1941
+ color: _accent.withValues(alpha: 0.16),
1942
+ shape: BoxShape.circle,
1943
+ border: Border.all(
1944
+ color: _accent.withValues(alpha: 0.24),
1945
+ ),
1946
+ ),
1947
+ child: Icon(
1948
+ Icons.cookie_outlined,
1949
+ color: _accent,
1950
+ size: 22,
1951
+ ),
1952
+ ),
1953
+ const SizedBox(width: 14),
1954
+ Expanded(
1955
+ child: Column(
1956
+ crossAxisAlignment: CrossAxisAlignment.start,
1957
+ children: <Widget>[
1958
+ Text(
1959
+ 'Analytics cookies',
1960
+ style: TextStyle(
1961
+ color: _textPrimary,
1962
+ fontSize: 15,
1963
+ fontWeight: FontWeight.w700,
1964
+ ),
1965
+ ),
1966
+ const SizedBox(height: 6),
1967
+ body,
1968
+ ],
1969
+ ),
1970
+ ),
1971
+ const SizedBox(width: 16),
1972
+ OutlinedButton(
1973
+ onPressed: controller.declineAnalyticsConsent,
1974
+ child: const Text('Decline'),
1975
+ ),
1976
+ const SizedBox(width: 10),
1977
+ FilledButton(
1978
+ onPressed: controller.acceptAnalyticsConsent,
1979
+ child: const Text('Allow analytics'),
1980
+ ),
1981
+ ],
1982
+ ),
1983
+ ),
1984
+ );
1985
+ },
1986
+ );
1987
+ }
1988
+ }
1989
+
1829
1990
  class _DesktopCloseDecision {
1830
1991
  const _DesktopCloseDecision({
1831
1992
  required this.keepRunning,