neoagent 2.4.1-beta.9 β†’ 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +36 -4
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +2 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +673 -871
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -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,7 +154,6 @@ const List<_SettingsSection> _settingsSearchSections = <_SettingsSection>[
163
154
  _modelsSettingsSection,
164
155
  _voiceRecordingSettingsSection,
165
156
  _desktopSettingsSection,
166
- _updatesSettingsSection,
167
157
  _diagnosticsSettingsSection,
168
158
  ];
169
159
 
@@ -187,7 +177,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
187
177
  final Map<String, bool> _providerEnabled = <String, bool>{};
188
178
  final Map<String, TextEditingController> _providerBaseUrlControllers =
189
179
  <String, TextEditingController>{};
190
- final Set<String> _expandedProviderIds = <String>{};
191
180
 
192
181
  // Inline runtime test state β€” ephemeral, not stored in controller.
193
182
  bool _cliTestRunning = false;
@@ -313,26 +302,8 @@ class _SettingsPanelState extends State<SettingsPanel> {
313
302
  final routingModels = availableModels.isEmpty
314
303
  ? controller.supportedModels
315
304
  : availableModels;
316
- final modelChoices = <DropdownMenuItem<String>>[
317
- const DropdownMenuItem<String>(
318
- value: 'auto',
319
- child: Text(
320
- 'Smart Selector (Auto)',
321
- maxLines: 1,
322
- overflow: TextOverflow.ellipsis,
323
- ),
324
- ),
325
- ...routingModels.map(
326
- (model) => DropdownMenuItem<String>(
327
- value: model.id,
328
- child: Text(
329
- model.label,
330
- maxLines: 1,
331
- overflow: TextOverflow.ellipsis,
332
- ),
333
- ),
334
- ),
335
- ];
305
+ final List<_ModelPickerOption> modelChoices =
306
+ _modelPickerOptions(routingModels, allowAuto: true);
336
307
  final enabledSmartModels = _enabledModels
337
308
  .where((id) => routingModels.any((model) => model.id == id))
338
309
  .length;
@@ -347,7 +318,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
347
318
  _PageTitle(
348
319
  title: 'Settings',
349
320
  subtitle:
350
- 'Workspace, models, recording, update, and diagnostics controls.',
321
+ 'Workspace, models, recording, and diagnostics controls.',
351
322
  trailing: _settingsSaveButton(controller),
352
323
  )
353
324
  else
@@ -367,7 +338,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
367
338
  onChanged: (_) => setState(() {}),
368
339
  decoration: InputDecoration(
369
340
  labelText: 'Search settings',
370
- hintText: 'Models, browser, voice, updates, diagnostics...',
341
+ hintText: 'Models, browser, voice, diagnostics...',
371
342
  prefixIcon: const Icon(Icons.search),
372
343
  suffixIcon: searchQuery.isEmpty
373
344
  ? null
@@ -400,6 +371,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
400
371
  _modelsSettingsSection,
401
372
  )) ...<Widget>[
402
373
  _buildModelsSection(
374
+ context: context,
403
375
  controller: controller,
404
376
  modelChoices: modelChoices,
405
377
  routingModels: routingModels,
@@ -427,13 +399,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
427
399
  _buildDesktopSection(controller),
428
400
  const SizedBox(height: 16),
429
401
  ],
430
- if (_matchesSettingsSection(
431
- searchQuery,
432
- _updatesSettingsSection,
433
- )) ...<Widget>[
434
- _buildUpdatesSection(controller),
435
- const SizedBox(height: 16),
436
- ],
437
402
  if (_matchesSettingsSection(
438
403
  searchQuery,
439
404
  _diagnosticsSettingsSection,
@@ -442,7 +407,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
442
407
  const _EmptyCard(
443
408
  title: 'No matching settings',
444
409
  subtitle:
445
- 'Try a broader search like models, browser, voice, or updates.',
410
+ 'Try a broader search like models, browser, or voice.',
446
411
  ),
447
412
  ],
448
413
  ],
@@ -552,7 +517,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
552
517
  const _SectionTitle('Overview'),
553
518
  const SizedBox(height: 10),
554
519
  Text(
555
- 'Configure workspace behavior, then models, recording defaults, and updates.',
520
+ 'Configure workspace behavior, models, and recording defaults.',
556
521
  style: TextStyle(color: _textSecondary, height: 1.45),
557
522
  ),
558
523
  const SizedBox(height: 14),
@@ -871,8 +836,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
871
836
  }
872
837
 
