neoagent 2.4.1-beta.39 → 2.4.1-beta.41

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.
@@ -187,7 +187,6 @@ class _SettingsPanelState extends State<SettingsPanel> {
187
187
  final Map<String, bool> _providerEnabled = <String, bool>{};
188
188
  final Map<String, TextEditingController> _providerBaseUrlControllers =
189
189
  <String, TextEditingController>{};
190
- final Set<String> _expandedProviderIds = <String>{};
191
190
 
192
191
  // Inline runtime test state — ephemeral, not stored in controller.
193
192
  bool _cliTestRunning = false;
@@ -382,6 +381,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
382
381
  _modelsSettingsSection,
383
382
  )) ...<Widget>[
384
383
  _buildModelsSection(
384
+ context: context,
385
385
  controller: controller,
386
386
  modelChoices: modelChoices,
387
387
  routingModels: routingModels,
@@ -853,6 +853,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
853
853
  }
854
854
 
855
855
  Widget _buildModelsSection({
856
+ required BuildContext context,
856
857
  required NeoAgentController controller,
857
858
  required List<_ModelPickerOption> modelChoices,
858
859
  required List<ModelMeta> routingModels,
@@ -868,88 +869,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
868
869
  const _SectionTitle('Models'),
869
870
  const SizedBox(height: 10),
870
871
  Text(
871
- 'Enable providers, then choose defaults for chat, agents, fallback behavior, and smart routing.',
872
+ 'Choose defaults for chat, agents, fallback behavior, and smart routing.',
872
873
  style: TextStyle(color: _textSecondary, height: 1.45),
873
874
  ),
874
875
  const SizedBox(height: 16),
875
- Text(
876
- 'Providers',
877
- style: TextStyle(
878
- fontWeight: FontWeight.w700,
879
- color: _textPrimary,
880
- ),
881
- ),
882
- const SizedBox(height: 14),
883
- if (controller.aiProviders.isEmpty)
884
- Text(
885
- 'Provider metadata is unavailable on this server version.',
886
- style: TextStyle(color: _textSecondary),
887
- )
888
- else
889
- LayoutBuilder(
890
- builder: (context, constraints) {
891
- final compact = constraints.maxWidth < 960;
892
- final cardWidth = compact
893
- ? constraints.maxWidth
894
- : (constraints.maxWidth - 16) / 2;
895
- return Wrap(
896
- spacing: 16,
897
- runSpacing: 16,
898
- children: controller.aiProviders
899
- .where(
900
- (provider) =>
901
- provider.available ||
902
- _providerEnabled[provider.id] == true ||
903
- controller
904
- .aiProviderConfigs[provider.id]
905
- ?.enabled ==
906
- true,
907
- )
908
- .map((provider) {
909
- return SizedBox(
910
- width: cardWidth,
911
- child: _AiProviderCard(
912
- provider: provider,
913
- enabled:
914
- _providerEnabled[provider.id] ??
915
- controller
916
- .aiProviderConfigs[provider.id]
917
- ?.enabled ??
918
- true,
919
- models: controller.supportedModels
920
- .where(
921
- (model) => model.provider == provider.id,
922
- )
923
- .toList(),
924
- baseUrlController:
925
- _providerBaseUrlControllers[provider.id]!,
926
- expanded: _expandedProviderIds.contains(
927
- provider.id,
928
- ),
929
- onEnabledChanged: (value) {
930
- setState(() {
931
- _providerEnabled[provider.id] = value;
932
- });
933
- },
934
- onExpandToggle: () {
935
- setState(() {
936
- if (_expandedProviderIds.contains(
937
- provider.id,
938
- )) {
939
- _expandedProviderIds.remove(provider.id);
940
- } else {
941
- _expandedProviderIds.add(provider.id);
942
- }
943
- });
944
- },
945
- ),
946
- );
947
- })
948
- .toList(),
949
- );
950
- },
951
- ),
952
- const Divider(height: 32),
953
876
  Text(
954
877
  'Default Routing',
955
878
  style: TextStyle(
@@ -1036,46 +959,38 @@ class _SettingsPanelState extends State<SettingsPanel> {
1036
959
  ),
1037
960
  ),
1038
961
  const SizedBox(height: 10),
1039
- Wrap(
1040
- spacing: 10,
1041
- runSpacing: 10,
1042
- children: controller.supportedModels.map((model) {
1043
- final selected = _enabledModels.contains(model.id);
1044
- return FilterChip(
1045
- label: Text(
1046
- model.available
1047
- ? model.label
1048
- : '${model.label} (${model.providerStatusLabel})',
962
+ Text(
963
+ 'The models the Smart Selector routes between automatically.',
964
+ style: TextStyle(color: _textSecondary, height: 1.45),
965
+ ),
966
+ const SizedBox(height: 12),
967
+ _SmartPoolSummary(
968
+ allModels: controller.supportedModels,
969
+ selectedIds: _enabledModels,
970
+ onManage: () async {
971
+ final result = await showGeneralDialog<Set<String>>(
972
+ context: context,
973
+ barrierDismissible: true,
974
+ barrierLabel: 'Dismiss',
975
+ barrierColor: Colors.black.withValues(alpha: 0.55),
976
+ transitionDuration: const Duration(milliseconds: 220),
977
+ transitionBuilder: (ctx, anim, _, child) => FadeTransition(
978
+ opacity: CurvedAnimation(parent: anim, curve: Curves.easeOut),
979
+ child: SlideTransition(
980
+ position: Tween<Offset>(
981
+ begin: const Offset(0, 0.04),
982
+ end: Offset.zero,
983
+ ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
984
+ child: child,
985
+ ),
1049
986
  ),
1050
- selected: selected,
1051
- selectedColor: _accentMuted,
1052
- checkmarkColor: _accent,
1053
- backgroundColor: _bgSecondary,
1054
- side: BorderSide(
1055
- color: model.available
1056
- ? _border
1057
- : _warning.withValues(alpha: 0.35),
987
+ pageBuilder: (ctx, _, __) => _SmartPoolDialog(
988
+ models: controller.supportedModels,
989
+ selectedIds: _enabledModels,
1058
990
  ),
1059
- onSelected: model.available
1060
- ? (value) {
1061
- setState(() {
1062
- if (value) {
1063
- _enabledModels.add(model.id);
1064
- } else if (_enabledModels.length > 1) {
1065
- _enabledModels.remove(model.id);
1066
- }
1067
- });
1068
- }
1069
- : null,
1070
991
  );
1071
- }).toList(),
1072
- ),
1073
- const SizedBox(height: 14),
1074
- Text(
1075
- availableModels.isEmpty
1076
- ? 'Enable a ready provider above to unlock model routing.'
1077
- : '$enabledSmartModels models are currently eligible for smart routing.',
1078
- style: TextStyle(color: _textSecondary),
992
+ if (result != null) setState(() => _enabledModels = result);
993
+ },
1079
994
  ),
1080
995
  ],
1081
996
  ),
@@ -2205,286 +2120,666 @@ class _SettingsPanelState extends State<SettingsPanel> {
2205
2120
  }
2206
2121
  }
2207
2122
 
2208
- class _AiProviderCard extends StatelessWidget {
2209
- const _AiProviderCard({
2210
- required this.provider,
2211
- required this.enabled,
2212
- required this.expanded,
2213
- required this.models,
2214
- required this.baseUrlController,
2215
- required this.onEnabledChanged,
2216
- required this.onExpandToggle,
2123
+ class _RoutingSelectCard extends StatelessWidget {
2124
+ const _RoutingSelectCard({
2125
+ required this.label,
2126
+ required this.icon,
2127
+ required this.value,
2128
+ required this.options,
2129
+ required this.onChanged,
2217
2130
  });
2218
2131
 
2219
- final AiProviderMeta provider;
2220
- final bool enabled;
2221
- final bool expanded;
2222
- final List<ModelMeta> models;
2223
- final TextEditingController baseUrlController;
2224
- final ValueChanged<bool> onEnabledChanged;
2225
- final VoidCallback onExpandToggle;
2132
+ final String label;
2133
+ final IconData icon;
2134
+ final String value;
2135
+ final List<_ModelPickerOption> options;
2136
+ final ValueChanged<String?> onChanged;
2226
2137
 
2227
2138
  @override
2228
2139
  Widget build(BuildContext context) {
2229
- final availableCount = models.where((model) => model.available).length;
2230
- final hasAdvancedFields = provider.supportsBaseUrl || models.isNotEmpty;
2231
2140
  return Container(
2232
- padding: const EdgeInsets.all(16),
2141
+ padding: const EdgeInsets.all(14),
2233
2142
  decoration: BoxDecoration(
2234
2143
  color: _bgSecondary,
2235
- borderRadius: BorderRadius.circular(18),
2236
- border: Border.all(color: _borderLight),
2144
+ borderRadius: BorderRadius.circular(16),
2145
+ border: Border.all(color: _border),
2237
2146
  ),
2238
2147
  child: Column(
2239
2148
  crossAxisAlignment: CrossAxisAlignment.start,
2240
2149
  children: <Widget>[
2241
2150
  Row(
2242
- crossAxisAlignment: CrossAxisAlignment.start,
2243
2151
  children: <Widget>[
2244
- Container(
2245
- width: 44,
2246
- height: 44,
2247
- decoration: BoxDecoration(
2248
- color: _accentMuted,
2249
- borderRadius: BorderRadius.circular(14),
2152
+ Icon(icon, size: 16, color: _accentHover),
2153
+ const SizedBox(width: 8),
2154
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2155
+ ],
2156
+ ),
2157
+ const SizedBox(height: 10),
2158
+ _ModelPickerButton(
2159
+ value: value,
2160
+ options: options,
2161
+ onChanged: onChanged,
2162
+ dialogTitle: 'Select $label',
2163
+ ),
2164
+ ],
2165
+ ),
2166
+ );
2167
+ }
2168
+ }
2169
+
2170
+ // ─────────────────────────────────────────────────────────────────────────────
2171
+ // Smart Pool Summary — compact summary card shown in settings
2172
+ // ─────────────────────────────────────────────────────────────────────────────
2173
+
2174
+ class _SmartPoolSummary extends StatelessWidget {
2175
+ const _SmartPoolSummary({
2176
+ required this.allModels,
2177
+ required this.selectedIds,
2178
+ required this.onManage,
2179
+ });
2180
+
2181
+ final List<ModelMeta> allModels;
2182
+ final Set<String> selectedIds;
2183
+ final VoidCallback onManage;
2184
+
2185
+ @override
2186
+ Widget build(BuildContext context) {
2187
+ final selected = allModels
2188
+ .where((m) => selectedIds.contains(m.id) && m.available)
2189
+ .toList();
2190
+ final providers = <String>{for (final m in selected) m.provider};
2191
+ final totalAvailable = allModels.where((m) => m.available).length;
2192
+
2193
+ return Container(
2194
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
2195
+ decoration: BoxDecoration(
2196
+ color: _bgSecondary,
2197
+ borderRadius: BorderRadius.circular(14),
2198
+ border: Border.all(color: _border),
2199
+ ),
2200
+ child: Row(
2201
+ children: <Widget>[
2202
+ Container(
2203
+ width: 38,
2204
+ height: 38,
2205
+ decoration: BoxDecoration(
2206
+ color: _accentMuted,
2207
+ borderRadius: BorderRadius.circular(10),
2208
+ ),
2209
+ child: Icon(Icons.hub_outlined, size: 18, color: _accentHover),
2210
+ ),
2211
+ const SizedBox(width: 12),
2212
+ Expanded(
2213
+ child: Column(
2214
+ crossAxisAlignment: CrossAxisAlignment.start,
2215
+ children: <Widget>[
2216
+ Text(
2217
+ '${selected.length} of $totalAvailable models',
2218
+ style: TextStyle(
2219
+ fontWeight: FontWeight.w600,
2220
+ fontSize: 14,
2221
+ color: _textPrimary,
2222
+ ),
2250
2223
  ),
2251
- child: Icon(provider.icon, color: _accentHover),
2252
- ),
2253
- const SizedBox(width: 12),
2254
- Expanded(
2255
- child: Column(
2256
- crossAxisAlignment: CrossAxisAlignment.start,
2224
+ const SizedBox(height: 5),
2225
+ Row(
2257
2226
  children: <Widget>[
2258
- Text(
2259
- provider.label,
2260
- style: TextStyle(
2261
- fontSize: 16,
2262
- fontWeight: FontWeight.w700,
2263
- ),
2264
- ),
2265
- const SizedBox(height: 4),
2266
- Text(
2267
- provider.description,
2268
- maxLines: 2,
2269
- overflow: TextOverflow.ellipsis,
2270
- style: TextStyle(color: _textSecondary, height: 1.4),
2271
- ),
2227
+ if (providers.isEmpty)
2228
+ Text(
2229
+ 'No models selected',
2230
+ style: TextStyle(fontSize: 12, color: _textMuted),
2231
+ )
2232
+ else
2233
+ ...providers.take(12).map(
2234
+ (p) => Container(
2235
+ width: 8,
2236
+ height: 8,
2237
+ margin: const EdgeInsets.only(right: 5),
2238
+ decoration: BoxDecoration(
2239
+ color: _providerPickerColor(p),
2240
+ shape: BoxShape.circle,
2241
+ ),
2242
+ ),
2243
+ ),
2272
2244
  ],
2273
2245
  ),
2246
+ ],
2247
+ ),
2248
+ ),
2249
+ const SizedBox(width: 10),
2250
+ OutlinedButton.icon(
2251
+ onPressed: onManage,
2252
+ icon: const Icon(Icons.tune_rounded, size: 14),
2253
+ label: const Text('Manage'),
2254
+ style: OutlinedButton.styleFrom(
2255
+ padding:
2256
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
2257
+ textStyle: const TextStyle(fontSize: 13),
2258
+ ),
2259
+ ),
2260
+ ],
2261
+ ),
2262
+ );
2263
+ }
2264
+ }
2265
+
2266
+ // ─────────────────────────────────────────────────────────────────────────────
2267
+ // Smart Pool Dialog — searchable, grouped multi-select manager
2268
+ // ─────────────────────────────────────────────────────────────────────────────
2269
+
2270
+ class _SmartPoolDialog extends StatefulWidget {
2271
+ const _SmartPoolDialog({
2272
+ required this.models,
2273
+ required this.selectedIds,
2274
+ });
2275
+
2276
+ final List<ModelMeta> models;
2277
+ final Set<String> selectedIds;
2278
+
2279
+ @override
2280
+ State<_SmartPoolDialog> createState() => _SmartPoolDialogState();
2281
+ }
2282
+
2283
+ class _SmartPoolDialogState extends State<_SmartPoolDialog> {
2284
+ late Set<String> _selected;
2285
+ final TextEditingController _searchCtrl = TextEditingController();
2286
+ String _query = '';
2287
+ bool _onlyAvailable = true;
2288
+
2289
+ @override
2290
+ void initState() {
2291
+ super.initState();
2292
+ _selected = Set<String>.from(widget.selectedIds);
2293
+ }
2294
+
2295
+ @override
2296
+ void dispose() {
2297
+ _searchCtrl.dispose();
2298
+ super.dispose();
2299
+ }
2300
+
2301
+ List<ModelMeta> get _filtered {
2302
+ var list = _onlyAvailable
2303
+ ? widget.models.where((m) => m.available).toList()
2304
+ : List<ModelMeta>.from(widget.models);
2305
+ if (_query.isNotEmpty) {
2306
+ final q = _query.toLowerCase();
2307
+ list = list
2308
+ .where((m) =>
2309
+ m.label.toLowerCase().contains(q) ||
2310
+ m.id.toLowerCase().contains(q) ||
2311
+ m.provider.toLowerCase().contains(q))
2312
+ .toList();
2313
+ }
2314
+ return list;
2315
+ }
2316
+
2317
+ void _selectAllVisible(List<ModelMeta> filtered) {
2318
+ setState(() {
2319
+ for (final m in filtered) {
2320
+ if (m.available) _selected.add(m.id);
2321
+ }
2322
+ });
2323
+ }
2324
+
2325
+ void _clearAllVisible(List<ModelMeta> filtered) {
2326
+ setState(() {
2327
+ final toRemove = filtered.map((m) => m.id).toSet();
2328
+ final remaining = _selected.difference(toRemove);
2329
+ _selected = remaining.isNotEmpty
2330
+ ? remaining
2331
+ : <String>{_selected.first};
2332
+ });
2333
+ }
2334
+
2335
+ @override
2336
+ Widget build(BuildContext context) {
2337
+ final filtered = _filtered;
2338
+
2339
+ // Build grouped structure
2340
+ final Map<String, List<ModelMeta>> grouped = <String, List<ModelMeta>>{};
2341
+ for (final m in filtered) {
2342
+ grouped.putIfAbsent(m.provider, () => <ModelMeta>[]).add(m);
2343
+ }
2344
+ final providerOrder = grouped.keys.toList();
2345
+
2346
+ final selectedAvailableCount = widget.models
2347
+ .where((m) => _selected.contains(m.id) && m.available)
2348
+ .length;
2349
+
2350
+ // Build flat row list (headers + model rows)
2351
+ final List<Widget> rows = <Widget>[];
2352
+ for (final provider in providerOrder) {
2353
+ final models = grouped[provider]!;
2354
+ final providerColor = _providerPickerColor(provider);
2355
+ final available = models.where((m) => m.available).toList();
2356
+ final allGroupSelected = available.isNotEmpty &&
2357
+ available.every((m) => _selected.contains(m.id));
2358
+
2359
+ rows.add(Padding(
2360
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
2361
+ child: Row(
2362
+ children: <Widget>[
2363
+ Container(
2364
+ width: 6,
2365
+ height: 6,
2366
+ decoration: BoxDecoration(
2367
+ color: providerColor,
2368
+ shape: BoxShape.circle,
2274
2369
  ),
2275
- const SizedBox(width: 8),
2276
- Column(
2277
- crossAxisAlignment: CrossAxisAlignment.end,
2278
- children: <Widget>[
2279
- _StatusPill(
2280
- label: enabled ? provider.statusLabel : 'Disabled',
2281
- color: enabled ? provider.statusColor : _textSecondary,
2282
- ),
2283
- const SizedBox(height: 8),
2284
- InkWell(
2285
- onTap: hasAdvancedFields || models.isNotEmpty
2286
- ? onExpandToggle
2287
- : null,
2288
- borderRadius: BorderRadius.circular(999),
2289
- child: Container(
2290
- padding: const EdgeInsets.symmetric(
2291
- horizontal: 10,
2292
- vertical: 6,
2370
+ ),
2371
+ const SizedBox(width: 8),
2372
+ Expanded(
2373
+ child: Text(
2374
+ _providerPickerLabel(provider).toUpperCase(),
2375
+ style: TextStyle(
2376
+ fontSize: 10.5,
2377
+ fontWeight: FontWeight.w700,
2378
+ color: _textMuted,
2379
+ letterSpacing: 0.8,
2380
+ ),
2381
+ ),
2382
+ ),
2383
+ const SizedBox(width: 8),
2384
+ GestureDetector(
2385
+ onTap: available.isEmpty
2386
+ ? null
2387
+ : () {
2388
+ setState(() {
2389
+ if (allGroupSelected) {
2390
+ final toRemove =
2391
+ available.map((m) => m.id).toSet();
2392
+ final remaining =
2393
+ _selected.difference(toRemove);
2394
+ _selected = remaining.isNotEmpty
2395
+ ? remaining
2396
+ : <String>{_selected.first};
2397
+ } else {
2398
+ for (final m in available) {
2399
+ _selected.add(m.id);
2400
+ }
2401
+ }
2402
+ });
2403
+ },
2404
+ child: Text(
2405
+ allGroupSelected ? 'None' : 'All',
2406
+ style: TextStyle(
2407
+ fontSize: 11,
2408
+ fontWeight: FontWeight.w600,
2409
+ color: available.isEmpty ? _textMuted : _accent,
2410
+ ),
2411
+ ),
2412
+ ),
2413
+ ],
2414
+ ),
2415
+ ));
2416
+
2417
+ for (final model in models) {
2418
+ rows.add(_SmartPoolRow(
2419
+ model: model,
2420
+ selected: _selected.contains(model.id),
2421
+ onToggle: (val) => setState(() {
2422
+ if (val) {
2423
+ _selected.add(model.id);
2424
+ } else if (_selected.length > 1) {
2425
+ _selected.remove(model.id);
2426
+ }
2427
+ }),
2428
+ ));
2429
+ }
2430
+ }
2431
+
2432
+ return Center(
2433
+ child: ConstrainedBox(
2434
+ constraints: BoxConstraints(
2435
+ maxWidth: 560,
2436
+ minWidth: 320,
2437
+ maxHeight: MediaQuery.sizeOf(context).height * 0.85,
2438
+ ),
2439
+ child: Padding(
2440
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
2441
+ child: Material(
2442
+ color: _bgCard,
2443
+ borderRadius: BorderRadius.circular(20),
2444
+ elevation: 24,
2445
+ shadowColor: Colors.black.withValues(alpha: 0.5),
2446
+ child: Container(
2447
+ decoration: BoxDecoration(
2448
+ borderRadius: BorderRadius.circular(20),
2449
+ border: Border.all(color: _borderLight),
2450
+ ),
2451
+ child: ClipRRect(
2452
+ borderRadius: BorderRadius.circular(20),
2453
+ child: Column(
2454
+ mainAxisSize: MainAxisSize.min,
2455
+ children: <Widget>[
2456
+ // Header
2457
+ Padding(
2458
+ padding: const EdgeInsets.fromLTRB(20, 16, 10, 0),
2459
+ child: Row(
2460
+ children: <Widget>[
2461
+ Expanded(
2462
+ child: Text(
2463
+ 'Smart Selector Pool',
2464
+ style: TextStyle(
2465
+ fontSize: 17,
2466
+ fontWeight: FontWeight.w700,
2467
+ color: _textPrimary,
2468
+ ),
2469
+ ),
2470
+ ),
2471
+ IconButton(
2472
+ onPressed: () =>
2473
+ Navigator.of(context).pop(_selected),
2474
+ icon: Icon(
2475
+ Icons.close_rounded,
2476
+ size: 20,
2477
+ color: _textSecondary,
2478
+ ),
2479
+ style: IconButton.styleFrom(
2480
+ minimumSize: const Size(36, 36),
2481
+ padding: EdgeInsets.zero,
2482
+ tapTargetSize:
2483
+ MaterialTapTargetSize.shrinkWrap,
2484
+ ),
2485
+ ),
2486
+ ],
2293
2487
  ),
2294
- decoration: BoxDecoration(
2295
- color: _bgCard,
2296
- borderRadius: BorderRadius.circular(999),
2297
- border: Border.all(color: _border),
2488
+ ),
2489
+ // Search + available toggle
2490
+ Padding(
2491
+ padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
2492
+ child: Row(
2493
+ children: <Widget>[
2494
+ Expanded(
2495
+ child: TextField(
2496
+ controller: _searchCtrl,
2497
+ autofocus: true,
2498
+ onChanged: (v) =>
2499
+ setState(() => _query = v.trim()),
2500
+ style: TextStyle(
2501
+ color: _textPrimary,
2502
+ fontSize: 14,
2503
+ ),
2504
+ decoration: InputDecoration(
2505
+ hintText: 'Search models or providers…',
2506
+ hintStyle: TextStyle(
2507
+ color: _textMuted,
2508
+ fontSize: 14,
2509
+ ),
2510
+ prefixIcon: Icon(
2511
+ Icons.search_rounded,
2512
+ size: 18,
2513
+ color: _textMuted,
2514
+ ),
2515
+ suffixIcon: _query.isNotEmpty
2516
+ ? GestureDetector(
2517
+ onTap: () => setState(() {
2518
+ _searchCtrl.clear();
2519
+ _query = '';
2520
+ }),
2521
+ child: Padding(
2522
+ padding: const EdgeInsets.all(10),
2523
+ child: Icon(
2524
+ Icons.cancel_rounded,
2525
+ size: 16,
2526
+ color: _textMuted,
2527
+ ),
2528
+ ),
2529
+ )
2530
+ : null,
2531
+ isDense: true,
2532
+ contentPadding: const EdgeInsets.symmetric(
2533
+ vertical: 10),
2534
+ filled: true,
2535
+ fillColor: _bgSecondary,
2536
+ border: OutlineInputBorder(
2537
+ borderRadius: BorderRadius.circular(12),
2538
+ borderSide:
2539
+ BorderSide(color: _border),
2540
+ ),
2541
+ enabledBorder: OutlineInputBorder(
2542
+ borderRadius: BorderRadius.circular(12),
2543
+ borderSide:
2544
+ BorderSide(color: _border),
2545
+ ),
2546
+ focusedBorder: OutlineInputBorder(
2547
+ borderRadius: BorderRadius.circular(12),
2548
+ borderSide: BorderSide(
2549
+ color: _accent,
2550
+ width: 1.5,
2551
+ ),
2552
+ ),
2553
+ ),
2554
+ ),
2555
+ ),
2556
+ const SizedBox(width: 8),
2557
+ GestureDetector(
2558
+ onTap: () => setState(
2559
+ () => _onlyAvailable = !_onlyAvailable),
2560
+ child: AnimatedContainer(
2561
+ duration: const Duration(milliseconds: 150),
2562
+ padding: const EdgeInsets.symmetric(
2563
+ horizontal: 10,
2564
+ vertical: 7,
2565
+ ),
2566
+ decoration: BoxDecoration(
2567
+ color: _onlyAvailable
2568
+ ? _accentMuted
2569
+ : _bgSecondary,
2570
+ borderRadius: BorderRadius.circular(8),
2571
+ border: Border.all(
2572
+ color: _onlyAvailable
2573
+ ? _accent.withValues(alpha: 0.5)
2574
+ : _border,
2575
+ ),
2576
+ ),
2577
+ child: Text(
2578
+ 'Available',
2579
+ style: TextStyle(
2580
+ fontSize: 12,
2581
+ fontWeight: FontWeight.w600,
2582
+ color: _onlyAvailable
2583
+ ? _accentHover
2584
+ : _textSecondary,
2585
+ ),
2586
+ ),
2587
+ ),
2588
+ ),
2589
+ ],
2298
2590
  ),
2591
+ ),
2592
+ // Quick-action toolbar
2593
+ Padding(
2594
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
2299
2595
  child: Row(
2300
- mainAxisSize: MainAxisSize.min,
2301
2596
  children: <Widget>[
2302
- Text(
2303
- expanded ? 'Hide' : 'Setup',
2304
- style: TextStyle(fontSize: 12),
2597
+ _PoolActionChip(
2598
+ label: 'Select all',
2599
+ onTap: () => _selectAllVisible(filtered),
2305
2600
  ),
2306
- const SizedBox(width: 4),
2307
- Icon(
2308
- expanded
2309
- ? Icons.keyboard_arrow_up
2310
- : Icons.keyboard_arrow_down,
2311
- size: 16,
2312
- color: _textSecondary,
2601
+ const SizedBox(width: 6),
2602
+ _PoolActionChip(
2603
+ label: 'Clear all',
2604
+ onTap: () => _clearAllVisible(filtered),
2605
+ ),
2606
+ const Spacer(),
2607
+ Text(
2608
+ '$selectedAvailableCount selected',
2609
+ style: TextStyle(
2610
+ fontSize: 12,
2611
+ color: _textMuted,
2612
+ ),
2313
2613
  ),
2314
2614
  ],
2315
2615
  ),
2316
2616
  ),
2317
- ),
2318
- ],
2319
- ),
2320
- ],
2321
- ),
2322
- const SizedBox(height: 12),
2323
- Wrap(
2324
- spacing: 8,
2325
- runSpacing: 8,
2326
- children: <Widget>[
2327
- _MetaPill(
2328
- label: '$availableCount of ${models.length} models ready',
2329
- icon: Icons.memory_outlined,
2330
- ),
2331
- if (provider.supportsApiKey && provider.credentialConfigured)
2332
- const _MetaPill(
2333
- label: 'Credentials ready',
2334
- icon: Icons.lock_outline,
2335
- ),
2336
- if (provider.supportsApiKey && !provider.credentialConfigured)
2337
- const _MetaPill(
2338
- label: 'Credentials needed',
2339
- icon: Icons.admin_panel_settings_outlined,
2340
- ),
2341
- if (provider.supportsBaseUrl &&
2342
- baseUrlController.text.trim().isNotEmpty)
2343
- _MetaPill(
2344
- label: _friendlyBaseUrlLabel(baseUrlController.text.trim()),
2345
- icon: Icons.link_outlined,
2617
+ Divider(height: 1, thickness: 1, color: _border),
2618
+ // Model list
2619
+ Flexible(
2620
+ child: rows.isEmpty
2621
+ ? Padding(
2622
+ padding: const EdgeInsets.all(36),
2623
+ child: Column(
2624
+ mainAxisSize: MainAxisSize.min,
2625
+ children: <Widget>[
2626
+ Icon(
2627
+ Icons.search_off_rounded,
2628
+ size: 36,
2629
+ color: _textMuted,
2630
+ ),
2631
+ const SizedBox(height: 12),
2632
+ Text(
2633
+ 'No results for "$_query"',
2634
+ style: TextStyle(
2635
+ color: _textSecondary,
2636
+ fontSize: 14,
2637
+ ),
2638
+ ),
2639
+ ],
2640
+ ),
2641
+ )
2642
+ : ListView(
2643
+ padding:
2644
+ const EdgeInsets.only(top: 4, bottom: 8),
2645
+ shrinkWrap: true,
2646
+ children: rows,
2647
+ ),
2648
+ ),
2649
+ ],
2346
2650
  ),
2347
- ],
2348
- ),
2349
- const SizedBox(height: 12),
2350
- Container(
2351
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
2352
- decoration: BoxDecoration(
2353
- color: _bgCard,
2354
- borderRadius: BorderRadius.circular(14),
2355
- border: Border.all(color: _border),
2651
+ ),
2356
2652
  ),
2653
+ ),
2654
+ ),
2655
+ ),
2656
+ );
2657
+ }
2658
+ }
2659
+
2660
+ // ─────────────────────────────────────────────────────────────────────────────
2661
+ // Smart Pool Row — individual model row inside the dialog
2662
+ // ─────────────────────────────────────────────────────────────────────────────
2663
+
2664
+ class _SmartPoolRow extends StatelessWidget {
2665
+ const _SmartPoolRow({
2666
+ required this.model,
2667
+ required this.selected,
2668
+ required this.onToggle,
2669
+ });
2670
+
2671
+ final ModelMeta model;
2672
+ final bool selected;
2673
+ final ValueChanged<bool> onToggle;
2674
+
2675
+ @override
2676
+ Widget build(BuildContext context) {
2677
+ final color = _providerPickerColor(model.provider);
2678
+ return Opacity(
2679
+ opacity: model.available ? 1.0 : 0.4,
2680
+ child: Material(
2681
+ color: selected
2682
+ ? _accentMuted.withValues(alpha: 0.12)
2683
+ : Colors.transparent,
2684
+ child: InkWell(
2685
+ onTap: model.available ? () => onToggle(!selected) : null,
2686
+ child: Padding(
2687
+ padding:
2688
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
2357
2689
  child: Row(
2358
2690
  children: <Widget>[
2359
- Expanded(
2360
- child: Text(
2361
- provider.availabilityReason,
2362
- style: TextStyle(color: _textSecondary, height: 1.35),
2691
+ // Thin provider accent bar on the left
2692
+ Container(
2693
+ width: 3,
2694
+ height: 30,
2695
+ decoration: BoxDecoration(
2696
+ color: color.withValues(
2697
+ alpha: selected ? 0.85 : 0.28),
2698
+ borderRadius: BorderRadius.circular(2),
2363
2699
  ),
2364
2700
  ),
2365
- const SizedBox(width: 12),
2366
- Switch(value: enabled, onChanged: onEnabledChanged),
2367
- ],
2368
- ),
2369
- ),
2370
- if (expanded) ...<Widget>[
2371
- const SizedBox(height: 14),
2372
- if (provider.supportsApiKey)
2373
- Container(
2374
- width: double.infinity,
2375
- padding: const EdgeInsets.all(12),
2376
- margin: const EdgeInsets.only(bottom: 12),
2377
- decoration: BoxDecoration(
2378
- color: _bgCard,
2379
- borderRadius: BorderRadius.circular(14),
2380
- border: Border.all(color: _border),
2381
- ),
2382
- child: Text(
2383
- provider.credentialConfigured
2384
- ? 'Credentials for this provider are already available to the runtime.'
2385
- : 'Credentials for this provider are managed outside this workspace UI. Finish the server or admin setup, then return here to enable routing.',
2386
- style: TextStyle(color: _textSecondary, height: 1.35),
2387
- ),
2388
- ),
2389
- if (provider.supportsBaseUrl) ...<Widget>[
2390
- TextField(
2391
- controller: baseUrlController,
2392
- keyboardType: TextInputType.url,
2393
- autocorrect: false,
2394
- decoration: InputDecoration(
2395
- labelText: provider.id == 'ollama'
2396
- ? 'Server URL'
2397
- : 'Base URL',
2398
- helperText: provider.defaultBaseUrl.trim().isEmpty
2399
- ? 'Optional override.'
2400
- : 'Default: ${provider.defaultBaseUrl}',
2701
+ const SizedBox(width: 10),
2702
+ SizedBox(
2703
+ width: 20,
2704
+ height: 20,
2705
+ child: Checkbox(
2706
+ value: selected,
2707
+ onChanged: model.available
2708
+ ? (v) => onToggle(v ?? false)
2709
+ : null,
2710
+ activeColor: _accent,
2711
+ side: BorderSide(color: _textMuted, width: 1.5),
2712
+ materialTapTargetSize:
2713
+ MaterialTapTargetSize.shrinkWrap,
2714
+ visualDensity: VisualDensity.compact,
2715
+ ),
2401
2716
  ),
2402
- ),
2403
- const SizedBox(height: 12),
2404
- ],
2405
- if (models.isNotEmpty) ...<Widget>[
2406
- Text('Models', style: TextStyle(fontWeight: FontWeight.w700)),
2407
- const SizedBox(height: 8),
2408
- Wrap(
2409
- spacing: 8,
2410
- runSpacing: 8,
2411
- children: models
2412
- .map(
2413
- (model) => Container(
2414
- padding: const EdgeInsets.symmetric(
2415
- horizontal: 10,
2416
- vertical: 8,
2417
- ),
2418
- decoration: BoxDecoration(
2419
- color: model.available ? _bgCard : _bgPrimary,
2420
- borderRadius: BorderRadius.circular(999),
2421
- border: Border.all(
2422
- color: model.available ? _border : _borderLight,
2423
- ),
2717
+ const SizedBox(width: 10),
2718
+ Expanded(
2719
+ child: Column(
2720
+ crossAxisAlignment: CrossAxisAlignment.start,
2721
+ children: <Widget>[
2722
+ Text(
2723
+ model.label,
2724
+ style: TextStyle(
2725
+ fontSize: 13,
2726
+ fontWeight: FontWeight.w500,
2727
+ color:
2728
+ selected ? _accentHover : _textPrimary,
2424
2729
  ),
2425
- child: Text(
2426
- model.label,
2730
+ maxLines: 1,
2731
+ overflow: TextOverflow.ellipsis,
2732
+ ),
2733
+ if (model.purpose.isNotEmpty)
2734
+ Text(
2735
+ model.purpose,
2427
2736
  style: TextStyle(
2428
- fontSize: 12,
2429
- color: model.available
2430
- ? _textPrimary
2431
- : _textSecondary,
2737
+ fontSize: 11,
2738
+ color: _textMuted,
2432
2739
  ),
2433
2740
  ),
2434
- ),
2435
- )
2436
- .toList(),
2437
- ),
2438
- ],
2439
- ],
2440
- ],
2741
+ ],
2742
+ ),
2743
+ ),
2744
+ const SizedBox(width: 8),
2745
+ if (model.priceTier != null)
2746
+ _PriceTierChip(tier: model.priceTier!),
2747
+ const SizedBox(width: 2),
2748
+ ],
2749
+ ),
2750
+ ),
2751
+ ),
2441
2752
  ),
2442
2753
  );
2443
2754
  }
2444
2755
  }
2445
2756
 
2446
- class _RoutingSelectCard extends StatelessWidget {
2447
- const _RoutingSelectCard({
2448
- required this.label,
2449
- required this.icon,
2450
- required this.value,
2451
- required this.options,
2452
- required this.onChanged,
2453
- });
2454
-
2757
+ // Toolbar chip button used inside _SmartPoolDialog
2758
+ class _PoolActionChip extends StatelessWidget {
2759
+ const _PoolActionChip({required this.label, required this.onTap});
2455
2760
  final String label;
2456
- final IconData icon;
2457
- final String value;
2458
- final List<_ModelPickerOption> options;
2459
- final ValueChanged<String?> onChanged;
2761
+ final VoidCallback onTap;
2460
2762
 
2461
2763
  @override
2462
2764
  Widget build(BuildContext context) {
2463
- return Container(
2464
- padding: const EdgeInsets.all(14),
2465
- decoration: BoxDecoration(
2466
- color: _bgSecondary,
2467
- borderRadius: BorderRadius.circular(16),
2468
- border: Border.all(color: _border),
2469
- ),
2470
- child: Column(
2471
- crossAxisAlignment: CrossAxisAlignment.start,
2472
- children: <Widget>[
2473
- Row(
2474
- children: <Widget>[
2475
- Icon(icon, size: 16, color: _accentHover),
2476
- const SizedBox(width: 8),
2477
- Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
2478
- ],
2479
- ),
2480
- const SizedBox(height: 10),
2481
- _ModelPickerButton(
2482
- value: value,
2483
- options: options,
2484
- onChanged: onChanged,
2485
- dialogTitle: 'Select $label',
2765
+ return GestureDetector(
2766
+ onTap: onTap,
2767
+ child: Container(
2768
+ padding:
2769
+ const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
2770
+ decoration: BoxDecoration(
2771
+ color: _bgSecondary,
2772
+ borderRadius: BorderRadius.circular(8),
2773
+ border: Border.all(color: _border),
2774
+ ),
2775
+ child: Text(
2776
+ label,
2777
+ style: TextStyle(
2778
+ fontSize: 12,
2779
+ fontWeight: FontWeight.w500,
2780
+ color: _textSecondary,
2486
2781
  ),
2487
- ],
2782
+ ),
2488
2783
  ),
2489
2784
  );
2490
2785
  }