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
@@ -139,15 +139,6 @@ const _desktopSettingsSection = _SettingsSection('desktop', <String>[
139
139
  'input',
140
140
  ], requiresDesktop: true);
141
141
 
142
- const _updatesSettingsSection = _SettingsSection('updates', <String>[
143
- 'updates',
144
- 'release',
145
- 'channel',
146
- 'version',
147
- 'self update',
148
- 'upgrade',
149
- ]);
150
-
151
142
  const _diagnosticsSettingsSection = _SettingsSection('diagnostics', <String>[
152
143
  'diagnostics',
153
144
  'logs',
@@ -163,13 +154,13 @@ const List<_SettingsSection> _settingsSearchSections = <_SettingsSection>[
163
154
  _modelsSettingsSection,
164
155
  _voiceRecordingSettingsSection,
165
156
  _desktopSettingsSection,
166
- _updatesSettingsSection,
167
157
  _diagnosticsSettingsSection,
168
158
  ];
169
159
 
170
160
  class _SettingsPanelState extends State<SettingsPanel> {
171
161
  late final TextEditingController _searchController;
172
162
  late String _browserBackend;
163
+ String? _browserExtensionTokenId;
173
164
  late String _cliBackend;
174
165
  String? _cliDesktopDeviceId;
175
166
  late bool _smarterSelector;
@@ -186,7 +177,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
186
177
  final Map<String, bool> _providerEnabled = <String, bool>{};
187
178
  final Map<String, TextEditingController> _providerBaseUrlControllers =
188
179
  <String, TextEditingController>{};
189
- final Set<String> _expandedProviderIds = <String>{};
190
180
 
191
181
  // Inline runtime test state — ephemeral, not stored in controller.
192
182
  bool _cliTestRunning = false;
@@ -233,6 +223,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
233
223
  .map((model) => model.id)
234
224
  .toSet();
235
225
  _browserBackend = _normalizeBrowserBackend(controller.browserBackend);
226
+ _browserExtensionTokenId =
227
+ controller.browserExtensionTokenId ??
228
+ controller.selectedBrowserExtensionTokenId;
236
229
  _cliBackend = _normalizeCliBackend(controller.cliBackend);
237
230
  _cliDesktopDeviceId = controller.cliDesktopDeviceId;
238
231
  _smarterSelector = controller.smarterSelector;
@@ -309,26 +302,8 @@ class _SettingsPanelState extends State<SettingsPanel> {
309
302
  final routingModels = availableModels.isEmpty
310
303
  ? controller.supportedModels
311
304
  : availableModels;
312
- final modelChoices = <DropdownMenuItem<String>>[
313
- const DropdownMenuItem<String>(
314
- value: 'auto',
315
- child: Text(
316
- 'Smart Selector (Auto)',
317
- maxLines: 1,
318
- overflow: TextOverflow.ellipsis,
319
- ),
320
- ),
321
- ...routingModels.map(
322
- (model) => DropdownMenuItem<String>(
323
- value: model.id,
324
- child: Text(
325
- model.label,
326
- maxLines: 1,
327
- overflow: TextOverflow.ellipsis,
328
- ),
329
- ),
330
- ),
331
- ];
305
+ final List<_ModelPickerOption> modelChoices =
306
+ _modelPickerOptions(routingModels, allowAuto: true);
332
307
  final enabledSmartModels = _enabledModels
333
308
  .where((id) => routingModels.any((model) => model.id == id))
334
309
  .length;
@@ -343,7 +318,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
343
318
  _PageTitle(
344
319
  title: 'Settings',
345
320
  subtitle:
346
- 'Workspace, models, recording, update, and diagnostics controls.',
321
+ 'Workspace, models, recording, and diagnostics controls.',
347
322
  trailing: _settingsSaveButton(controller),
348
323
  )
349
324
  else
@@ -363,7 +338,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
363
338
  onChanged: (_) => setState(() {}),
364
339
  decoration: InputDecoration(
365
340
  labelText: 'Search settings',
366
- hintText: 'Models, browser, voice, updates, diagnostics...',
341
+ hintText: 'Models, browser, voice, diagnostics...',
367
342
  prefixIcon: const Icon(Icons.search),
368
343
  suffixIcon: searchQuery.isEmpty
369
344
  ? null
@@ -396,6 +371,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
396
371
  _modelsSettingsSection,
397
372
  )) ...<Widget>[
398
373
  _buildModelsSection(
374
+ context: context,
399
375
  controller: controller,
400
376
  modelChoices: modelChoices,
401
377
  routingModels: routingModels,
@@ -423,13 +399,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
423
399
  _buildDesktopSection(controller),
424
400
  const SizedBox(height: 16),
425
401
  ],
426
- if (_matchesSettingsSection(
427
- searchQuery,
428
- _updatesSettingsSection,
429
- )) ...<Widget>[
430
- _buildUpdatesSection(controller),
431
- const SizedBox(height: 16),
432
- ],
433
402
  if (_matchesSettingsSection(
434
403
  searchQuery,
435
404
  _diagnosticsSettingsSection,
@@ -438,7 +407,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
438
407
  const _EmptyCard(
439
408
  title: 'No matching settings',
440
409
  subtitle:
441
- 'Try a broader search like models, browser, voice, or updates.',
410
+ 'Try a broader search like models, browser, or voice.',
442
411
  ),
443
412
  ],
444
413
  ],
@@ -476,6 +445,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
476
445
  browserBackend: _browserBackend == 'extension'
477
446
  ? 'extension'
478
447
  : 'vm',
448
+ browserExtensionTokenId: _browserBackend == 'extension'
449
+ ? _browserExtensionTokenId
450
+ : null,
479
451
  cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
480
452
  cliDesktopDeviceId: _cliDesktopDeviceId,
481
453
  smarterSelector: _smarterSelector,
@@ -545,7 +517,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
545
517
  const _SectionTitle('Overview'),
546
518
  const SizedBox(height: 10),
547
519
  Text(
548
- 'Configure workspace behavior, then models, recording defaults, and updates.',
520
+ 'Configure workspace behavior, models, and recording defaults.',
549
521
  style: TextStyle(color: _textSecondary, height: 1.45),
550
522
  ),
551
523
  const SizedBox(height: 14),
@@ -642,12 +614,62 @@ class _SettingsPanelState extends State<SettingsPanel> {
642
614
  ],
643
615
  onChanged: (value) {
644
616
  if (value != null) {
645
- setState(() => _browserBackend = value);
617
+ setState(() {
618
+ _browserBackend = value;
619
+ _browserExtensionTokenId ??=
620
+ controller.selectedBrowserExtensionTokenId;
621
+ });
646
622
  }
647
623
  },
648
624
  ),
649
625
  const SizedBox(height: 10),
650
626
  if (_browserBackend == 'extension') ...<Widget>[
627
+ if (controller.browserExtensionTokens.isNotEmpty) ...<Widget>[
628
+ DropdownButtonFormField<String>(
629
+ initialValue: controller.browserExtensionTokens.any(
630
+ (token) => token['tokenId']?.toString() == _browserExtensionTokenId,
631
+ )
632
+ ? _browserExtensionTokenId
633
+ : null,
634
+ decoration: const InputDecoration(
635
+ labelText: 'Default extension',
636
+ helperText: 'Choose which paired Chrome extension controls browser actions.',
637
+ ),
638
+ items: controller.browserExtensionTokens.map((token) {
639
+ final tokenId = token['tokenId']?.toString() ?? '';
640
+ final label = token['name']?.toString().trim().isNotEmpty == true
641
+ ? token['name'].toString()
642
+ : tokenId;
643
+ final online = token['online'] == true || token['connected'] == true;
644
+ return DropdownMenuItem<String>(
645
+ value: tokenId,
646
+ child: Row(
647
+ children: <Widget>[
648
+ Icon(
649
+ online ? Icons.circle : Icons.circle_outlined,
650
+ size: 10,
651
+ color: online ? Colors.green : Colors.grey,
652
+ ),
653
+ const SizedBox(width: 8),
654
+ Flexible(
655
+ child: Text(
656
+ label,
657
+ maxLines: 1,
658
+ overflow: TextOverflow.ellipsis,
659
+ ),
660
+ ),
661
+ ],
662
+ ),
663
+ );
664
+ }).toList(),
665
+ onChanged: (value) {
666
+ if (value != null) {
667
+ setState(() => _browserExtensionTokenId = value);
668
+ }
669
+ },
670
+ ),
671
+ const SizedBox(height: 10),
672
+ ],
651
673
  _buildInlineTestRow(
652
674
  label: 'Chrome extension',
653
675
  running: _extensionTestRunning,
@@ -659,11 +681,17 @@ class _SettingsPanelState extends State<SettingsPanel> {
659
681
  setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
660
682
  try {
661
683
  final r = await controller.testBrowserExtension();
662
- setState(() => _extensionTestResult = r);
684
+ if (mounted) {
685
+ setState(() => _extensionTestResult = r);
686
+ }
663
687
  } catch (e) {
664
- setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
688
+ if (mounted) {
689
+ setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
690
+ }
665
691
  } finally {
666
- setState(() => _extensionTestRunning = false);
692
+ if (mounted) {
693
+ setState(() => _extensionTestRunning = false);
694
+ }
667
695
  }
668
696
  },
669
697
  ),
@@ -764,18 +792,24 @@ class _SettingsPanelState extends State<SettingsPanel> {
764
792
  result: _cliTestResult,
765
793
  note: _cliBackend == 'desktop'
766
794
  ? (controller.desktopCompanionConnected
767
- ? 'Desktop app connected — commands currently route through the cloud VM (desktop routing coming soon).'
795
+ ? 'Desktop app connected — commands route locally through the companion.'
768
796
  : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
769
797
  : 'Cloud VM — commands run in an isolated container.',
770
798
  onTest: () async {
771
799
  setState(() { _cliTestRunning = true; _cliTestResult = null; });
772
800
  try {
773
801
  final r = await controller.testCliRuntime();
774
- setState(() => _cliTestResult = r);
802
+ if (mounted) {
803
+ setState(() => _cliTestResult = r);
804
+ }
775
805
  } catch (e) {
776
- setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
806
+ if (mounted) {
807
+ setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
808
+ }
777
809
  } finally {
778
- setState(() => _cliTestRunning = false);
810
+ if (mounted) {
811
+ setState(() => _cliTestRunning = false);
812
+ }
779
813
  }
780
814
  },
781
815
  ),
@@ -802,8 +836,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
802
836
  }
803
837
 
804
838
  Widget _buildModelsSection({
839
+ required BuildContext context,
805
840
  required NeoAgentController controller,
806
- required List<DropdownMenuItem<String>> modelChoices,
841
+ required List<_ModelPickerOption> modelChoices,
807
842
  required List<ModelMeta> routingModels,
808
843
  required List<ModelMeta> availableModels,
809
844
  required int enabledSmartModels,
@@ -817,88 +852,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
817
852
  const _SectionTitle('Models'),
818
853
  const SizedBox(height: 10),
819
854
  Text(
820
- 'Enable providers, then choose defaults for chat, agents, fallback behavior, and smart routing.',
855
+ 'Choose defaults for chat, agents, fallback behavior, and smart routing.',
821
856
  style: TextStyle(color: _textSecondary, height: 1.45),
822
857
  ),
823
858
  const SizedBox(height: 16),
824
- Text(
825
- 'Providers',
826
- style: TextStyle(
827
- fontWeight: FontWeight.w700,
828
- color: _textPrimary,
829
- ),
830
- ),
831
- const SizedBox(height: 14),
832
- if (controller.aiProviders.isEmpty)
833
- Text(
834
- 'Provider metadata is unavailable on this server version.',
835
- style: TextStyle(color: _textSecondary),
836
- )
837
- else
838
- LayoutBuilder(
839
- builder: (context, constraints) {
840
- final compact = constraints.maxWidth < 960;
841
- final cardWidth = compact
842
- ? constraints.maxWidth
843
- : (constraints.maxWidth - 16) / 2;
844
- return Wrap(
845
- spacing: 16,
846
- runSpacing: 16,
847
- children: controller.aiProviders
848
- .where(
849
- (provider) =>
850
- provider.available ||
851
- _providerEnabled[provider.id] == true ||
852
- controller
853
- .aiProviderConfigs[provider.id]
854
- ?.enabled ==
855
- true,
856
- )
857
- .map((provider) {
858
- return SizedBox(
859
- width: cardWidth,
860
- child: _AiProviderCard(
861
- provider: provider,
862
- enabled:
863
- _providerEnabled[provider.id] ??
864
- controller
865
- .aiProviderConfigs[provider.id]
866
- ?.enabled ??
867
- true,
868
- models: controller.supportedModels
869
- .where(
870
- (model) => model.provider == provider.id,
871
- )
872
- .toList(),
873
- baseUrlController:
874
- _providerBaseUrlControllers[provider.id]!,
875
- expanded: _expandedProviderIds.contains(
876
- provider.id,
877
- ),
878
- onEnabledChanged: (value) {
879
- setState(() {
880
- _providerEnabled[provider.id] = value;
881
- });
882
- },
883
- onExpandToggle: () {
884
- setState(() {
885
- if (_expandedProviderIds.contains(
886
- provider.id,
887
- )) {
888
- _expandedProviderIds.remove(provider.id);
889
- } else {
890
- _expandedProviderIds.add(provider.id);
891
- }
892
- });
893
- },
894
- ),
895
- );
896
- })
897
- .toList(),
898
- );
899
- },
900
- ),
901
- const Divider(height: 32),
902
859
  Text(
903
860
  'Default Routing',
904
861
  style: TextStyle(
@@ -928,7 +885,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
928
885
  routingModels,
929
886
  allowAuto: true,
930
887
  ),
931
- items: modelChoices,
888
+ options: modelChoices,
932
889
  onChanged: (value) {
933
890
  if (value != null) {
934
891
  setState(() => _defaultChatModel = value);
@@ -946,7 +903,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
946
903
  routingModels,
947
904
  allowAuto: true,
948
905
  ),
949
- items: modelChoices,
906
+ options: modelChoices,
950
907
  onChanged: (value) {
951
908
  if (value != null) {
952
909
  setState(() => _defaultSubagentModel = value);
@@ -964,18 +921,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
964
921
  routingModels,
965
922
  allowAuto: false,
966
923
  ),
967
- items: routingModels
968
- .map(
969
- (model) => DropdownMenuItem<String>(
970
- value: model.id,
971
- child: Text(
972
- model.label,
973
- maxLines: 1,
974
- overflow: TextOverflow.ellipsis,
975
- ),
976
- ),
977
- )
978
- .toList(),
924
+ options: _modelPickerOptions(routingModels),
979
925
  onChanged: (value) {
980
926
  if (value != null) {
981
927
  setState(() => _fallbackModel = value);
@@ -996,46 +942,38 @@ class _SettingsPanelState extends State<SettingsPanel> {
996
942
  ),
997
943
  ),
998
944
  const SizedBox(height: 10),
999
- Wrap(
1000
- spacing: 10,
1001
- runSpacing: 10,
1002
- children: controller.supportedModels.map((model) {
1003
- final selected = _enabledModels.contains(model.id);
1004
- return FilterChip(
1005
- label: Text(
1006
- model.available
1007
- ? model.label
1008
- : '${model.label} (${model.providerStatusLabel})',
945
+ Text(
946
+ 'The models the Smart Selector routes between automatically.',
947
+ style: TextStyle(color: _textSecondary, height: 1.45),
948
+ ),
949
+ const SizedBox(height: 12),
950
+ _SmartPoolSummary(
951
+ allModels: controller.supportedModels,
952
+ selectedIds: _enabledModels,
953
+ onManage: () async {
954
+ final result = await showGeneralDialog<Set<String>>(
955
+ context: context,
956
+ barrierDismissible: true,
957
+ barrierLabel: 'Dismiss',
958
+ barrierColor: Colors.black.withValues(alpha: 0.55),
959
+ transitionDuration: const Duration(milliseconds: 220),
960
+ transitionBuilder: (ctx, anim, _, child) => FadeTransition(
961
+ opacity: CurvedAnimation(parent: anim, curve: Curves.easeOut),
962
+ child: SlideTransition(
963
+ position: Tween<Offset>(
964
+ begin: const Offset(0, 0.04),
965
+ end: Offset.zero,
966
+ ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
967
+ child: child,
968
+ ),
1009
969
  ),
1010
- selected: selected,
1011
- selectedColor: _accentMuted,
1012
- checkmarkColor: _accent,
1013
- backgroundColor: _bgSecondary,
1014
- side: BorderSide(
1015
- color: model.available
1016
- ? _border
1017
- : _warning.withValues(alpha: 0.35),
970
+ pageBuilder: (ctx, _, __) => _SmartPoolDialog(
971
+ models: controller.supportedModels,
972
+ selectedIds: _enabledModels,
1018
973
  ),
1019
- onSelected: model.available
1020
- ? (value) {
1021
- setState(() {
1022
- if (value) {
1023
- _enabledModels.add(model.id);
1024
- } else if (_enabledModels.length > 1) {
1025
- _enabledModels.remove(model.id);
1026
- }
1027
- });
1028
- }
1029
- : null,
1030
974
  );
1031
- }).toList(),
1032
- ),
1033
- const SizedBox(height: 14),
1034
- Text(
1035
- availableModels.isEmpty
1036
- ? 'Enable a ready provider above to unlock model routing.'
1037
- : '$enabledSmartModels models are currently eligible for smart routing.',
1038
- style: TextStyle(color: _textSecondary),
975
+ if (result != null) setState(() => _enabledModels = result);
976
+ },
1039
977
  ),
1040
978
  ],
1041
979
  ),
@@ -1045,7 +983,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1045
983
 
1046
984
  Widget _buildVoiceAndRecordingSection({
1047
985
  required NeoAgentController controller,
1048
- required List<DropdownMenuItem<String>> modelChoices,
986
+ required List<_ModelPickerOption> modelChoices,
1049
987
  required List<ModelMeta> routingModels,
1050
988
  }) {
1051
989
  final liveVoiceOptions =
@@ -1091,7 +1029,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1091
1029
  routingModels,
1092
1030
  allowAuto: true,
1093
1031
  ),
1094
- items: modelChoices,
1032
+ options: modelChoices,
1095
1033
  onChanged: (value) {
1096
1034
  if (value != null) {
1097
1035
  setState(
@@ -1107,7 +1045,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1107
1045
  label: 'Recording Transcription',
1108
1046
  icon: Icons.hearing_outlined,
1109
1047
  value: _defaultRecordingTranscriptionModel,
1110
- items: _recordingTranscriptionModelChoices(
1048
+ options: _recordingTranscriptionOptions(
1111
1049
  _defaultRecordingTranscriptionModel,
1112
1050
  ),
1113
1051
  onChanged: (value) {
@@ -1152,7 +1090,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1152
1090
  routingModels,
1153
1091
  allowAuto: true,
1154
1092
  ),
1155
- items: modelChoices,
1093
+ options: modelChoices,
1156
1094
  onChanged: (value) {
1157
1095
  if (value != null) {
1158
1096
  setState(() => _defaultSpeechModel = value);
@@ -1194,14 +1132,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
1194
1132
  label: 'Live Provider',
1195
1133
  icon: Icons.call_outlined,
1196
1134
  value: _voiceLiveProvider,
1197
- items: const <String>['openai', 'gemini']
1198
- .map(
1199
- (value) => DropdownMenuItem<String>(
1200
- value: value,
1201
- child: Text(value),
1202
- ),
1203
- )
1204
- .toList(),
1135
+ options: _simplePickerOptions(
1136
+ const <String>['openai', 'gemini'],
1137
+ ),
1205
1138
  onChanged: (value) {
1206
1139
  if (value == null) return;
1207
1140
  setState(() {
@@ -1230,16 +1163,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1230
1163
  label: 'Live Model',
1231
1164
  icon: Icons.speed_outlined,
1232
1165
  value: _voiceLiveModel,
1233
- items:
1234
- (_voiceLiveModelsByProvider[_voiceLiveProvider] ??
1235
- const <String>[])
1236
- .map(
1237
- (value) => DropdownMenuItem<String>(
1238
- value: value,
1239
- child: Text(value),
1240
- ),
1241
- )
1242
- .toList(),
1166
+ options: _simplePickerOptions(
1167
+ _voiceLiveModelsByProvider[_voiceLiveProvider] ??
1168
+ const <String>[],
1169
+ ),
1243
1170
  onChanged: (value) {
1244
1171
  if (value != null) {
1245
1172
  setState(() => _voiceLiveModel = value);
@@ -1254,14 +1181,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1254
1181
  label: 'Live Voice',
1255
1182
  icon: Icons.graphic_eq_outlined,
1256
1183
  value: _voiceLiveVoice,
1257
- items: liveVoiceOptions
1258
- .map(
1259
- (value) => DropdownMenuItem<String>(
1260
- value: value,
1261
- child: Text(value),
1262
- ),
1263
- )
1264
- .toList(),
1184
+ options: _simplePickerOptions(liveVoiceOptions),
1265
1185
  onChanged: (value) {
1266
1186
  if (value != null) {
1267
1187
  setState(() => _voiceLiveVoice = value);
@@ -1453,14 +1373,20 @@ class _SettingsPanelState extends State<SettingsPanel> {
1453
1373
  } else if (multi) {
1454
1374
  detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
1455
1375
  }
1456
- setState(() => _desktopTestResult = <String, dynamic>{
1457
- ...r,
1458
- 'detail': detail,
1459
- });
1376
+ if (mounted) {
1377
+ setState(() => _desktopTestResult = <String, dynamic>{
1378
+ ...r,
1379
+ 'detail': detail,
1380
+ });
1381
+ }
1460
1382
  } catch (e) {
1461
- setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1383
+ if (mounted) {
1384
+ setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1385
+ }
1462
1386
  } finally {
1463
- setState(() => _desktopTestRunning = false);
1387
+ if (mounted) {
1388
+ setState(() => _desktopTestRunning = false);
1389
+ }
1464
1390
  }
1465
1391
  },
1466
1392
  ),
@@ -1580,426 +1506,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
1580
1506
  );
1581
1507
  }
1582
1508
 
1583
- Widget _buildUpdatesSection(NeoAgentController controller) {
1584
- return Card(
1585
- child: Padding(
1586
- padding: const EdgeInsets.all(20),
1587
- child: Column(
1588
- crossAxisAlignment: CrossAxisAlignment.start,
1589
- children: <Widget>[
1590
- const _SectionTitle('Updates'),
1591
- const SizedBox(height: 10),
1592
- Text(
1593
- 'Client and runtime update controls live here.',
1594
- style: TextStyle(color: _textSecondary, height: 1.45),
1595
- ),
1596
- const SizedBox(height: 16),
1597
- LayoutBuilder(
1598
- builder: (context, constraints) {
1599
- final compact = constraints.maxWidth < 720;
1600
- final checkButton = FilledButton.icon(
1601
- onPressed:
1602
- controller.isCheckingAppUpdate ||
1603
- !controller.appUpdaterConfigured
1604
- ? null
1605
- : () => controller.checkForAppUpdates(),
1606
- style: FilledButton.styleFrom(backgroundColor: _accent),
1607
- icon: controller.isCheckingAppUpdate
1608
- ? _inlineProgressIndicator()
1609
- : const Icon(Icons.sync),
1610
- label: Text(
1611
- controller.isCheckingAppUpdate
1612
- ? 'Checking...'
1613
- : 'Check now',
1614
- ),
1615
- );
1616
- final appHeading = Text(
1617
- 'Client App',
1618
- style: TextStyle(
1619
- fontWeight: FontWeight.w700,
1620
- color: _textPrimary,
1621
- ),
1622
- );
1623
- if (compact) {
1624
- return Column(
1625
- crossAxisAlignment: CrossAxisAlignment.start,
1626
- children: <Widget>[
1627
- appHeading,
1628
- const SizedBox(height: 10),
1629
- checkButton,
1630
- ],
1631
- );
1632
- }
1633
- return Row(
1634
- children: <Widget>[
1635
- Expanded(child: appHeading),
1636
- checkButton,
1637
- ],
1638
- );
1639
- },
1640
- ),
1641
- const SizedBox(height: 12),
1642
- if (!controller.appUpdaterConfigured)
1643
- if (!kIsWeb)
1644
- Text(
1645
- 'Client app updates are not configured for this build.',
1646
- style: TextStyle(color: _textSecondary, height: 1.5),
1647
- )
1648
- else ...<Widget>[
1649
- LayoutBuilder(
1650
- builder: (context, constraints) {
1651
- final compact = constraints.maxWidth < 780;
1652
- final channelPicker = DropdownButtonFormField<String>(
1653
- initialValue: controller.appUpdateChannel,
1654
- decoration: const InputDecoration(
1655
- labelText: 'App release channel',
1656
- ),
1657
- items: const <DropdownMenuItem<String>>[
1658
- DropdownMenuItem<String>(
1659
- value: 'stable',
1660
- child: Text('Stable'),
1661
- ),
1662
- DropdownMenuItem<String>(
1663
- value: 'beta',
1664
- child: Text('Beta'),
1665
- ),
1666
- ],
1667
- onChanged: (value) {
1668
- if (value != null) {
1669
- unawaited(controller.setAppUpdateChannel(value));
1670
- }
1671
- },
1672
- );
1673
- final autoCheck = SwitchListTile.adaptive(
1674
- value: controller.appUpdateAutoCheckEnabled,
1675
- contentPadding: EdgeInsets.zero,
1676
- title: Text('Check automatically on launch'),
1677
- subtitle: Text(
1678
- 'This only checks GitHub Releases on startup. Installation still requires your confirmation.',
1679
- style: TextStyle(color: _textSecondary),
1680
- ),
1681
- onChanged: controller.setAppUpdateAutoCheckEnabled,
1682
- );
1683
-
1684
- if (compact) {
1685
- return Column(
1686
- crossAxisAlignment: CrossAxisAlignment.start,
1687
- children: <Widget>[
1688
- channelPicker,
1689
- const SizedBox(height: 10),
1690
- autoCheck,
1691
- ],
1692
- );
1693
- }
1694
-
1695
- return Row(
1696
- crossAxisAlignment: CrossAxisAlignment.start,
1697
- children: <Widget>[
1698
- Expanded(child: channelPicker),
1699
- const SizedBox(width: 16),
1700
- Expanded(child: autoCheck),
1701
- ],
1702
- );
1703
- },
1704
- ),
1705
- const SizedBox(height: 8),
1706
- Text(
1707
- 'Installed: ${controller.installedAppVersion ?? 'Unknown'} | Channel: ${controller.appUpdateChannelLabel} | Last checked: ${controller.appUpdateLastCheckedLabel}',
1708
- style: TextStyle(color: _textSecondary),
1709
- ),
1710
- const SizedBox(height: 6),
1711
- Text(
1712
- 'Source: ${app_release_updater.appUpdaterGithubOwner}/${app_release_updater.appUpdaterGithubRepo}${app_release_updater.appUpdaterGithubToken.trim().isNotEmpty ? ' (override active)' : ''}',
1713
- style: TextStyle(color: _textSecondary),
1714
- ),
1715
- if (controller.appUpdateErrorMessage
1716
- case final message?) ...<Widget>[
1717
- const SizedBox(height: 12),
1718
- _InlineError(message: message),
1719
- ],
1720
- if (controller.availableAppUpdate
1721
- case final release?) ...<Widget>[
1722
- const SizedBox(height: 16),
1723
- Container(
1724
- width: double.infinity,
1725
- padding: const EdgeInsets.all(16),
1726
- decoration: BoxDecoration(
1727
- color: _bgSecondary,
1728
- borderRadius: BorderRadius.circular(18),
1729
- border: Border.all(color: _border),
1730
- ),
1731
- child: Column(
1732
- crossAxisAlignment: CrossAxisAlignment.start,
1733
- children: <Widget>[
1734
- Wrap(
1735
- spacing: 10,
1736
- runSpacing: 10,
1737
- children: <Widget>[
1738
- _StatusPill(
1739
- label: 'Update ${release.version}',
1740
- color: release.channel == 'beta'
1741
- ? _warning
1742
- : _accent,
1743
- ),
1744
- _StatusPill(
1745
- label: release.asset.name,
1746
- color: _textSecondary,
1747
- ),
1748
- ],
1749
- ),
1750
- const SizedBox(height: 10),
1751
- Text(
1752
- '${release.title} · ${release.publishedLabel} · ${release.asset.sizeLabel}',
1753
- style: TextStyle(color: _textSecondary),
1754
- ),
1755
- if (release.body.trim().isNotEmpty) ...<Widget>[
1756
- const SizedBox(height: 14),
1757
- ConstrainedBox(
1758
- constraints: const BoxConstraints(maxHeight: 220),
1759
- child: SingleChildScrollView(
1760
- child: MarkdownBody(
1761
- data: release.body,
1762
- selectable: true,
1763
- styleSheet: MarkdownStyleSheet(
1764
- p: TextStyle(
1765
- color: _textSecondary,
1766
- height: 1.45,
1767
- ),
1768
- ),
1769
- ),
1770
- ),
1771
- ),
1772
- ],
1773
- const SizedBox(height: 14),
1774
- Wrap(
1775
- spacing: 10,
1776
- runSpacing: 10,
1777
- children: <Widget>[
1778
- FilledButton.icon(
1779
- onPressed: controller.isOpeningAppUpdate
1780
- ? null
1781
- : controller.openAppUpdate,
1782
- style: FilledButton.styleFrom(
1783
- backgroundColor: _accent,
1784
- ),
1785
- icon: controller.isOpeningAppUpdate
1786
- ? _inlineProgressIndicator()
1787
- : const Icon(Icons.system_update_alt),
1788
- label: Text(
1789
- controller.isOpeningAppUpdate
1790
- ? 'Opening...'
1791
- : 'Download update',
1792
- ),
1793
- ),
1794
- if (release.htmlUrl.trim().isNotEmpty)
1795
- OutlinedButton.icon(
1796
- onPressed: () {
1797
- unawaited(
1798
- widget.controller._oauthLauncher
1799
- .openExternal(
1800
- url: release.htmlUrl,
1801
- label: 'release_notes',
1802
- ),
1803
- );
1804
- },
1805
- icon: const Icon(Icons.open_in_new),
1806
- label: Text('View release'),
1807
- ),
1808
- ],
1809
- ),
1810
- ],
1811
- ),
1812
- ),
1813
- ] else ...<Widget>[
1814
- const SizedBox(height: 12),
1815
- Text(
1816
- controller.isCheckingAppUpdate
1817
- ? 'Checking GitHub releases...'
1818
- : controller.appUpdateLastCheckedAt == null
1819
- ? 'Choose a channel, then check GitHub releases.'
1820
- : 'No newer app release is available on the selected channel.',
1821
- style: TextStyle(color: _textSecondary, height: 1.45),
1822
- ),
1823
- ],
1824
- ],
1825
- const Divider(height: 32),
1826
- if (controller.updateStatus.allowSelfUpdate) ...<Widget>[
1827
- LayoutBuilder(
1828
- builder: (context, constraints) {
1829
- final compact = constraints.maxWidth < 780;
1830
- final channelPicker = DropdownButtonFormField<String>(
1831
- initialValue: controller.updateStatus.releaseChannel,
1832
- decoration: const InputDecoration(
1833
- labelText: 'Runtime release channel',
1834
- ),
1835
- items: const <DropdownMenuItem<String>>[
1836
- DropdownMenuItem<String>(
1837
- value: 'stable',
1838
- child: Text('Stable'),
1839
- ),
1840
- DropdownMenuItem<String>(
1841
- value: 'beta',
1842
- child: Text('Beta'),
1843
- ),
1844
- ],
1845
- onChanged:
1846
- controller.isSavingReleaseChannel ||
1847
- controller.isTriggeringUpdate ||
1848
- controller.updateStatus.state == 'running'
1849
- ? null
1850
- : (value) {
1851
- if (value != null) {
1852
- unawaited(controller.setReleaseChannel(value));
1853
- }
1854
- },
1855
- );
1856
-
1857
- final channelHelper = Text(
1858
- controller.updateStatus.releaseChannel == 'beta'
1859
- ? 'Beta follows preview releases.'
1860
- : 'Stable follows production releases.',
1861
- style: TextStyle(color: _textSecondary),
1862
- );
1863
-
1864
- if (compact) {
1865
- return Column(
1866
- crossAxisAlignment: CrossAxisAlignment.start,
1867
- children: <Widget>[
1868
- channelPicker,
1869
- const SizedBox(height: 8),
1870
- channelHelper,
1871
- const SizedBox(height: 16),
1872
- ],
1873
- );
1874
- }
1875
-
1876
- return Padding(
1877
- padding: const EdgeInsets.only(bottom: 16),
1878
- child: Row(
1879
- crossAxisAlignment: CrossAxisAlignment.start,
1880
- children: <Widget>[
1881
- Expanded(child: channelPicker),
1882
- const SizedBox(width: 12),
1883
- Expanded(child: channelHelper),
1884
- ],
1885
- ),
1886
- );
1887
- },
1888
- ),
1889
- LayoutBuilder(
1890
- builder: (context, constraints) {
1891
- final compact = constraints.maxWidth < 780;
1892
- final runtimeTitle = Text(
1893
- 'Runtime',
1894
- style: TextStyle(
1895
- fontWeight: FontWeight.w700,
1896
- color: _textPrimary,
1897
- ),
1898
- );
1899
- final updateButton = FilledButton.icon(
1900
- onPressed:
1901
- controller.isSavingReleaseChannel ||
1902
- controller.isTriggeringUpdate ||
1903
- controller.updateStatus.state == 'running'
1904
- ? null
1905
- : controller.triggerUpdate,
1906
- style: FilledButton.styleFrom(backgroundColor: _accent),
1907
- icon: controller.isTriggeringUpdate
1908
- ? const SizedBox.square(
1909
- dimension: 16,
1910
- child: CircularProgressIndicator(
1911
- strokeWidth: 2,
1912
- color: Colors.white,
1913
- ),
1914
- )
1915
- : Icon(Icons.system_update),
1916
- label: Text('Update'),
1917
- );
1918
- if (compact) {
1919
- return Column(
1920
- crossAxisAlignment: CrossAxisAlignment.start,
1921
- children: <Widget>[
1922
- runtimeTitle,
1923
- const SizedBox(height: 10),
1924
- updateButton,
1925
- ],
1926
- );
1927
- }
1928
- return Row(
1929
- children: <Widget>[
1930
- Expanded(child: runtimeTitle),
1931
- updateButton,
1932
- ],
1933
- );
1934
- },
1935
- ),
1936
- ] else ...<Widget>[
1937
- Text(
1938
- 'Runtime',
1939
- style: TextStyle(
1940
- fontWeight: FontWeight.w700,
1941
- color: _textPrimary,
1942
- ),
1943
- ),
1944
- const SizedBox(height: 10),
1945
- Text(
1946
- 'Updates and release tracks are managed for this deployment.',
1947
- style: TextStyle(color: _textSecondary),
1948
- ),
1949
- ],
1950
- const SizedBox(height: 12),
1951
- LayoutBuilder(
1952
- builder: (context, constraints) {
1953
- final compact = constraints.maxWidth < 760;
1954
- final statusRow = Wrap(
1955
- spacing: 10,
1956
- runSpacing: 10,
1957
- crossAxisAlignment: WrapCrossAlignment.center,
1958
- children: <Widget>[
1959
- _StatusPill(
1960
- label: controller.updateStatus.badgeLabel,
1961
- color: controller.updateStatus.badgeColor,
1962
- ),
1963
- _StatusPill(
1964
- label: controller.updateStatus.releaseChannelLabel,
1965
- color: controller.updateStatus.releaseChannel == 'beta'
1966
- ? _warning
1967
- : _accent,
1968
- ),
1969
- Text(
1970
- controller.updateStatus.message,
1971
- style: TextStyle(color: _textSecondary),
1972
- ),
1973
- Text('${controller.updateStatus.progress}%'),
1974
- ],
1975
- );
1976
- if (compact) {
1977
- return Column(
1978
- crossAxisAlignment: CrossAxisAlignment.start,
1979
- children: <Widget>[statusRow],
1980
- );
1981
- }
1982
- return statusRow;
1983
- },
1984
- ),
1985
- const SizedBox(height: 10),
1986
- ClipRRect(
1987
- borderRadius: BorderRadius.circular(999),
1988
- child: LinearProgressIndicator(
1989
- minHeight: 8,
1990
- value: controller.updateStatus.progress / 100,
1991
- backgroundColor: _bgSecondary,
1992
- color: _accent,
1993
- ),
1994
- ),
1995
- const SizedBox(height: 12),
1996
- Text(controller.updateStatus.versionLine),
1997
- ],
1998
- ),
1999
- ),
2000
- );
2001
- }
2002
-
2003
1509
  Widget _buildDiagnosticsSection(NeoAgentController controller) {
2004
1510
  return Card(
2005
1511
  child: Padding(
@@ -2104,20 +1610,12 @@ class _SettingsPanelState extends State<SettingsPanel> {
2104
1610
  }
2105
1611
  }
2106
1612
 
2107
- List<DropdownMenuItem<String>> _recordingTranscriptionModelChoices(
2108
- String current,
2109
- ) {
2110
- const defaults = <String>['nova-3', 'nova-2-general'];
2111
- final normalizedCurrent = current.trim();
2112
- final values = <String>{...defaults};
2113
- if (normalizedCurrent.isNotEmpty) {
2114
- values.add(normalizedCurrent);
2115
- }
2116
- return values
2117
- .map(
2118
- (value) => DropdownMenuItem<String>(value: value, child: Text(value)),
2119
- )
2120
- .toList();
1613
+ List<_ModelPickerOption> _recordingTranscriptionOptions(String current) {
1614
+ const List<String> defaults = <String>['nova-3', 'nova-2-general'];
1615
+ final String normalizedCurrent = current.trim();
1616
+ final Set<String> values = <String>{...defaults};
1617
+ if (normalizedCurrent.isNotEmpty) values.add(normalizedCurrent);
1618
+ return _simplePickerOptions(values.toList());
2121
1619
  }
2122
1620
 
2123
1621
  // Shared helper: small "Test" button + inline result row.
@@ -2185,287 +1683,666 @@ class _SettingsPanelState extends State<SettingsPanel> {
2185
1683
  }
2186
1684
  }
2187
1685
 
2188
- class _AiProviderCard extends StatelessWidget {
2189
- const _AiProviderCard({
2190
- required this.provider,
2191
- required this.enabled,
2192
- required this.expanded,
2193
- required this.models,
2194
- required this.baseUrlController,
2195
- required this.onEnabledChanged,
2196
- required this.onExpandToggle,
2197
- });
2198
-
2199
- final AiProviderMeta provider;
2200
- final bool enabled;
2201
- final bool expanded;
2202
- final List<ModelMeta> models;
2203
- final TextEditingController baseUrlController;
2204
- final ValueChanged<bool> onEnabledChanged;
2205
- final VoidCallback onExpandToggle;
2206
-
2207
- @override
1686
+ class _RoutingSelectCard extends StatelessWidget {
1687
+ const _RoutingSelectCard({
1688
+ required this.label,
1689
+ required this.icon,
1690
+ required this.value,
1691
+ required this.options,
1692
+ required this.onChanged,
1693
+ });
1694
+
1695
+ final String label;
1696
+ final IconData icon;
1697
+ final String value;
1698
+ final List<_ModelPickerOption> options;
1699
+ final ValueChanged<String?> onChanged;
1700
+
1701
+ @override
2208
1702
  Widget build(BuildContext context) {
2209
- final availableCount = models.where((model) => model.available).length;
2210
- final hasAdvancedFields = provider.supportsBaseUrl || models.isNotEmpty;
2211
1703
  return Container(
2212
- padding: const EdgeInsets.all(16),
1704
+ padding: const EdgeInsets.all(14),
2213
1705
  decoration: BoxDecoration(
2214
1706
  color: _bgSecondary,
2215
- borderRadius: BorderRadius.circular(18),
2216
- border: Border.all(color: _borderLight),
1707
+ borderRadius: BorderRadius.circular(16),
1708
+ border: Border.all(color: _border),
2217
1709
  ),
2218
1710
  child: Column(
2219
1711
  crossAxisAlignment: CrossAxisAlignment.start,
2220
1712
  children: <Widget>[
2221
1713
  Row(
2222
- crossAxisAlignment: CrossAxisAlignment.start,
2223
1714
  children: <Widget>[
2224
- Container(
2225
- width: 44,
2226
- height: 44,
2227
- decoration: BoxDecoration(
2228
- color: _accentMuted,
2229
- borderRadius: BorderRadius.circular(14),
1715
+ Icon(icon, size: 16, color: _accentHover),
1716
+ const SizedBox(width: 8),
1717
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
1718
+ ],
1719
+ ),
1720
+ const SizedBox(height: 10),
1721
+ _ModelPickerButton(
1722
+ value: value,
1723
+ options: options,
1724
+ onChanged: onChanged,
1725
+ dialogTitle: 'Select $label',
1726
+ ),
1727
+ ],
1728
+ ),
1729
+ );
1730
+ }
1731
+ }
1732
+
1733
+ // ─────────────────────────────────────────────────────────────────────────────
1734
+ // Smart Pool Summary — compact summary card shown in settings
1735
+ // ─────────────────────────────────────────────────────────────────────────────
1736
+
1737
+ class _SmartPoolSummary extends StatelessWidget {
1738
+ const _SmartPoolSummary({
1739
+ required this.allModels,
1740
+ required this.selectedIds,
1741
+ required this.onManage,
1742
+ });
1743
+
1744
+ final List<ModelMeta> allModels;
1745
+ final Set<String> selectedIds;
1746
+ final VoidCallback onManage;
1747
+
1748
+ @override
1749
+ Widget build(BuildContext context) {
1750
+ final selected = allModels
1751
+ .where((m) => selectedIds.contains(m.id) && m.available)
1752
+ .toList();
1753
+ final providers = <String>{for (final m in selected) m.provider};
1754
+ final totalAvailable = allModels.where((m) => m.available).length;
1755
+
1756
+ return Container(
1757
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
1758
+ decoration: BoxDecoration(
1759
+ color: _bgSecondary,
1760
+ borderRadius: BorderRadius.circular(14),
1761
+ border: Border.all(color: _border),
1762
+ ),
1763
+ child: Row(
1764
+ children: <Widget>[
1765
+ Container(
1766
+ width: 38,
1767
+ height: 38,
1768
+ decoration: BoxDecoration(
1769
+ color: _accentMuted,
1770
+ borderRadius: BorderRadius.circular(10),
1771
+ ),
1772
+ child: Icon(Icons.hub_outlined, size: 18, color: _accentHover),
1773
+ ),
1774
+ const SizedBox(width: 12),
1775
+ Expanded(
1776
+ child: Column(
1777
+ crossAxisAlignment: CrossAxisAlignment.start,
1778
+ children: <Widget>[
1779
+ Text(
1780
+ '${selected.length} of $totalAvailable models',
1781
+ style: TextStyle(
1782
+ fontWeight: FontWeight.w600,
1783
+ fontSize: 14,
1784
+ color: _textPrimary,
1785
+ ),
2230
1786
  ),
2231
- child: Icon(provider.icon, color: _accentHover),
2232
- ),
2233
- const SizedBox(width: 12),
2234
- Expanded(
2235
- child: Column(
2236
- crossAxisAlignment: CrossAxisAlignment.start,
1787
+ const SizedBox(height: 5),
1788
+ Row(
2237
1789
  children: <Widget>[
2238
- Text(
2239
- provider.label,
2240
- style: TextStyle(
2241
- fontSize: 16,
2242
- fontWeight: FontWeight.w700,
2243
- ),
2244
- ),
2245
- const SizedBox(height: 4),
2246
- Text(
2247
- provider.description,
2248
- maxLines: 2,
2249
- overflow: TextOverflow.ellipsis,
2250
- style: TextStyle(color: _textSecondary, height: 1.4),
2251
- ),
1790
+ if (providers.isEmpty)
1791
+ Text(
1792
+ 'No models selected',
1793
+ style: TextStyle(fontSize: 12, color: _textMuted),
1794
+ )
1795
+ else
1796
+ ...providers.take(12).map(
1797
+ (p) => Container(
1798
+ width: 8,
1799
+ height: 8,
1800
+ margin: const EdgeInsets.only(right: 5),
1801
+ decoration: BoxDecoration(
1802
+ color: _providerPickerColor(p),
1803
+ shape: BoxShape.circle,
1804
+ ),
1805
+ ),
1806
+ ),
2252
1807
  ],
2253
1808
  ),
1809
+ ],
1810
+ ),
1811
+ ),
1812
+ const SizedBox(width: 10),
1813
+ OutlinedButton.icon(
1814
+ onPressed: onManage,
1815
+ icon: const Icon(Icons.tune_rounded, size: 14),
1816
+ label: const Text('Manage'),
1817
+ style: OutlinedButton.styleFrom(
1818
+ padding:
1819
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1820
+ textStyle: const TextStyle(fontSize: 13),
1821
+ ),
1822
+ ),
1823
+ ],
1824
+ ),
1825
+ );
1826
+ }
1827
+ }
1828
+
1829
+ // ─────────────────────────────────────────────────────────────────────────────
1830
+ // Smart Pool Dialog — searchable, grouped multi-select manager
1831
+ // ─────────────────────────────────────────────────────────────────────────────
1832
+
1833
+ class _SmartPoolDialog extends StatefulWidget {
1834
+ const _SmartPoolDialog({
1835
+ required this.models,
1836
+ required this.selectedIds,
1837
+ });
1838
+
1839
+ final List<ModelMeta> models;
1840
+ final Set<String> selectedIds;
1841
+
1842
+ @override
1843
+ State<_SmartPoolDialog> createState() => _SmartPoolDialogState();
1844
+ }
1845
+
1846
+ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
1847
+ late Set<String> _selected;
1848
+ final TextEditingController _searchCtrl = TextEditingController();
1849
+ String _query = '';
1850
+ bool _onlyAvailable = true;
1851
+
1852
+ @override
1853
+ void initState() {
1854
+ super.initState();
1855
+ _selected = Set<String>.from(widget.selectedIds);
1856
+ }
1857
+
1858
+ @override
1859
+ void dispose() {
1860
+ _searchCtrl.dispose();
1861
+ super.dispose();
1862
+ }
1863
+
1864
+ List<ModelMeta> get _filtered {
1865
+ var list = _onlyAvailable
1866
+ ? widget.models.where((m) => m.available).toList()
1867
+ : List<ModelMeta>.from(widget.models);
1868
+ if (_query.isNotEmpty) {
1869
+ final q = _query.toLowerCase();
1870
+ list = list
1871
+ .where((m) =>
1872
+ m.label.toLowerCase().contains(q) ||
1873
+ m.id.toLowerCase().contains(q) ||
1874
+ m.provider.toLowerCase().contains(q))
1875
+ .toList();
1876
+ }
1877
+ return list;
1878
+ }
1879
+
1880
+ void _selectAllVisible(List<ModelMeta> filtered) {
1881
+ setState(() {
1882
+ for (final m in filtered) {
1883
+ if (m.available) _selected.add(m.id);
1884
+ }
1885
+ });
1886
+ }
1887
+
1888
+ void _clearAllVisible(List<ModelMeta> filtered) {
1889
+ setState(() {
1890
+ final toRemove = filtered.map((m) => m.id).toSet();
1891
+ final remaining = _selected.difference(toRemove);
1892
+ _selected = remaining.isNotEmpty
1893
+ ? remaining
1894
+ : <String>{_selected.first};
1895
+ });
1896
+ }
1897
+
1898
+ @override
1899
+ Widget build(BuildContext context) {
1900
+ final filtered = _filtered;
1901
+
1902
+ // Build grouped structure
1903
+ final Map<String, List<ModelMeta>> grouped = <String, List<ModelMeta>>{};
1904
+ for (final m in filtered) {
1905
+ grouped.putIfAbsent(m.provider, () => <ModelMeta>[]).add(m);
1906
+ }
1907
+ final providerOrder = grouped.keys.toList();
1908
+
1909
+ final selectedAvailableCount = widget.models
1910
+ .where((m) => _selected.contains(m.id) && m.available)
1911
+ .length;
1912
+
1913
+ // Build flat row list (headers + model rows)
1914
+ final List<Widget> rows = <Widget>[];
1915
+ for (final provider in providerOrder) {
1916
+ final models = grouped[provider]!;
1917
+ final providerColor = _providerPickerColor(provider);
1918
+ final available = models.where((m) => m.available).toList();
1919
+ final allGroupSelected = available.isNotEmpty &&
1920
+ available.every((m) => _selected.contains(m.id));
1921
+
1922
+ rows.add(Padding(
1923
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
1924
+ child: Row(
1925
+ children: <Widget>[
1926
+ Container(
1927
+ width: 6,
1928
+ height: 6,
1929
+ decoration: BoxDecoration(
1930
+ color: providerColor,
1931
+ shape: BoxShape.circle,
1932
+ ),
1933
+ ),
1934
+ const SizedBox(width: 8),
1935
+ Expanded(
1936
+ child: Text(
1937
+ _providerPickerLabel(provider).toUpperCase(),
1938
+ style: TextStyle(
1939
+ fontSize: 10.5,
1940
+ fontWeight: FontWeight.w700,
1941
+ color: _textMuted,
1942
+ letterSpacing: 0.8,
1943
+ ),
2254
1944
  ),
2255
- const SizedBox(width: 8),
2256
- Column(
2257
- crossAxisAlignment: CrossAxisAlignment.end,
2258
- children: <Widget>[
2259
- _StatusPill(
2260
- label: enabled ? provider.statusLabel : 'Disabled',
2261
- color: enabled ? provider.statusColor : _textSecondary,
2262
- ),
2263
- const SizedBox(height: 8),
2264
- InkWell(
2265
- onTap: hasAdvancedFields || models.isNotEmpty
2266
- ? onExpandToggle
2267
- : null,
2268
- borderRadius: BorderRadius.circular(999),
2269
- child: Container(
2270
- padding: const EdgeInsets.symmetric(
2271
- horizontal: 10,
2272
- vertical: 6,
1945
+ ),
1946
+ const SizedBox(width: 8),
1947
+ GestureDetector(
1948
+ onTap: available.isEmpty
1949
+ ? null
1950
+ : () {
1951
+ setState(() {
1952
+ if (allGroupSelected) {
1953
+ final toRemove =
1954
+ available.map((m) => m.id).toSet();
1955
+ final remaining =
1956
+ _selected.difference(toRemove);
1957
+ _selected = remaining.isNotEmpty
1958
+ ? remaining
1959
+ : <String>{_selected.first};
1960
+ } else {
1961
+ for (final m in available) {
1962
+ _selected.add(m.id);
1963
+ }
1964
+ }
1965
+ });
1966
+ },
1967
+ child: Text(
1968
+ allGroupSelected ? 'None' : 'All',
1969
+ style: TextStyle(
1970
+ fontSize: 11,
1971
+ fontWeight: FontWeight.w600,
1972
+ color: available.isEmpty ? _textMuted : _accent,
1973
+ ),
1974
+ ),
1975
+ ),
1976
+ ],
1977
+ ),
1978
+ ));
1979
+
1980
+ for (final model in models) {
1981
+ rows.add(_SmartPoolRow(
1982
+ model: model,
1983
+ selected: _selected.contains(model.id),
1984
+ onToggle: (val) => setState(() {
1985
+ if (val) {
1986
+ _selected.add(model.id);
1987
+ } else if (_selected.length > 1) {
1988
+ _selected.remove(model.id);
1989
+ }
1990
+ }),
1991
+ ));
1992
+ }
1993
+ }
1994
+
1995
+ return Center(
1996
+ child: ConstrainedBox(
1997
+ constraints: BoxConstraints(
1998
+ maxWidth: 560,
1999
+ minWidth: 320,
2000
+ maxHeight: MediaQuery.sizeOf(context).height * 0.85,
2001
+ ),
2002
+ child: Padding(
2003
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
2004
+ child: Material(
2005
+ color: _bgCard,
2006
+ borderRadius: BorderRadius.circular(20),
2007
+ elevation: 24,
2008
+ shadowColor: Colors.black.withValues(alpha: 0.5),
2009
+ child: Container(
2010
+ decoration: BoxDecoration(
2011
+ borderRadius: BorderRadius.circular(20),
2012
+ border: Border.all(color: _borderLight),
2013
+ ),
2014
+ child: ClipRRect(
2015
+ borderRadius: BorderRadius.circular(20),
2016
+ child: Column(
2017
+ mainAxisSize: MainAxisSize.min,
2018
+ children: <Widget>[
2019
+ // Header
2020
+ Padding(
2021
+ padding: const EdgeInsets.fromLTRB(20, 16, 10, 0),
2022
+ child: Row(
2023
+ children: <Widget>[
2024
+ Expanded(
2025
+ child: Text(
2026
+ 'Smart Selector Pool',
2027
+ style: TextStyle(
2028
+ fontSize: 17,
2029
+ fontWeight: FontWeight.w700,
2030
+ color: _textPrimary,
2031
+ ),
2032
+ ),
2033
+ ),
2034
+ IconButton(
2035
+ onPressed: () =>
2036
+ Navigator.of(context).pop(_selected),
2037
+ icon: Icon(
2038
+ Icons.close_rounded,
2039
+ size: 20,
2040
+ color: _textSecondary,
2041
+ ),
2042
+ style: IconButton.styleFrom(
2043
+ minimumSize: const Size(36, 36),
2044
+ padding: EdgeInsets.zero,
2045
+ tapTargetSize:
2046
+ MaterialTapTargetSize.shrinkWrap,
2047
+ ),
2048
+ ),
2049
+ ],
2273
2050
  ),
2274
- decoration: BoxDecoration(
2275
- color: _bgCard,
2276
- borderRadius: BorderRadius.circular(999),
2277
- border: Border.all(color: _border),
2051
+ ),
2052
+ // Search + available toggle
2053
+ Padding(
2054
+ padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
2055
+ child: Row(
2056
+ children: <Widget>[
2057
+ Expanded(
2058
+ child: TextField(
2059
+ controller: _searchCtrl,
2060
+ autofocus: true,
2061
+ onChanged: (v) =>
2062
+ setState(() => _query = v.trim()),
2063
+ style: TextStyle(
2064
+ color: _textPrimary,
2065
+ fontSize: 14,
2066
+ ),
2067
+ decoration: InputDecoration(
2068
+ hintText: 'Search models or providers…',
2069
+ hintStyle: TextStyle(
2070
+ color: _textMuted,
2071
+ fontSize: 14,
2072
+ ),
2073
+ prefixIcon: Icon(
2074
+ Icons.search_rounded,
2075
+ size: 18,
2076
+ color: _textMuted,
2077
+ ),
2078
+ suffixIcon: _query.isNotEmpty
2079
+ ? GestureDetector(
2080
+ onTap: () => setState(() {
2081
+ _searchCtrl.clear();
2082
+ _query = '';
2083
+ }),
2084
+ child: Padding(
2085
+ padding: const EdgeInsets.all(10),
2086
+ child: Icon(
2087
+ Icons.cancel_rounded,
2088
+ size: 16,
2089
+ color: _textMuted,
2090
+ ),
2091
+ ),
2092
+ )
2093
+ : null,
2094
+ isDense: true,
2095
+ contentPadding: const EdgeInsets.symmetric(
2096
+ vertical: 10),
2097
+ filled: true,
2098
+ fillColor: _bgSecondary,
2099
+ border: OutlineInputBorder(
2100
+ borderRadius: BorderRadius.circular(12),
2101
+ borderSide:
2102
+ BorderSide(color: _border),
2103
+ ),
2104
+ enabledBorder: OutlineInputBorder(
2105
+ borderRadius: BorderRadius.circular(12),
2106
+ borderSide:
2107
+ BorderSide(color: _border),
2108
+ ),
2109
+ focusedBorder: OutlineInputBorder(
2110
+ borderRadius: BorderRadius.circular(12),
2111
+ borderSide: BorderSide(
2112
+ color: _accent,
2113
+ width: 1.5,
2114
+ ),
2115
+ ),
2116
+ ),
2117
+ ),
2118
+ ),
2119
+ const SizedBox(width: 8),
2120
+ GestureDetector(
2121
+ onTap: () => setState(
2122
+ () => _onlyAvailable = !_onlyAvailable),
2123
+ child: AnimatedContainer(
2124
+ duration: const Duration(milliseconds: 150),
2125
+ padding: const EdgeInsets.symmetric(
2126
+ horizontal: 10,
2127
+ vertical: 7,
2128
+ ),
2129
+ decoration: BoxDecoration(
2130
+ color: _onlyAvailable
2131
+ ? _accentMuted
2132
+ : _bgSecondary,
2133
+ borderRadius: BorderRadius.circular(8),
2134
+ border: Border.all(
2135
+ color: _onlyAvailable
2136
+ ? _accent.withValues(alpha: 0.5)
2137
+ : _border,
2138
+ ),
2139
+ ),
2140
+ child: Text(
2141
+ 'Available',
2142
+ style: TextStyle(
2143
+ fontSize: 12,
2144
+ fontWeight: FontWeight.w600,
2145
+ color: _onlyAvailable
2146
+ ? _accentHover
2147
+ : _textSecondary,
2148
+ ),
2149
+ ),
2150
+ ),
2151
+ ),
2152
+ ],
2278
2153
  ),
2154
+ ),
2155
+ // Quick-action toolbar
2156
+ Padding(
2157
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
2279
2158
  child: Row(
2280
- mainAxisSize: MainAxisSize.min,
2281
2159
  children: <Widget>[
2282
- Text(
2283
- expanded ? 'Hide' : 'Setup',
2284
- style: TextStyle(fontSize: 12),
2160
+ _PoolActionChip(
2161
+ label: 'Select all',
2162
+ onTap: () => _selectAllVisible(filtered),
2285
2163
  ),
2286
- const SizedBox(width: 4),
2287
- Icon(
2288
- expanded
2289
- ? Icons.keyboard_arrow_up
2290
- : Icons.keyboard_arrow_down,
2291
- size: 16,
2292
- color: _textSecondary,
2164
+ const SizedBox(width: 6),
2165
+ _PoolActionChip(
2166
+ label: 'Clear all',
2167
+ onTap: () => _clearAllVisible(filtered),
2168
+ ),
2169
+ const Spacer(),
2170
+ Text(
2171
+ '$selectedAvailableCount selected',
2172
+ style: TextStyle(
2173
+ fontSize: 12,
2174
+ color: _textMuted,
2175
+ ),
2293
2176
  ),
2294
2177
  ],
2295
2178
  ),
2296
2179
  ),
2297
- ),
2298
- ],
2299
- ),
2300
- ],
2301
- ),
2302
- const SizedBox(height: 12),
2303
- Wrap(
2304
- spacing: 8,
2305
- runSpacing: 8,
2306
- children: <Widget>[
2307
- _MetaPill(
2308
- label: '$availableCount of ${models.length} models ready',
2309
- icon: Icons.memory_outlined,
2310
- ),
2311
- if (provider.supportsApiKey && provider.credentialConfigured)
2312
- const _MetaPill(
2313
- label: 'Credentials ready',
2314
- icon: Icons.lock_outline,
2315
- ),
2316
- if (provider.supportsApiKey && !provider.credentialConfigured)
2317
- const _MetaPill(
2318
- label: 'Credentials needed',
2319
- icon: Icons.admin_panel_settings_outlined,
2320
- ),
2321
- if (provider.supportsBaseUrl &&
2322
- baseUrlController.text.trim().isNotEmpty)
2323
- _MetaPill(
2324
- label: _friendlyBaseUrlLabel(baseUrlController.text.trim()),
2325
- icon: Icons.link_outlined,
2180
+ Divider(height: 1, thickness: 1, color: _border),
2181
+ // Model list
2182
+ Flexible(
2183
+ child: rows.isEmpty
2184
+ ? Padding(
2185
+ padding: const EdgeInsets.all(36),
2186
+ child: Column(
2187
+ mainAxisSize: MainAxisSize.min,
2188
+ children: <Widget>[
2189
+ Icon(
2190
+ Icons.search_off_rounded,
2191
+ size: 36,
2192
+ color: _textMuted,
2193
+ ),
2194
+ const SizedBox(height: 12),
2195
+ Text(
2196
+ 'No results for "$_query"',
2197
+ style: TextStyle(
2198
+ color: _textSecondary,
2199
+ fontSize: 14,
2200
+ ),
2201
+ ),
2202
+ ],
2203
+ ),
2204
+ )
2205
+ : ListView(
2206
+ padding:
2207
+ const EdgeInsets.only(top: 4, bottom: 8),
2208
+ shrinkWrap: true,
2209
+ children: rows,
2210
+ ),
2211
+ ),
2212
+ ],
2326
2213
  ),
2327
- ],
2328
- ),
2329
- const SizedBox(height: 12),
2330
- Container(
2331
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
2332
- decoration: BoxDecoration(
2333
- color: _bgCard,
2334
- borderRadius: BorderRadius.circular(14),
2335
- border: Border.all(color: _border),
2214
+ ),
2336
2215
  ),
2216
+ ),
2217
+ ),
2218
+ ),
2219
+ );
2220
+ }
2221
+ }
2222
+
2223
+ // ─────────────────────────────────────────────────────────────────────────────
2224
+ // Smart Pool Row — individual model row inside the dialog
2225
+ // ─────────────────────────────────────────────────────────────────────────────
2226
+
2227
+ class _SmartPoolRow extends StatelessWidget {
2228
+ const _SmartPoolRow({
2229
+ required this.model,
2230
+ required this.selected,
2231
+ required this.onToggle,
2232
+ });
2233
+
2234
+ final ModelMeta model;
2235
+ final bool selected;
2236
+ final ValueChanged<bool> onToggle;
2237
+
2238
+ @override
2239
+ Widget build(BuildContext context) {
2240
+ final color = _providerPickerColor(model.provider);
2241
+ return Opacity(
2242
+ opacity: model.available ? 1.0 : 0.4,
2243
+ child: Material(
2244
+ color: selected
2245
+ ? _accentMuted.withValues(alpha: 0.12)
2246
+ : Colors.transparent,
2247
+ child: InkWell(
2248
+ onTap: model.available ? () => onToggle(!selected) : null,
2249
+ child: Padding(
2250
+ padding:
2251
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
2337
2252
  child: Row(
2338
2253
  children: <Widget>[
2339
- Expanded(
2340
- child: Text(
2341
- provider.availabilityReason,
2342
- style: TextStyle(color: _textSecondary, height: 1.35),
2254
+ // Thin provider accent bar on the left
2255
+ Container(
2256
+ width: 3,
2257
+ height: 30,
2258
+ decoration: BoxDecoration(
2259
+ color: color.withValues(
2260
+ alpha: selected ? 0.85 : 0.28),
2261
+ borderRadius: BorderRadius.circular(2),
2343
2262
  ),
2344
2263
  ),
2345
- const SizedBox(width: 12),
2346
- Switch(value: enabled, onChanged: onEnabledChanged),
2347
- ],
2348
- ),
2349
- ),
2350
- if (expanded) ...<Widget>[
2351
- const SizedBox(height: 14),
2352
- if (provider.supportsApiKey)
2353
- Container(
2354
- width: double.infinity,
2355
- padding: const EdgeInsets.all(12),
2356
- margin: const EdgeInsets.only(bottom: 12),
2357
- decoration: BoxDecoration(
2358
- color: _bgCard,
2359
- borderRadius: BorderRadius.circular(14),
2360
- border: Border.all(color: _border),
2361
- ),
2362
- child: Text(
2363
- provider.credentialConfigured
2364
- ? 'Credentials for this provider are already available to the runtime.'
2365
- : 'Credentials for this provider are managed outside this workspace UI. Finish the server or admin setup, then return here to enable routing.',
2366
- style: TextStyle(color: _textSecondary, height: 1.35),
2367
- ),
2368
- ),
2369
- if (provider.supportsBaseUrl) ...<Widget>[
2370
- TextField(
2371
- controller: baseUrlController,
2372
- keyboardType: TextInputType.url,
2373
- autocorrect: false,
2374
- decoration: InputDecoration(
2375
- labelText: provider.id == 'ollama'
2376
- ? 'Server URL'
2377
- : 'Base URL',
2378
- helperText: provider.defaultBaseUrl.trim().isEmpty
2379
- ? 'Optional override.'
2380
- : 'Default: ${provider.defaultBaseUrl}',
2264
+ const SizedBox(width: 10),
2265
+ SizedBox(
2266
+ width: 20,
2267
+ height: 20,
2268
+ child: Checkbox(
2269
+ value: selected,
2270
+ onChanged: model.available
2271
+ ? (v) => onToggle(v ?? false)
2272
+ : null,
2273
+ activeColor: _accent,
2274
+ side: BorderSide(color: _textMuted, width: 1.5),
2275
+ materialTapTargetSize:
2276
+ MaterialTapTargetSize.shrinkWrap,
2277
+ visualDensity: VisualDensity.compact,
2278
+ ),
2381
2279
  ),
2382
- ),
2383
- const SizedBox(height: 12),
2384
- ],
2385
- if (models.isNotEmpty) ...<Widget>[
2386
- Text('Models', style: TextStyle(fontWeight: FontWeight.w700)),
2387
- const SizedBox(height: 8),
2388
- Wrap(
2389
- spacing: 8,
2390
- runSpacing: 8,
2391
- children: models
2392
- .map(
2393
- (model) => Container(
2394
- padding: const EdgeInsets.symmetric(
2395
- horizontal: 10,
2396
- vertical: 8,
2397
- ),
2398
- decoration: BoxDecoration(
2399
- color: model.available ? _bgCard : _bgPrimary,
2400
- borderRadius: BorderRadius.circular(999),
2401
- border: Border.all(
2402
- color: model.available ? _border : _borderLight,
2403
- ),
2280
+ const SizedBox(width: 10),
2281
+ Expanded(
2282
+ child: Column(
2283
+ crossAxisAlignment: CrossAxisAlignment.start,
2284
+ children: <Widget>[
2285
+ Text(
2286
+ model.label,
2287
+ style: TextStyle(
2288
+ fontSize: 13,
2289
+ fontWeight: FontWeight.w500,
2290
+ color:
2291
+ selected ? _accentHover : _textPrimary,
2404
2292
  ),
2405
- child: Text(
2406
- model.label,
2293
+ maxLines: 1,
2294
+ overflow: TextOverflow.ellipsis,
2295
+ ),
2296
+ if (model.purpose.isNotEmpty)
2297
+ Text(
2298
+ model.purpose,
2407
2299
  style: TextStyle(
2408
- fontSize: 12,
2409
- color: model.available
2410
- ? _textPrimary
2411
- : _textSecondary,
2300
+ fontSize: 11,
2301
+ color: _textMuted,
2412
2302
  ),
2413
2303
  ),
2414
- ),
2415
- )
2416
- .toList(),
2417
- ),
2418
- ],
2419
- ],
2420
- ],
2304
+ ],
2305
+ ),
2306
+ ),
2307
+ const SizedBox(width: 8),
2308
+ if (model.priceTier != null)
2309
+ _PriceTierChip(tier: model.priceTier!),
2310
+ const SizedBox(width: 2),
2311
+ ],
2312
+ ),
2313
+ ),
2314
+ ),
2421
2315
  ),
2422
2316
  );
2423
2317
  }
2424
2318
  }
2425
2319
 
2426
- class _RoutingSelectCard extends StatelessWidget {
2427
- const _RoutingSelectCard({
2428
- required this.label,
2429
- required this.icon,
2430
- required this.value,
2431
- required this.items,
2432
- required this.onChanged,
2433
- });
2434
-
2320
+ // Toolbar chip button used inside _SmartPoolDialog
2321
+ class _PoolActionChip extends StatelessWidget {
2322
+ const _PoolActionChip({required this.label, required this.onTap});
2435
2323
  final String label;
2436
- final IconData icon;
2437
- final String value;
2438
- final List<DropdownMenuItem<String>> items;
2439
- final ValueChanged<String?> onChanged;
2324
+ final VoidCallback onTap;
2440
2325
 
2441
2326
  @override
2442
2327
  Widget build(BuildContext context) {
2443
- return Container(
2444
- padding: const EdgeInsets.all(14),
2445
- decoration: BoxDecoration(
2446
- color: _bgSecondary,
2447
- borderRadius: BorderRadius.circular(16),
2448
- border: Border.all(color: _border),
2449
- ),
2450
- child: Column(
2451
- crossAxisAlignment: CrossAxisAlignment.start,
2452
- children: <Widget>[
2453
- Row(
2454
- children: <Widget>[
2455
- Icon(icon, size: 16, color: _accentHover),
2456
- const SizedBox(width: 8),
2457
- Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2458
- ],
2459
- ),
2460
- const SizedBox(height: 10),
2461
- DropdownButtonFormField<String>(
2462
- initialValue: value,
2463
- items: items,
2464
- isExpanded: true,
2465
- decoration: const InputDecoration(isDense: true),
2466
- onChanged: onChanged,
2328
+ return GestureDetector(
2329
+ onTap: onTap,
2330
+ child: Container(
2331
+ padding:
2332
+ const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
2333
+ decoration: BoxDecoration(
2334
+ color: _bgSecondary,
2335
+ borderRadius: BorderRadius.circular(8),
2336
+ border: Border.all(color: _border),
2337
+ ),
2338
+ child: Text(
2339
+ label,
2340
+ style: TextStyle(
2341
+ fontSize: 12,
2342
+ fontWeight: FontWeight.w500,
2343
+ color: _textSecondary,
2467
2344
  ),
2468
- ],
2345
+ ),
2469
2346
  ),
2470
2347
  );
2471
2348
  }