873
838
  Widget _buildModelsSection({
839
+ required BuildContext context,
874
840
  required NeoAgentController controller,
875
- required List<DropdownMenuItem<String>> modelChoices,
841
+ required List<_ModelPickerOption> modelChoices,
876
842
  required List<ModelMeta> routingModels,
877
843
  required List<ModelMeta> availableModels,
878
844
  required int enabledSmartModels,
@@ -886,88 +852,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
886
852
  const _SectionTitle('Models'),
887
853
  const SizedBox(height: 10),
888
854
  Text(
889
- 'Enable providers, then choose defaults for chat, agents, fallback behavior, and smart routing.',
855
+ 'Choose defaults for chat, agents, fallback behavior, and smart routing.',
890
856
  style: TextStyle(color: _textSecondary, height: 1.45),
891
857
  ),
892
858
  const SizedBox(height: 16),
893
- Text(
894
- 'Providers',
895
- style: TextStyle(
896
- fontWeight: FontWeight.w700,
897
- color: _textPrimary,
898
- ),
899
- ),
900
- const SizedBox(height: 14),
901
- if (controller.aiProviders.isEmpty)
902
- Text(
903
- 'Provider metadata is unavailable on this server version.',
904
- style: TextStyle(color: _textSecondary),
905
- )
906
- else
907
- LayoutBuilder(
908
- builder: (context, constraints) {
909
- final compact = constraints.maxWidth < 960;
910
- final cardWidth = compact
911
- ? constraints.maxWidth
912
- : (constraints.maxWidth - 16) / 2;
913
- return Wrap(
914
- spacing: 16,
915
- runSpacing: 16,
916
- children: controller.aiProviders
917
- .where(
918
- (provider) =>
919
- provider.available ||
920
- _providerEnabled[provider.id] == true ||
921
- controller
922
- .aiProviderConfigs[provider.id]
923
- ?.enabled ==
924
- true,
925
- )
926
- .map((provider) {
927
- return SizedBox(
928
- width: cardWidth,
929
- child: _AiProviderCard(
930
- provider: provider,
931
- enabled:
932
- _providerEnabled[provider.id] ??
933
- controller
934
- .aiProviderConfigs[provider.id]
935
- ?.enabled ??
936
- true,
937
- models: controller.supportedModels
938
- .where(
939
- (model) => model.provider == provider.id,
940
- )
941
- .toList(),
942
- baseUrlController:
943
- _providerBaseUrlControllers[provider.id]!,
944
- expanded: _expandedProviderIds.contains(
945
- provider.id,
946
- ),
947
- onEnabledChanged: (value) {
948
- setState(() {
949
- _providerEnabled[provider.id] = value;
950
- });
951
- },
952
- onExpandToggle: () {
953
- setState(() {
954
- if (_expandedProviderIds.contains(
955
- provider.id,
956
- )) {
957
- _expandedProviderIds.remove(provider.id);
958
- } else {
959
- _expandedProviderIds.add(provider.id);
960
- }
961
- });
962
- },
963
- ),
964
- );
965
- })
966
- .toList(),
967
- );
968
- },
969
- ),
970
- const Divider(height: 32),
971
859
  Text(
972
860
  'Default Routing',
973
861
  style: TextStyle(
@@ -997,7 +885,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
997
885
  routingModels,
998
886
  allowAuto: true,
999
887
  ),
1000
- items: modelChoices,
888
+ options: modelChoices,
1001
889
  onChanged: (value) {
1002
890
  if (value != null) {
1003
891
  setState(() => _defaultChatModel = value);
@@ -1015,7 +903,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1015
903
  routingModels,
1016
904
  allowAuto: true,
1017
905
  ),
1018
- items: modelChoices,
906
+ options: modelChoices,
1019
907
  onChanged: (value) {
1020
908
  if (value != null) {
1021
909
  setState(() => _defaultSubagentModel = value);
@@ -1033,18 +921,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1033
921
  routingModels,
1034
922
  allowAuto: false,
1035
923
  ),
1036
- items: routingModels
1037
- .map(
1038
- (model) => DropdownMenuItem<String>(
1039
- value: model.id,
1040
- child: Text(
1041
- model.label,
1042
- maxLines: 1,
1043
- overflow: TextOverflow.ellipsis,
1044
- ),
1045
- ),
1046
- )
1047
- .toList(),
924
+ options: _modelPickerOptions(routingModels),
1048
925
  onChanged: (value) {
1049
926
  if (value != null) {
1050
927
  setState(() => _fallbackModel = value);
@@ -1065,46 +942,38 @@ class _SettingsPanelState extends State<SettingsPanel> {
1065
942
  ),
1066
943
  ),
1067
944
  const SizedBox(height: 10),
1068
- Wrap(
1069
- spacing: 10,
1070
- runSpacing: 10,
1071
- children: controller.supportedModels.map((model) {
1072
- final selected = _enabledModels.contains(model.id);
1073
- return FilterChip(
1074
- label: Text(
1075
- model.available
1076
- ? model.label
1077
- : '${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
+ ),
1078
969
  ),
1079
- selected: selected,
1080
- selectedColor: _accentMuted,
1081
- checkmarkColor: _accent,
1082
- backgroundColor: _bgSecondary,
1083
- side: BorderSide(
1084
- color: model.available
1085
- ? _border
1086
- : _warning.withValues(alpha: 0.35),
970
+ pageBuilder: (ctx, _, __) => _SmartPoolDialog(
971
+ models: controller.supportedModels,
972
+ selectedIds: _enabledModels,
1087
973
  ),
1088
- onSelected: model.available
1089
- ? (value) {
1090
- setState(() {
1091
- if (value) {
1092
- _enabledModels.add(model.id);
1093
- } else if (_enabledModels.length > 1) {
1094
- _enabledModels.remove(model.id);
1095
- }
1096
- });
1097
- }
1098
- : null,
1099
974
  );
1100
- }).toList(),
1101
- ),
1102
- const SizedBox(height: 14),
1103
- Text(
1104
- availableModels.isEmpty
1105
- ? 'Enable a ready provider above to unlock model routing.'
1106
- : '$enabledSmartModels models are currently eligible for smart routing.',
1107
- style: TextStyle(color: _textSecondary),
975
+ if (result != null) setState(() => _enabledModels = result);
976
+ },
1108
977
  ),
1109
978
  ],
1110
979
  ),
@@ -1114,7 +983,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1114
983
 
1115
984
  Widget _buildVoiceAndRecordingSection({
1116
985
  required NeoAgentController controller,
1117
- required List<DropdownMenuItem<String>> modelChoices,
986
+ required List<_ModelPickerOption> modelChoices,
1118
987
  required List<ModelMeta> routingModels,
1119
988
  }) {
1120
989
  final liveVoiceOptions =
@@ -1160,7 +1029,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1160
1029
  routingModels,
1161
1030
  allowAuto: true,
1162
1031
  ),
1163
- items: modelChoices,
1032
+ options: modelChoices,
1164
1033
  onChanged: (value) {
1165
1034
  if (value != null) {
1166
1035
  setState(
@@ -1176,7 +1045,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1176
1045
  label: 'Recording Transcription',
1177
1046
  icon: Icons.hearing_outlined,
1178
1047
  value: _defaultRecordingTranscriptionModel,
1179
- items: _recordingTranscriptionModelChoices(
1048
+ options: _recordingTranscriptionOptions(
1180
1049
  _defaultRecordingTranscriptionModel,
1181
1050
  ),
1182
1051
  onChanged: (value) {
@@ -1221,7 +1090,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1221
1090
  routingModels,
1222
1091
  allowAuto: true,
1223
1092
  ),
1224
- items: modelChoices,
1093
+ options: modelChoices,
1225
1094
  onChanged: (value) {
1226
1095
  if (value != null) {
1227
1096
  setState(() => _defaultSpeechModel = value);
@@ -1263,14 +1132,9 @@ class _SettingsPanelState extends State<SettingsPanel> {
1263
1132
  label: 'Live Provider',
1264
1133
  icon: Icons.call_outlined,
1265
1134
  value: _voiceLiveProvider,
1266
- items: const <String>['openai', 'gemini']
1267
- .map(
1268
- (value) => DropdownMenuItem<String>(
1269
- value: value,
1270
- child: Text(value),
1271
- ),
1272
- )
1273
- .toList(),
1135
+ options: _simplePickerOptions(
1136
+ const <String>['openai', 'gemini'],
1137
+ ),
1274
1138
  onChanged: (value) {
1275
1139
  if (value == null) return;
1276
1140
  setState(() {
@@ -1299,16 +1163,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1299
1163
  label: 'Live Model',
1300
1164
  icon: Icons.speed_outlined,
1301
1165
  value: _voiceLiveModel,
1302
- items:
1303
- (_voiceLiveModelsByProvider[_voiceLiveProvider] ??
1304
- const <String>[])
1305
- .map(
1306
- (value) => DropdownMenuItem<String>(
1307
- value: value,
1308
- child: Text(value),
1309
- ),
1310
- )
1311
- .toList(),
1166
+ options: _simplePickerOptions(
1167
+ _voiceLiveModelsByProvider[_voiceLiveProvider] ??
1168
+ const <String>[],
1169
+ ),
1312
1170
  onChanged: (value) {
1313
1171
  if (value != null) {
1314
1172
  setState(() => _voiceLiveModel = value);
@@ -1323,14 +1181,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1323
1181
  label: 'Live Voice',
1324
1182
  icon: Icons.graphic_eq_outlined,
1325
1183
  value: _voiceLiveVoice,
1326
- items: liveVoiceOptions
1327
- .map(
1328
- (value) => DropdownMenuItem<String>(
1329
- value: value,
1330
- child: Text(value),
1331
- ),
1332
- )
1333
- .toList(),
1184
+ options: _simplePickerOptions(liveVoiceOptions),
1334
1185
  onChanged: (value) {
1335
1186
  if (value != null) {
1336
1187
  setState(() => _voiceLiveVoice = value);
@@ -1655,426 +1506,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
1655
1506
  );
1656
1507
  }
1657
1508
 
1658
- Widget _buildUpdatesSection(NeoAgentController controller) {
1659
- return Card(
1660
- child: Padding(
1661
- padding: const EdgeInsets.all(20),
1662
- child: Column(
1663
- crossAxisAlignment: CrossAxisAlignment.start,
1664
- children: <Widget>[
1665
- const _SectionTitle('Updates'),
1666
- const SizedBox(height: 10),
1667
- Text(
1668
- 'Client and runtime update controls live here.',
1669
- style: TextStyle(color: _textSecondary, height: 1.45),
1670
- ),
1671
- const SizedBox(height: 16),
1672
- LayoutBuilder(
1673
- builder: (context, constraints) {
1674
- final compact = constraints.maxWidth < 720;
1675
- final checkButton = FilledButton.icon(
1676
- onPressed:
1677
- controller.isCheckingAppUpdate ||
1678
- !controller.appUpdaterConfigured
1679
- ? null
1680
- : () => controller.checkForAppUpdates(),
1681
- style: FilledButton.styleFrom(backgroundColor: _accent),
1682
- icon: controller.isCheckingAppUpdate
1683
- ? _inlineProgressIndicator()
1684
- : const Icon(Icons.sync),
1685
- label: Text(
1686
- controller.isCheckingAppUpdate
1687
- ? 'Checking...'
1688
- : 'Check now',
1689
- ),
1690
- );
1691
- final appHeading = Text(
1692
- 'Client App',
1693
- style: TextStyle(
1694
- fontWeight: FontWeight.w700,
1695
- color: _textPrimary,
1696
- ),
1697
- );
1698
- if (compact) {
1699
- return Column(
1700
- crossAxisAlignment: CrossAxisAlignment.start,
1701
- children: <Widget>[
1702
- appHeading,
1703
- const SizedBox(height: 10),
1704
- checkButton,
1705
- ],
1706
- );
1707
- }
1708
- return Row(
1709
- children: <Widget>[
1710
- Expanded(child: appHeading),
1711
- checkButton,
1712
- ],
1713
- );
1714
- },
1715
- ),
1716
- const SizedBox(height: 12),
1717
- if (!controller.appUpdaterConfigured)
1718
- if (!kIsWeb)
1719
- Text(
1720
- 'Client app updates are not configured for this build.',
1721
- style: TextStyle(color: _textSecondary, height: 1.5),
1722
- )
1723
- else ...<Widget>[
1724
- LayoutBuilder(
1725
- builder: (context, constraints) {
1726
- final compact = constraints.maxWidth < 780;
1727
- final channelPicker = DropdownButtonFormField<String>(
1728
- initialValue: controller.appUpdateChannel,
1729
- decoration: const InputDecoration(
1730
- labelText: 'App release channel',
1731
- ),
1732
- items: const <DropdownMenuItem<String>>[
1733
- DropdownMenuItem<String>(
1734
- value: 'stable',
1735
- child: Text('Stable'),
1736
- ),
1737
- DropdownMenuItem<String>(
1738
- value: 'beta',
1739
- child: Text('Beta'),
1740
- ),
1741
- ],
1742
- onChanged: (value) {
1743
- if (value != null) {
1744
- unawaited(controller.setAppUpdateChannel(value));
1745
- }
1746
- },
1747
- );
1748
- final autoCheck = SwitchListTile.adaptive(
1749
- value: controller.appUpdateAutoCheckEnabled,
1750
- contentPadding: EdgeInsets.zero,
1751
- title: Text('Check automatically on launch'),
1752
- subtitle: Text(
1753
- 'This only checks GitHub Releases on startup. Installation still requires your confirmation.',
1754
- style: TextStyle(color: _textSecondary),
1755
- ),
1756
- onChanged: controller.setAppUpdateAutoCheckEnabled,
1757
- );
1758
-
1759
- if (compact) {
1760
- return Column(
1761
- crossAxisAlignment: CrossAxisAlignment.start,
1762
- children: <Widget>[
1763
- channelPicker,
1764
- const SizedBox(height: 10),
1765
- autoCheck,
1766
- ],
1767
- );
1768
- }
1769
-
1770
- return Row(
1771
- crossAxisAlignment: CrossAxisAlignment.start,
1772
- children: <Widget>[
1773
- Expanded(child: channelPicker),
1774
- const SizedBox(width: 16),
1775
- Expanded(child: autoCheck),
1776
- ],
1777
- );
1778
- },
1779
- ),
1780
- const SizedBox(height: 8),
1781
- Text(
1782
- 'Installed: ${controller.installedAppVersion ?? 'Unknown'} | Channel: ${controller.appUpdateChannelLabel} | Last checked: ${controller.appUpdateLastCheckedLabel}',
1783
- style: TextStyle(color: _textSecondary),
1784
- ),
1785
- const SizedBox(height: 6),
1786
- Text(
1787
- 'Source: ${app_release_updater.appUpdaterGithubOwner}/${app_release_updater.appUpdaterGithubRepo}${app_release_updater.appUpdaterGithubToken.trim().isNotEmpty ? ' (override active)' : ''}',
1788
- style: TextStyle(color: _textSecondary),
1789
- ),
1790
- if (controller.appUpdateErrorMessage
1791
- case final message?) ...<Widget>[
1792
- const SizedBox(height: 12),
1793
- _InlineError(message: message),
1794
- ],
1795
- if (controller.availableAppUpdate
1796
- case final release?) ...<Widget>[
1797
- const SizedBox(height: 16),
1798
- Container(
1799
- width: double.infinity,
1800
- padding: const EdgeInsets.all(16),
1801
- decoration: BoxDecoration(
1802
- color: _bgSecondary,
1803
- borderRadius: BorderRadius.circular(18),
1804
- border: Border.all(color: _border),
1805
- ),
1806
- child: Column(
1807
- crossAxisAlignment: CrossAxisAlignment.start,
1808
- children: <Widget>[
1809
- Wrap(
1810
- spacing: 10,
1811
- runSpacing: 10,
1812
- children: <Widget>[
1813
- _StatusPill(
1814
- label: 'Update ${release.version}',
1815
- color: release.channel == 'beta'
1816
- ? _warning
1817
- : _accent,
1818
- ),
1819
- _StatusPill(
1820
- label: release.asset.name,
1821
- color: _textSecondary,
1822
- ),
1823
- ],
1824
- ),
1825
- const SizedBox(height: 10),
1826
- Text(
1827
- '${release.title} Β· ${release.publishedLabel} Β· ${release.asset.sizeLabel}',
1828
- style: TextStyle(color: _textSecondary),
1829
- ),
1830
- if (release.body.trim().isNotEmpty) ...<Widget>[
1831
- const SizedBox(height: 14),
1832
- ConstrainedBox(
1833
- constraints: const BoxConstraints(maxHeight: 220),
1834
- child: SingleChildScrollView(
1835
- child: MarkdownBody(
1836
- data: release.body,
1837
- selectable: true,
1838
- styleSheet: MarkdownStyleSheet(
1839
- p: TextStyle(
1840
- color: _textSecondary,
1841
- height: 1.45,
1842
- ),
1843
- ),
1844
- ),
1845
- ),
1846
- ),
1847
- ],
1848
- const SizedBox(height: 14),
1849
- Wrap(
1850
- spacing: 10,
1851
- runSpacing: 10,
1852
- children: <Widget>[
1853
- FilledButton.icon(
1854
- onPressed: controller.isOpeningAppUpdate
1855
- ? null
1856
- : controller.openAppUpdate,
1857
- style: FilledButton.styleFrom(
1858
- backgroundColor: _accent,
1859
- ),
1860
- icon: controller.isOpeningAppUpdate
1861
- ? _inlineProgressIndicator()
1862
- : const Icon(Icons.system_update_alt),
1863
- label: Text(
1864
- controller.isOpeningAppUpdate
1865
- ? 'Opening...'
1866
- : 'Download update',
1867
- ),
1868
- ),
1869
- if (release.htmlUrl.trim().isNotEmpty)
1870
- OutlinedButton.icon(
1871
- onPressed: () {
1872
- unawaited(
1873
- widget.controller._oauthLauncher
1874
- .openExternal(
1875
- url: release.htmlUrl,
1876
- label: 'release_notes',
1877
- ),
1878
- );
1879
- },
1880
- icon: const Icon(Icons.open_in_new),
1881
- label: Text('View release'),
1882
- ),
1883
- ],
1884
- ),
1885
- ],
1886
- ),
1887
- ),
1888
- ] else ...<Widget>[
1889
- const SizedBox(height: 12),
1890
- Text(
1891
- controller.isCheckingAppUpdate
1892
- ? 'Checking GitHub releases...'
1893
- : controller.appUpdateLastCheckedAt == null
1894
- ? 'Choose a channel, then check GitHub releases.'
1895
- : 'No newer app release is available on the selected channel.',
1896
- style: TextStyle(color: _textSecondary, height: 1.45),
1897
- ),
1898
- ],
1899
- ],
1900
- const Divider(height: 32),
1901
- if (controller.updateStatus.allowSelfUpdate) ...<Widget>[
1902
- LayoutBuilder(
1903
- builder: (context, constraints) {
1904
- final compact = constraints.maxWidth < 780;
1905
- final channelPicker = DropdownButtonFormField<String>(
1906
- initialValue: controller.updateStatus.releaseChannel,
1907
- decoration: const InputDecoration(
1908
- labelText: 'Runtime release channel',
1909
- ),
1910
- items: const <DropdownMenuItem<String>>[
1911
- DropdownMenuItem<String>(
1912
- value: 'stable',
1913
- child: Text('Stable'),
1914
- ),
1915
- DropdownMenuItem<String>(
1916
- value: 'beta',
1917
- child: Text('Beta'),
1918
- ),
1919
- ],
1920
- onChanged:
1921
- controller.isSavingReleaseChannel ||
1922
- controller.isTriggeringUpdate ||
1923
- controller.updateStatus.state == 'running'
1924
- ? null
1925
- : (value) {
1926
- if (value != null) {
1927
- unawaited(controller.setReleaseChannel(value));
1928
- }
1929
- },
1930
- );
1931
-
1932
- final channelHelper = Text(
1933
- controller.updateStatus.releaseChannel == 'beta'
1934
- ? 'Beta follows preview releases.'
1935
- : 'Stable follows production releases.',
1936
- style: TextStyle(color: _textSecondary),
1937
- );
1938
-
1939
- if (compact) {
1940
- return Column(
1941
- crossAxisAlignment: CrossAxisAlignment.start,
1942
- children: <Widget>[
1943
- channelPicker,
1944
- const SizedBox(height: 8),
1945
- channelHelper,
1946
- const SizedBox(height: 16),
1947
- ],
1948
- );
1949
- }
1950
-
1951
- return Padding(
1952
- padding: const EdgeInsets.only(bottom: 16),
1953
- child: Row(
1954
- crossAxisAlignment: CrossAxisAlignment.start,
1955
- children: <Widget>[
1956
- Expanded(child: channelPicker),
1957
- const SizedBox(width: 12),
1958
- Expanded(child: channelHelper),
1959
- ],
1960
- ),
1961
- );
1962
- },
1963
- ),
1964
- LayoutBuilder(
1965
- builder: (context, constraints) {
1966
- final compact = constraints.maxWidth < 780;
1967
- final runtimeTitle = Text(
1968
- 'Runtime',
1969
- style: TextStyle(
1970
- fontWeight: FontWeight.w700,
1971
- color: _textPrimary,
1972
- ),
1973
- );
1974
- final updateButton = FilledButton.icon(
1975
- onPressed:
1976
- controller.isSavingReleaseChannel ||
1977
- controller.isTriggeringUpdate ||
1978
- controller.updateStatus.state == 'running'
1979
- ? null
1980
- : controller.triggerUpdate,
1981
- style: FilledButton.styleFrom(backgroundColor: _accent),
1982
- icon: controller.isTriggeringUpdate
1983
- ? const SizedBox.square(
1984
- dimension: 16,
1985
- child: CircularProgressIndicator(
1986
- strokeWidth: 2,
1987
- color: Colors.white,
1988
- ),
1989
- )
1990
- : Icon(Icons.system_update),
1991
- label: Text('Update'),
1992
- );
1993
- if (compact) {
1994
- return Column(
1995
- crossAxisAlignment: CrossAxisAlignment.start,
1996
- children: <Widget>[
1997
- runtimeTitle,
1998
- const SizedBox(height: 10),
1999
- updateButton,
2000
- ],
2001
- );
2002
- }
2003
- return Row(
2004
- children: <Widget>[
2005
- Expanded(child: runtimeTitle),
2006
- updateButton,
2007
- ],
2008
- );
2009
- },
2010
- ),
2011
- ] else ...<Widget>[
2012
- Text(
2013
- 'Runtime',
2014
- style: TextStyle(
2015
- fontWeight: FontWeight.w700,
2016
- color: _textPrimary,
2017
- ),
2018
- ),
2019
- const SizedBox(height: 10),
2020
- Text(
2021
- 'Updates and release tracks are managed for this deployment.',
2022
- style: TextStyle(color: _textSecondary),
2023
- ),
2024
- ],
2025
- const SizedBox(height: 12),
2026
- LayoutBuilder(
2027
- builder: (context, constraints) {
2028
- final compact = constraints.maxWidth < 760;
2029
- final statusRow = Wrap(
2030
- spacing: 10,
2031
- runSpacing: 10,
2032
- crossAxisAlignment: WrapCrossAlignment.center,
2033
- children: <Widget>[
2034
- _StatusPill(
2035
- label: controller.updateStatus.badgeLabel,
2036
- color: controller.updateStatus.badgeColor,
2037
- ),
2038
- _StatusPill(
2039
- label: controller.updateStatus.releaseChannelLabel,
2040
- color: controller.updateStatus.releaseChannel == 'beta'
2041
- ? _warning
2042
- : _accent,
2043
- ),
2044
- Text(
2045
- controller.updateStatus.message,
2046
- style: TextStyle(color: _textSecondary),
2047
- ),
2048
- Text('${controller.updateStatus.progress}%'),
2049
- ],
2050
- );
2051
- if (compact) {
2052
- return Column(
2053
- crossAxisAlignment: CrossAxisAlignment.start,
2054
- children: <Widget>[statusRow],
2055
- );
2056
- }
2057
- return statusRow;
2058
- },
2059
- ),
2060
- const SizedBox(height: 10),
2061
- ClipRRect(
2062
- borderRadius: BorderRadius.circular(999),
2063
- child: LinearProgressIndicator(
2064
- minHeight: 8,
2065
- value: controller.updateStatus.progress / 100,
2066
- backgroundColor: _bgSecondary,
2067
- color: _accent,
2068
- ),
2069
- ),
2070
- const SizedBox(height: 12),
2071
- Text(controller.updateStatus.versionLine),
2072
- ],
2073
- ),
2074
- ),
2075
- );
2076
- }
2077
-
2078
1509
  Widget _buildDiagnosticsSection(NeoAgentController controller) {
2079
1510
  return Card(
2080
1511
  child: Padding(
@@ -2179,20 +1610,12 @@ class _SettingsPanelState extends State<SettingsPanel> {
2179
1610
  }
2180
1611
  }
2181
1612
 
2182
- List<DropdownMenuItem<String>> _recordingTranscriptionModelChoices(
2183
- String current,
2184
- ) {
2185
- const defaults = <String>['nova-3', 'nova-2-general'];
2186
- final normalizedCurrent = current.trim();
2187
- final values = <String>{...defaults};
2188
- if (normalizedCurrent.isNotEmpty) {
2189
- values.add(normalizedCurrent);
2190
- }
2191
- return values
2192
- .map(
2193
- (value) => DropdownMenuItem<String>(value: value, child: Text(value)),
2194
- )
2195
- .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());
2196
1619
  }
2197
1620
 
2198
1621
  // Shared helper: small "Test" button + inline result row.
@@ -2260,287 +1683,666 @@ class _SettingsPanelState extends State<SettingsPanel> {
2260
1683
  }
2261
1684
  }
2262
1685
 
2263
- class _AiProviderCard extends StatelessWidget {
2264
- const _AiProviderCard({
2265
- required this.provider,
2266
- required this.enabled,
2267
- required this.expanded,
2268
- required this.models,
2269
- required this.baseUrlController,
2270
- required this.onEnabledChanged,
2271
- required this.onExpandToggle,
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,
2272
1693
  });
2273
1694
 
2274
- final AiProviderMeta provider;
2275
- final bool enabled;
2276
- final bool expanded;
2277
- final List<ModelMeta> models;
2278
- final TextEditingController baseUrlController;
2279
- final ValueChanged<bool> onEnabledChanged;
2280
- final VoidCallback onExpandToggle;
1695
+ final String label;
1696
+ final IconData icon;
1697
+ final String value;
1698
+ final List<_ModelPickerOption> options;
1699
+ final ValueChanged<String?> onChanged;
2281
1700
 
2282
1701
  @override
2283
1702
  Widget build(BuildContext context) {
2284
- final availableCount = models.where((model) => model.available).length;
2285
- final hasAdvancedFields = provider.supportsBaseUrl || models.isNotEmpty;
2286
1703
  return Container(
2287
- padding: const EdgeInsets.all(16),
1704
+ padding: const EdgeInsets.all(14),
2288
1705
  decoration: BoxDecoration(
2289
1706
  color: _bgSecondary,
2290
- borderRadius: BorderRadius.circular(18),
2291
- border: Border.all(color: _borderLight),
1707
+ borderRadius: BorderRadius.circular(16),
1708
+ border: Border.all(color: _border),
2292
1709
  ),
2293
1710
  child: Column(
2294
1711
  crossAxisAlignment: CrossAxisAlignment.start,
2295
1712
  children: <Widget>[
2296
1713
  Row(
2297
- crossAxisAlignment: CrossAxisAlignment.start,
2298
1714
  children: <Widget>[
2299
- Container(
2300
- width: 44,
2301
- height: 44,
2302
- decoration: BoxDecoration(
2303
- color: _accentMuted,
2304
- borderRadius: BorderRadius.circular(14),
2305
- ),
2306
- child: Icon(provider.icon, color: _accentHover),
2307
- ),
2308
- const SizedBox(width: 12),
2309
- Expanded(
2310
- child: Column(
2311
- crossAxisAlignment: CrossAxisAlignment.start,
2312
- children: <Widget>[
2313
- Text(
2314
- provider.label,
2315
- style: TextStyle(
2316
- fontSize: 16,
2317
- fontWeight: FontWeight.w700,
2318
- ),
2319
- ),
2320
- const SizedBox(height: 4),
2321
- Text(
2322
- provider.description,
2323
- maxLines: 2,
2324
- overflow: TextOverflow.ellipsis,
2325
- style: TextStyle(color: _textSecondary, height: 1.4),
2326
- ),
2327
- ],
2328
- ),
2329
- ),
1715
+ Icon(icon, size: 16, color: _accentHover),
2330
1716
  const SizedBox(width: 8),
2331
- Column(
2332
- crossAxisAlignment: CrossAxisAlignment.end,
2333
- children: <Widget>[
2334
- _StatusPill(
2335
- label: enabled ? provider.statusLabel : 'Disabled',
2336
- color: enabled ? provider.statusColor : _textSecondary,
2337
- ),
2338
- const SizedBox(height: 8),
2339
- InkWell(
2340
- onTap: hasAdvancedFields || models.isNotEmpty
2341
- ? onExpandToggle
2342
- : null,
2343
- borderRadius: BorderRadius.circular(999),
2344
- child: Container(
2345
- padding: const EdgeInsets.symmetric(
2346
- horizontal: 10,
2347
- vertical: 6,
2348
- ),
2349
- decoration: BoxDecoration(
2350
- color: _bgCard,
2351
- borderRadius: BorderRadius.circular(999),
2352
- border: Border.all(color: _border),
2353
- ),
2354
- child: Row(
2355
- mainAxisSize: MainAxisSize.min,
2356
- children: <Widget>[
2357
- Text(
2358
- expanded ? 'Hide' : 'Setup',
2359
- style: TextStyle(fontSize: 12),
2360
- ),
2361
- const SizedBox(width: 4),
2362
- Icon(
2363
- expanded
2364
- ? Icons.keyboard_arrow_up
2365
- : Icons.keyboard_arrow_down,
2366
- size: 16,
2367
- color: _textSecondary,
2368
- ),
2369
- ],
2370
- ),
2371
- ),
2372
- ),
2373
- ],
2374
- ),
1717
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2375
1718
  ],
2376
1719
  ),
2377
- const SizedBox(height: 12),
2378
- Wrap(
2379
- spacing: 8,
2380
- runSpacing: 8,
2381
- children: <Widget>[
2382
- _MetaPill(
2383
- label: '$availableCount of ${models.length} models ready',
2384
- icon: Icons.memory_outlined,
2385
- ),
2386
- if (provider.supportsApiKey && provider.credentialConfigured)
2387
- const _MetaPill(
2388
- label: 'Credentials ready',
2389
- icon: Icons.lock_outline,
2390
- ),
2391
- if (provider.supportsApiKey && !provider.credentialConfigured)
2392
- const _MetaPill(
2393
- label: 'Credentials needed',
2394
- icon: Icons.admin_panel_settings_outlined,
2395
- ),
2396
- if (provider.supportsBaseUrl &&
2397
- baseUrlController.text.trim().isNotEmpty)
2398
- _MetaPill(
2399
- label: _friendlyBaseUrlLabel(baseUrlController.text.trim()),
2400
- icon: Icons.link_outlined,
2401
- ),
2402
- ],
1720
+ const SizedBox(height: 10),
1721
+ _ModelPickerButton(
1722
+ value: value,
1723
+ options: options,
1724
+ onChanged: onChanged,
1725
+ dialogTitle: 'Select $label',
2403
1726
  ),
2404
- const SizedBox(height: 12),
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>[
2405
1765
  Container(
2406
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
1766
+ width: 38,
1767
+ height: 38,
2407
1768
  decoration: BoxDecoration(
2408
- color: _bgCard,
2409
- borderRadius: BorderRadius.circular(14),
2410
- border: Border.all(color: _border),
1769
+ color: _accentMuted,
1770
+ borderRadius: BorderRadius.circular(10),
2411
1771
  ),
2412
- child: Row(
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,
2413
1778
  children: <Widget>[
2414
- Expanded(
2415
- child: Text(
2416
- provider.availabilityReason,
2417
- style: TextStyle(color: _textSecondary, height: 1.35),
1779
+ Text(
1780
+ '${selected.length} of $totalAvailable models',
1781
+ style: TextStyle(
1782
+ fontWeight: FontWeight.w600,
1783
+ fontSize: 14,
1784
+ color: _textPrimary,
2418
1785
  ),
2419
1786
  ),
2420
- const SizedBox(width: 12),
2421
- Switch(value: enabled, onChanged: onEnabledChanged),
1787
+ const SizedBox(height: 5),
1788
+ Row(
1789
+ children: <Widget>[
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
+ ),
1807
+ ],
1808
+ ),
2422
1809
  ],
2423
1810
  ),
2424
1811
  ),
2425
- if (expanded) ...<Widget>[
2426
- const SizedBox(height: 14),
2427
- if (provider.supportsApiKey)
2428
- Container(
2429
- width: double.infinity,
2430
- padding: const EdgeInsets.all(12),
2431
- margin: const EdgeInsets.only(bottom: 12),
2432
- decoration: BoxDecoration(
2433
- color: _bgCard,
2434
- borderRadius: BorderRadius.circular(14),
2435
- border: Border.all(color: _border),
2436
- ),
2437
- child: Text(
2438
- provider.credentialConfigured
2439
- ? 'Credentials for this provider are already available to the runtime.'
2440
- : 'Credentials for this provider are managed outside this workspace UI. Finish the server or admin setup, then return here to enable routing.',
2441
- style: TextStyle(color: _textSecondary, height: 1.35),
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,
2442
1943
  ),
2443
1944
  ),
2444
- if (provider.supportsBaseUrl) ...<Widget>[
2445
- TextField(
2446
- controller: baseUrlController,
2447
- keyboardType: TextInputType.url,
2448
- autocorrect: false,
2449
- decoration: InputDecoration(
2450
- labelText: provider.id == 'ollama'
2451
- ? 'Server URL'
2452
- : 'Base URL',
2453
- helperText: provider.defaultBaseUrl.trim().isEmpty
2454
- ? 'Optional override.'
2455
- : 'Default: ${provider.defaultBaseUrl}',
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,
2456
1973
  ),
2457
1974
  ),
2458
- const SizedBox(height: 12),
2459
- ],
2460
- if (models.isNotEmpty) ...<Widget>[
2461
- Text('Models', style: TextStyle(fontWeight: FontWeight.w700)),
2462
- const SizedBox(height: 8),
2463
- Wrap(
2464
- spacing: 8,
2465
- runSpacing: 8,
2466
- children: models
2467
- .map(
2468
- (model) => Container(
2469
- padding: const EdgeInsets.symmetric(
2470
- horizontal: 10,
2471
- vertical: 8,
2472
- ),
2473
- decoration: BoxDecoration(
2474
- color: model.available ? _bgCard : _bgPrimary,
2475
- borderRadius: BorderRadius.circular(999),
2476
- border: Border.all(
2477
- color: model.available ? _border : _borderLight,
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
+ ),
2478
2033
  ),
2479
- ),
2480
- child: Text(
2481
- model.label,
2482
- style: TextStyle(
2483
- fontSize: 12,
2484
- color: model.available
2485
- ? _textPrimary
2486
- : _textSecondary,
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
+ ),
2487
2048
  ),
2488
- ),
2049
+ ],
2489
2050
  ),
2490
- )
2491
- .toList(),
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
+ ],
2153
+ ),
2154
+ ),
2155
+ // Quick-action toolbar
2156
+ Padding(
2157
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
2158
+ child: Row(
2159
+ children: <Widget>[
2160
+ _PoolActionChip(
2161
+ label: 'Select all',
2162
+ onTap: () => _selectAllVisible(filtered),
2163
+ ),
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
+ ),
2176
+ ),
2177
+ ],
2178
+ ),
2179
+ ),
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
+ ],
2213
+ ),
2492
2214
  ),
2493
- ],
2494
- ],
2495
- ],
2215
+ ),
2216
+ ),
2217
+ ),
2496
2218
  ),
2497
2219
  );
2498
2220
  }
2499
2221
  }
2500
2222
 
2501
- class _RoutingSelectCard extends StatelessWidget {
2502
- const _RoutingSelectCard({
2503
- required this.label,
2504
- required this.icon,
2505
- required this.value,
2506
- required this.items,
2507
- required this.onChanged,
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,
2508
2232
  });
2509
2233
 
2510
- final String label;
2511
- final IconData icon;
2512
- final String value;
2513
- final List<DropdownMenuItem<String>> items;
2514
- final ValueChanged<String?> onChanged;
2234
+ final ModelMeta model;
2235
+ final bool selected;
2236
+ final ValueChanged<bool> onToggle;
2515
2237
 
2516
2238
  @override
2517
2239
  Widget build(BuildContext context) {
2518
- return Container(
2519
- padding: const EdgeInsets.all(14),
2520
- decoration: BoxDecoration(
2521
- color: _bgSecondary,
2522
- borderRadius: BorderRadius.circular(16),
2523
- border: Border.all(color: _border),
2524
- ),
2525
- child: Column(
2526
- crossAxisAlignment: CrossAxisAlignment.start,
2527
- children: <Widget>[
2528
- Row(
2529
- children: <Widget>[
2530
- Icon(icon, size: 16, color: _accentHover),
2531
- const SizedBox(width: 8),
2532
- Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2533
- ],
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),
2252
+ child: Row(
2253
+ children: <Widget>[
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),
2262
+ ),
2263
+ ),
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
+ ),
2279
+ ),
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,
2292
+ ),
2293
+ maxLines: 1,
2294
+ overflow: TextOverflow.ellipsis,
2295
+ ),
2296
+ if (model.purpose.isNotEmpty)
2297
+ Text(
2298
+ model.purpose,
2299
+ style: TextStyle(
2300
+ fontSize: 11,
2301
+ color: _textMuted,
2302
+ ),
2303
+ ),
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
+ ),
2534
2313
  ),
2535
- const SizedBox(height: 10),
2536
- DropdownButtonFormField<String>(
2537
- initialValue: value,
2538
- items: items,
2539
- isExpanded: true,
2540
- decoration: const InputDecoration(isDense: true),
2541
- onChanged: onChanged,
2314
+ ),
2315
+ ),
2316
+ );
2317
+ }
2318
+ }
2319
+
2320
+ // Toolbar chip button used inside _SmartPoolDialog
2321
+ class _PoolActionChip extends StatelessWidget {
2322
+ const _PoolActionChip({required this.label, required this.onTap});
2323
+ final String label;
2324
+ final VoidCallback onTap;
2325
+
2326
+ @override
2327
+ Widget build(BuildContext context) {
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,
2542
2344
  ),
2543
- ],
2345
+ ),
2544
2346
  ),
2545
2347
  );
2546
2348
  }