groove-dev 0.27.143 → 0.27.145

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 (251) hide show
  1. package/CLAUDE.md +0 -7
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +18 -48
  6. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
  7. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
  9. package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
  10. package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
  11. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  13. package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
  14. package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
  15. package/node_modules/@groove-dev/daemon/src/routes/agents.js +812 -0
  16. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
  17. package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
  18. package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
  19. package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
  21. package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
  22. package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
  23. package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
  24. package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
  25. package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
  26. package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
  27. package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
  28. package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
  29. package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
  30. package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
  31. package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
  32. package/node_modules/@groove-dev/gui/dist/assets/index-Bxc0gU06.js +1006 -0
  33. package/node_modules/@groove-dev/gui/dist/assets/index-C0pztKBn.css +1 -0
  34. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  35. package/node_modules/@groove-dev/gui/package.json +1 -1
  36. package/node_modules/@groove-dev/gui/src/{app.jsx → App.jsx} +0 -2
  37. package/node_modules/@groove-dev/gui/src/app.css +35 -0
  38. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
  39. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +210 -112
  40. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +2 -70
  42. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
  43. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
  44. package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
  45. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
  46. package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
  47. package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
  48. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +2 -0
  49. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +68 -66
  50. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +4 -8
  51. package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
  52. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
  53. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
  54. package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  55. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
  56. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
  57. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
  58. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  59. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
  60. package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +39 -31
  61. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
  62. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
  63. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +200 -18
  64. package/node_modules/@groove-dev/gui/src/components/lab/preset-manager.jsx +17 -14
  65. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +335 -152
  66. package/node_modules/@groove-dev/gui/src/components/lab/system-prompt-editor.jsx +10 -8
  67. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
  68. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
  69. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
  70. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
  71. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
  72. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
  73. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
  74. package/node_modules/@groove-dev/gui/src/components/ui/slider.jsx +8 -8
  75. package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
  76. package/node_modules/@groove-dev/gui/src/lib/status.js +25 -24
  77. package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
  78. package/node_modules/@groove-dev/gui/src/stores/groove.js +51 -3144
  79. package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
  80. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +459 -0
  81. package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
  82. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +226 -0
  83. package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
  84. package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
  85. package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
  86. package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
  87. package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
  88. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
  89. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
  90. package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
  91. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
  92. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
  93. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +54 -12
  94. package/node_modules/@groove-dev/gui/src/views/models.jsx +419 -496
  95. package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
  96. package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
  97. package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
  98. package/node_modules/axios/CHANGELOG.md +260 -0
  99. package/node_modules/axios/README.md +595 -223
  100. package/node_modules/axios/dist/axios.js +1460 -1090
  101. package/node_modules/axios/dist/axios.js.map +1 -1
  102. package/node_modules/axios/dist/axios.min.js +3 -3
  103. package/node_modules/axios/dist/axios.min.js.map +1 -1
  104. package/node_modules/axios/dist/browser/axios.cjs +1560 -1132
  105. package/node_modules/axios/dist/browser/axios.cjs.map +1 -1
  106. package/node_modules/axios/dist/esm/axios.js +1557 -1128
  107. package/node_modules/axios/dist/esm/axios.js.map +1 -1
  108. package/node_modules/axios/dist/esm/axios.min.js +2 -2
  109. package/node_modules/axios/dist/esm/axios.min.js.map +1 -1
  110. package/node_modules/axios/dist/node/axios.cjs +1594 -1057
  111. package/node_modules/axios/dist/node/axios.cjs.map +1 -1
  112. package/node_modules/axios/index.d.cts +40 -41
  113. package/node_modules/axios/index.d.ts +151 -227
  114. package/node_modules/axios/index.js +2 -0
  115. package/node_modules/axios/lib/adapters/adapters.js +4 -2
  116. package/node_modules/axios/lib/adapters/fetch.js +147 -16
  117. package/node_modules/axios/lib/adapters/http.js +306 -58
  118. package/node_modules/axios/lib/adapters/xhr.js +6 -2
  119. package/node_modules/axios/lib/core/Axios.js +7 -3
  120. package/node_modules/axios/lib/core/AxiosError.js +120 -34
  121. package/node_modules/axios/lib/core/AxiosHeaders.js +27 -25
  122. package/node_modules/axios/lib/core/buildFullPath.js +1 -1
  123. package/node_modules/axios/lib/core/dispatchRequest.js +19 -7
  124. package/node_modules/axios/lib/core/mergeConfig.js +21 -4
  125. package/node_modules/axios/lib/core/settle.js +7 -11
  126. package/node_modules/axios/lib/defaults/index.js +14 -9
  127. package/node_modules/axios/lib/env/data.js +1 -1
  128. package/node_modules/axios/lib/helpers/AxiosURLSearchParams.js +1 -2
  129. package/node_modules/axios/lib/helpers/buildURL.js +1 -1
  130. package/node_modules/axios/lib/helpers/cookies.js +14 -2
  131. package/node_modules/axios/lib/helpers/estimateDataURLDecodedBytes.js +28 -1
  132. package/node_modules/axios/lib/helpers/formDataToJSON.js +3 -1
  133. package/node_modules/axios/lib/helpers/formDataToStream.js +3 -2
  134. package/node_modules/axios/lib/helpers/parseProtocol.js +1 -1
  135. package/node_modules/axios/lib/helpers/progressEventReducer.js +5 -5
  136. package/node_modules/axios/lib/helpers/resolveConfig.js +54 -18
  137. package/node_modules/axios/lib/helpers/shouldBypassProxy.js +74 -2
  138. package/node_modules/axios/lib/helpers/toFormData.js +10 -2
  139. package/node_modules/axios/lib/helpers/validator.js +3 -1
  140. package/node_modules/axios/lib/utils.js +33 -21
  141. package/node_modules/axios/package.json +17 -24
  142. package/node_modules/follow-redirects/README.md +7 -5
  143. package/node_modules/follow-redirects/index.js +24 -1
  144. package/node_modules/follow-redirects/package.json +1 -1
  145. package/package.json +1 -1
  146. package/packages/cli/package.json +1 -1
  147. package/packages/daemon/package.json +1 -1
  148. package/packages/daemon/src/api.js +1086 -6532
  149. package/packages/daemon/src/conversations.js +18 -48
  150. package/packages/daemon/src/gateways/manager.js +35 -1
  151. package/packages/daemon/src/index.js +3 -0
  152. package/packages/daemon/src/journalist.js +23 -13
  153. package/packages/daemon/src/mlx-server.js +365 -0
  154. package/packages/daemon/src/model-lab.js +308 -12
  155. package/packages/daemon/src/pm.js +1 -1
  156. package/packages/daemon/src/process.js +2 -2
  157. package/packages/daemon/src/providers/local.js +36 -8
  158. package/packages/daemon/src/registry.js +21 -5
  159. package/packages/daemon/src/routes/agents.js +812 -0
  160. package/packages/daemon/src/routes/coordination.js +318 -0
  161. package/packages/daemon/src/routes/files.js +751 -0
  162. package/packages/daemon/src/routes/integrations.js +485 -0
  163. package/packages/daemon/src/routes/network.js +1784 -0
  164. package/packages/daemon/src/routes/providers.js +755 -0
  165. package/packages/daemon/src/routes/schedules.js +110 -0
  166. package/packages/daemon/src/routes/teams.js +650 -0
  167. package/packages/daemon/src/scheduler.js +456 -24
  168. package/packages/daemon/src/teams.js +1 -1
  169. package/packages/daemon/src/validate.js +38 -1
  170. package/packages/daemon/templates/mlx-setup.json +12 -0
  171. package/packages/daemon/templates/tgi-setup.json +1 -1
  172. package/packages/daemon/templates/vllm-setup.json +1 -1
  173. package/packages/gui/dist/assets/index-Bxc0gU06.js +1006 -0
  174. package/packages/gui/dist/assets/index-C0pztKBn.css +1 -0
  175. package/packages/gui/dist/index.html +2 -2
  176. package/packages/gui/package.json +1 -1
  177. package/packages/gui/src/{app.jsx → App.jsx} +0 -2
  178. package/packages/gui/src/app.css +35 -0
  179. package/packages/gui/src/components/agents/agent-config.jsx +1 -128
  180. package/packages/gui/src/components/agents/agent-feed.jsx +210 -112
  181. package/packages/gui/src/components/agents/agent-node.jsx +8 -13
  182. package/packages/gui/src/components/agents/agent-panel.jsx +2 -70
  183. package/packages/gui/src/components/agents/code-review.jsx +159 -122
  184. package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
  185. package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
  186. package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
  187. package/packages/gui/src/components/automations/automation-card.jsx +274 -0
  188. package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
  189. package/packages/gui/src/components/chat/chat-header.jsx +2 -0
  190. package/packages/gui/src/components/chat/chat-input.jsx +68 -66
  191. package/packages/gui/src/components/chat/chat-view.jsx +4 -8
  192. package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
  193. package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
  194. package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
  195. package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  196. package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
  197. package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
  198. package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
  199. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  200. package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
  201. package/packages/gui/src/components/lab/chat-playground.jsx +39 -31
  202. package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
  203. package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
  204. package/packages/gui/src/components/lab/parameter-panel.jsx +200 -18
  205. package/packages/gui/src/components/lab/preset-manager.jsx +17 -14
  206. package/packages/gui/src/components/lab/runtime-config.jsx +335 -152
  207. package/packages/gui/src/components/lab/system-prompt-editor.jsx +10 -8
  208. package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
  209. package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
  210. package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
  211. package/packages/gui/src/components/network/network-health.jsx +2 -2
  212. package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
  213. package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
  214. package/packages/gui/src/components/ui/sheet.jsx +5 -2
  215. package/packages/gui/src/components/ui/slider.jsx +8 -8
  216. package/packages/gui/src/lib/cron.js +64 -0
  217. package/packages/gui/src/lib/status.js +25 -24
  218. package/packages/gui/src/lib/theme-hex.js +1 -0
  219. package/packages/gui/src/stores/groove.js +51 -3144
  220. package/packages/gui/src/stores/helpers.js +10 -0
  221. package/packages/gui/src/stores/slices/agents-slice.js +459 -0
  222. package/packages/gui/src/stores/slices/automations-slice.js +96 -0
  223. package/packages/gui/src/stores/slices/chat-slice.js +226 -0
  224. package/packages/gui/src/stores/slices/editor-slice.js +285 -0
  225. package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
  226. package/packages/gui/src/stores/slices/network-slice.js +361 -0
  227. package/packages/gui/src/stores/slices/preview-slice.js +109 -0
  228. package/packages/gui/src/stores/slices/providers-slice.js +897 -0
  229. package/packages/gui/src/stores/slices/teams-slice.js +413 -0
  230. package/packages/gui/src/stores/slices/ui-slice.js +98 -0
  231. package/packages/gui/src/views/agents.jsx +5 -5
  232. package/packages/gui/src/views/dashboard.jsx +12 -13
  233. package/packages/gui/src/views/marketplace.jsx +191 -3
  234. package/packages/gui/src/views/model-lab.jsx +54 -12
  235. package/packages/gui/src/views/models.jsx +419 -496
  236. package/packages/gui/src/views/network.jsx +3 -3
  237. package/packages/gui/src/views/settings.jsx +81 -94
  238. package/packages/gui/src/views/teams.jsx +40 -483
  239. package/SECURITY_SWEEP.md +0 -228
  240. package/TRAINING_DATA_v4.md +0 -6
  241. package/node_modules/@groove-dev/gui/dist/assets/index-CCVvAoQn.css +0 -1
  242. package/node_modules/@groove-dev/gui/dist/assets/index-DGIv_TRm.js +0 -984
  243. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -379
  244. package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
  245. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
  246. package/packages/gui/dist/assets/index-CCVvAoQn.css +0 -1
  247. package/packages/gui/dist/assets/index-DGIv_TRm.js +0 -984
  248. package/packages/gui/src/components/agents/agent-chat.jsx +0 -379
  249. package/packages/gui/src/views/preview.jsx +0 -6
  250. package/packages/gui/src/views/subscription-panel.jsx +0 -327
  251. package/test.py +0 -571
@@ -1,6 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useEffect, useRef, useMemo } from 'react';
3
- import { Badge } from '../components/ui/badge';
4
3
  import { Button } from '../components/ui/button';
5
4
  import { api } from '../lib/api';
6
5
  import { useToast } from '../lib/hooks/use-toast';
@@ -9,7 +8,7 @@ import {
9
8
  Search, Download, Trash2, HardDrive, Cpu, MemoryStick,
10
9
  Check, Loader2, Box, ChevronDown, ChevronRight,
11
10
  RefreshCw, Play, Square, Rocket, MoreHorizontal,
12
- Sparkles, FlaskConical, ExternalLink,
11
+ ExternalLink,
13
12
  } from 'lucide-react';
14
13
  import { cn } from '../lib/cn';
15
14
 
@@ -33,85 +32,101 @@ const FILTERS = [
33
32
  { id: 'downloaded', label: 'Downloaded' },
34
33
  ];
35
34
 
36
- const STATUS_CONFIG = {
37
- running: { label: 'Running', variant: 'success', dot: 'pulse' },
38
- ready: { label: 'Ready', variant: 'info', dot: true },
39
- downloaded: { label: 'Downloaded', variant: 'purple', dot: true },
40
- downloading: { label: 'Downloading', variant: 'accent', dot: 'pulse' },
35
+ const STATUS_LABEL = {
36
+ running: 'running',
37
+ ready: 'ready',
38
+ downloaded: 'downloaded',
39
+ downloading: 'pulling',
41
40
  };
42
41
 
43
- // ── Unified Model Card ──────────────────────────────────────────
42
+ // ── Model Card ─────────────────────────────────────────────────
44
43
 
45
- function UnifiedModelCard({
46
- model, serverRunning,
47
- onStart, onStop, onSpawn, onDelete, onImport,
48
- isLoading, isUnloading, isDeleting, isImporting,
44
+ function ModelCard({
45
+ model, runtimes,
46
+ onStop, onSpawn, onDelete,
47
+ onStartRuntime, onCreateRuntime,
48
+ isStarting, isUnloading, isDeleting,
49
49
  }) {
50
50
  const [menuOpen, setMenuOpen] = useState(false);
51
+ const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
51
52
  const menuRef = useRef(null);
53
+ const runtimeRef = useRef(null);
52
54
 
53
55
  useEffect(() => {
54
- if (!menuOpen) return;
56
+ if (!menuOpen && !runtimeMenuOpen) return;
55
57
  const close = (e) => {
56
58
  if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
59
+ if (runtimeRef.current && !runtimeRef.current.contains(e.target)) setRuntimeMenuOpen(false);
57
60
  };
58
61
  document.addEventListener('mousedown', close);
59
62
  return () => document.removeEventListener('mousedown', close);
60
- }, [menuOpen]);
63
+ }, [menuOpen, runtimeMenuOpen]);
64
+
65
+ function handleRuntimeClick() {
66
+ if (runtimes.length === 0) {
67
+ onCreateRuntime();
68
+ } else if (runtimes.length === 1) {
69
+ onStartRuntime(model.id, runtimes[0]);
70
+ } else {
71
+ setRuntimeMenuOpen(!runtimeMenuOpen);
72
+ }
73
+ }
74
+
75
+ const specs = [
76
+ model.parameters,
77
+ model.quantization,
78
+ model.size && model.size !== '—' && model.size,
79
+ ].filter(Boolean);
61
80
 
62
- const status = STATUS_CONFIG[model.status] || STATUS_CONFIG.ready;
81
+ const formatTag = model.formatTag || (model.source === 'gguf' ? 'GGUF' : model.source === 'mlx' ? 'MLX' : model.source === 'hf' ? 'HF' : 'Ollama');
63
82
 
64
83
  return (
65
- <div className={cn(
66
- 'group rounded-xl border p-4 transition-all',
67
- model.status === 'running'
68
- ? 'bg-success/5 border-success/20 hover:border-success/40'
69
- : 'bg-surface-1 border-border-subtle hover:border-accent/30',
70
- )}>
71
- {/* Header: name + badges + menu */}
72
- <div className="flex items-start justify-between gap-2 mb-2">
73
- <div className="min-w-0 flex-1">
74
- <span className="text-sm font-mono font-bold text-text-0 truncate block">{model.name}</span>
75
- <div className="flex items-center gap-1.5 mt-1 flex-wrap">
76
- <Badge variant={model.source === 'ollama' ? 'info' : 'purple'} className="text-2xs">
77
- {model.source === 'ollama' ? 'Ollama' : 'GGUF'}
78
- </Badge>
79
- <Badge variant={status.variant} dot={status.dot} className="text-2xs">
80
- {status.label}
81
- </Badge>
82
- {model.isInLab && (
83
- <Badge variant="accent" className="text-2xs gap-0.5">
84
- <FlaskConical size={8} /> Lab
85
- </Badge>
84
+ <div className="group flex flex-col p-5 rounded-md border border-border-subtle bg-surface-1 min-h-[180px]">
85
+ {/* Header: icon + name + menu */}
86
+ <div className="flex items-start gap-3 mb-3">
87
+ <div
88
+ className="w-9 h-9 rounded-md flex items-center justify-center flex-shrink-0 text-base font-bold font-sans mt-0.5"
89
+ style={{
90
+ background: `hsl(${(model.name || '').charCodeAt(0) * 37 % 360}, 40%, 18%)`,
91
+ color: `hsl(${(model.name || '').charCodeAt(0) * 37 % 360}, 60%, 65%)`,
92
+ }}
93
+ >
94
+ {(model.name || '?')[0].toUpperCase()}
95
+ </div>
96
+ <div className="flex-1 min-w-0">
97
+ <div className="flex items-center gap-1.5">
98
+ <span className="text-[14px] font-semibold text-text-0 font-sans truncate">{model.name}</span>
99
+ {model.status === 'running' && (
100
+ <span className="relative flex-shrink-0 w-2 h-2">
101
+ <span className="absolute inset-0 rounded-full bg-success" />
102
+ <span className="absolute inset-[-2px] rounded-full bg-success opacity-30 animate-pulse" />
103
+ </span>
104
+ )}
105
+ {model.status === 'downloading' && (
106
+ <Loader2 size={12} className="animate-spin text-text-3 flex-shrink-0" />
86
107
  )}
87
108
  </div>
109
+ <span className="text-2xs text-text-3 font-sans">
110
+ {formatTag} · {STATUS_LABEL[model.status]}
111
+ </span>
88
112
  </div>
89
-
113
+ {/* Overflow menu — upper right */}
90
114
  {model.status !== 'downloading' && (
91
- <div ref={menuRef} className="relative flex-shrink-0">
115
+ <div ref={menuRef} className="relative flex-shrink-0 -mt-0.5 -mr-1">
92
116
  <button
93
117
  onClick={() => setMenuOpen(!menuOpen)}
94
- className="p-1.5 rounded-md text-text-4 hover:text-text-2 hover:bg-surface-3 transition-colors cursor-pointer"
118
+ className="p-1 rounded text-text-4 hover:text-text-2 transition-colors cursor-pointer"
95
119
  >
96
120
  <MoreHorizontal size={14} />
97
121
  </button>
98
122
  {menuOpen && (
99
- <div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-surface-3 border border-border rounded-lg shadow-lg py-1">
100
- {model.source === 'gguf' && (
101
- <button
102
- onClick={() => { onImport(model.id); setMenuOpen(false); }}
103
- disabled={isImporting}
104
- className="w-full text-left px-3 py-1.5 text-xs font-sans text-text-1 hover:bg-surface-4 transition-colors cursor-pointer flex items-center gap-2 disabled:opacity-40"
105
- >
106
- <Rocket size={12} /> Import to Ollama
107
- </button>
108
- )}
123
+ <div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-surface-2 border border-border rounded-md shadow-lg py-1">
109
124
  <button
110
125
  onClick={() => { onDelete(model); setMenuOpen(false); }}
111
126
  disabled={isDeleting}
112
- className="w-full text-left px-3 py-1.5 text-xs font-sans text-danger hover:bg-danger/10 transition-colors cursor-pointer flex items-center gap-2 disabled:opacity-40"
127
+ className="w-full text-left px-3 py-1.5 text-xs font-sans text-danger hover:bg-danger/5 transition-colors cursor-pointer flex items-center gap-2 disabled:opacity-40"
113
128
  >
114
- <Trash2 size={12} /> Delete
129
+ <Trash2 size={10} /> Delete
115
130
  </button>
116
131
  </div>
117
132
  )}
@@ -120,111 +135,99 @@ function UnifiedModelCard({
120
135
  </div>
121
136
 
122
137
  {/* Specs */}
123
- <div className="text-2xs text-text-3 font-sans mb-3 flex items-center gap-1.5 flex-wrap">
124
- {model.parameters && <span>{model.parameters}</span>}
125
- {model.parameters && model.quantization && <span className="text-text-4">&middot;</span>}
126
- {model.quantization && <span>{model.quantization}</span>}
127
- {(model.parameters || model.quantization) && model.size && model.size !== '—' && (
128
- <span className="text-text-4">&middot;</span>
129
- )}
130
- {model.size && model.size !== '—' && <span>{model.size}</span>}
131
- {model.vramGb && (
132
- <>
133
- <span className="text-text-4">&middot;</span>
134
- <span className="text-green-400">{model.vramGb} GB VRAM</span>
135
- </>
136
- )}
137
- {model.repoId && (
138
- <>
139
- <span className="text-text-4">&middot;</span>
140
- <span className="truncate max-w-[140px]">{model.repoId}</span>
141
- </>
142
- )}
143
- </div>
138
+ <p className="text-xs text-text-2 font-sans leading-relaxed">
139
+ {specs.length > 0 ? specs.join(' · ') : 'Local model'}
140
+ {model.vramGb ? ` · ${model.vramGb} GB VRAM` : ''}
141
+ </p>
144
142
 
145
- {/* Download progress inline */}
143
+ {/* Download progress */}
146
144
  {model.status === 'downloading' && model.download && (
147
- <div className="mb-3 space-y-1">
148
- <div className="flex items-center justify-between text-2xs font-sans text-text-3">
149
- <span className="truncate">{model.download.filename}</span>
150
- <span>{Math.round((model.download.percent || 0) * 100)}% {formatSpeed(model.download.speed)}</span>
151
- </div>
152
- <div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
153
- <div
154
- className="h-full bg-accent rounded-full transition-all"
155
- style={{ width: `${Math.round((model.download.percent || 0) * 100)}%` }}
156
- />
157
- </div>
158
- <div className="text-2xs text-text-4">
159
- {formatBytes(model.download.downloaded)} / {formatBytes(model.download.totalBytes)}
145
+ <div className="mt-2">
146
+ <div className="flex items-center gap-2 mb-1">
147
+ <div className="flex-1 h-1.5 rounded-full overflow-hidden bg-surface-4">
148
+ <div className="h-full rounded-full bg-accent transition-all" style={{ width: `${Math.round((model.download.percent || 0) * 100)}%` }} />
149
+ </div>
150
+ <span className="text-2xs font-mono text-text-3 tabular-nums">
151
+ {Math.round((model.download.percent || 0) * 100)}%
152
+ </span>
160
153
  </div>
154
+ {model.download.speed && (
155
+ <div className="text-2xs font-mono text-text-4">{formatSpeed(model.download.speed)}</div>
156
+ )}
161
157
  </div>
162
158
  )}
163
159
  {model.status === 'downloading' && model.pullProgress && (
164
- <div className="mb-3">
165
- <div className="flex items-center gap-2">
166
- <Loader2 size={12} className="animate-spin text-accent flex-shrink-0" />
167
- <span className="text-2xs text-text-3 font-sans truncate">
168
- {model.pullProgress.progress || 'Pulling...'}
169
- </span>
170
- </div>
171
- <div className="mt-1.5 h-1.5 bg-surface-3 rounded-full overflow-hidden">
172
- <div className="h-full bg-accent rounded-full animate-pulse w-full" />
173
- </div>
160
+ <div className="flex items-center gap-2 mt-2">
161
+ <Loader2 size={10} className="animate-spin text-text-3" />
162
+ <span className="text-2xs font-sans text-text-3">Pulling…</span>
174
163
  </div>
175
164
  )}
176
165
 
177
- {/* Action buttons */}
178
- <div className="flex items-center gap-2 mt-auto">
179
- {model.status === 'running' && (
180
- <>
181
- <button
182
- onClick={() => onStop(model.name)}
183
- disabled={isUnloading}
184
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium text-text-2 hover:text-warning hover:bg-warning/10 transition-colors cursor-pointer disabled:opacity-40"
185
- >
186
- {isUnloading ? <Loader2 size={11} className="animate-spin" /> : <Square size={11} />}
187
- Stop
188
- </button>
189
- <button
190
- onClick={() => onSpawn(model.name)}
191
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer"
192
- >
193
- <Rocket size={11} /> Spawn Agent
194
- </button>
195
- </>
196
- )}
197
- {model.status === 'ready' && (
198
- <>
199
- {serverRunning && (
200
- <button
201
- onClick={() => onStart(model.id)}
202
- disabled={isLoading}
203
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium text-text-2 hover:text-success hover:bg-success/10 transition-colors cursor-pointer disabled:opacity-40"
204
- >
205
- {isLoading ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
206
- Run
207
- </button>
166
+ <div className="flex-1" />
167
+
168
+ {/* Divider + Actions — left-aligned */}
169
+ {model.status !== 'downloading' && (
170
+ <>
171
+ <div className="h-px bg-border-subtle my-2" />
172
+ <div className="flex items-center gap-2">
173
+ {model.status === 'running' ? (
174
+ <>
175
+ <button
176
+ onClick={() => onStop(model.name)}
177
+ disabled={isUnloading}
178
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-2xs font-sans font-semibold text-text-3 hover:text-text-1 transition-colors cursor-pointer disabled:opacity-40"
179
+ title="Stop"
180
+ >
181
+ {isUnloading ? <Loader2 size={10} className="animate-spin" /> : <Square size={10} />} Stop
182
+ </button>
183
+ <button
184
+ onClick={() => onSpawn(model.name)}
185
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-2xs font-sans font-semibold text-accent hover:bg-accent/10 transition-colors cursor-pointer"
186
+ >
187
+ <Rocket size={10} /> Spawn
188
+ </button>
189
+ </>
190
+ ) : (
191
+ <>
192
+ {/* Runtime picker */}
193
+ <div ref={runtimeRef} className="relative">
194
+ <button
195
+ onClick={handleRuntimeClick}
196
+ disabled={isStarting}
197
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-2xs font-sans font-semibold text-accent hover:bg-accent/10 transition-colors cursor-pointer disabled:opacity-40"
198
+ >
199
+ {isStarting
200
+ ? <Loader2 size={10} className="animate-spin" />
201
+ : <Play size={10} />}
202
+ {runtimes.length === 0 ? 'Create Runtime' : 'Start Runtime'}
203
+ </button>
204
+ {runtimeMenuOpen && runtimes.length > 1 && (
205
+ <div className="absolute left-0 top-full mt-1 z-50 min-w-[180px] bg-surface-2 border border-border rounded-md shadow-lg py-1">
206
+ {runtimes.map((rt) => (
207
+ <button
208
+ key={rt.id}
209
+ onClick={() => { onStartRuntime(model.id, rt); setRuntimeMenuOpen(false); }}
210
+ className="w-full text-left px-3 py-1.5 text-xs font-sans text-text-2 hover:bg-surface-3 transition-colors cursor-pointer flex items-center gap-2"
211
+ >
212
+ <Play size={10} />
213
+ <span className="truncate flex-1">{rt.name}</span>
214
+ <span className="text-2xs text-text-4 flex-shrink-0">{rt.type}</span>
215
+ </button>
216
+ ))}
217
+ </div>
218
+ )}
219
+ </div>
220
+ <button
221
+ onClick={() => onSpawn(model.id)}
222
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-2xs font-sans font-semibold text-text-3 hover:text-text-1 transition-colors cursor-pointer"
223
+ >
224
+ <Rocket size={10} /> Spawn
225
+ </button>
226
+ </>
208
227
  )}
209
- <button
210
- onClick={() => onSpawn(model.id)}
211
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer"
212
- >
213
- <Rocket size={11} /> Spawn Agent
214
- </button>
215
- </>
216
- )}
217
- {model.status === 'downloaded' && (
218
- <button
219
- onClick={() => onImport(model.id)}
220
- disabled={isImporting}
221
- className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer disabled:opacity-40"
222
- >
223
- {isImporting ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
224
- {isImporting ? 'Importing...' : 'Launch'}
225
- </button>
226
- )}
227
- </div>
228
+ </div>
229
+ </>
230
+ )}
228
231
  </div>
229
232
  );
230
233
  }
@@ -258,44 +261,34 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
258
261
  }
259
262
 
260
263
  if (loading) {
261
- return <div className="py-3 px-4 text-2xs text-text-4 font-sans">Loading quantization variants...</div>;
264
+ return <div className="py-2 px-4 text-2xs text-text-4 font-mono">Loading variants...</div>;
262
265
  }
263
266
  if (!files?.length) {
264
- return <div className="py-3 px-4 text-2xs text-text-4 font-sans">No GGUF files found in this repo.</div>;
267
+ return <div className="py-2 px-4 text-2xs text-text-4 font-mono">No GGUF files found.</div>;
265
268
  }
266
269
 
267
270
  return (
268
- <div className="pl-6 pr-4 pb-2 space-y-1.5">
271
+ <div className="pl-8 pr-4 pb-1 space-y-0">
269
272
  {files.map((f) => {
270
273
  const canRun = !f.estimatedRamGb || !systemRamGb || f.estimatedRamGb <= systemRamGb;
271
- const tight = f.estimatedRamGb && systemRamGb && f.estimatedRamGb > systemRamGb * 0.8 && canRun;
272
274
  return (
273
275
  <div key={f.filename} className={cn(
274
- 'flex items-center gap-2 py-1.5 px-3 rounded-md text-xs font-sans',
275
- canRun ? 'bg-surface-2' : 'bg-red-500/5 border border-red-500/15',
276
+ 'flex items-center gap-2 py-1 text-xs font-mono',
277
+ !canRun && 'opacity-40',
276
278
  )}>
277
- <span className="font-mono text-text-1 truncate flex-1">{f.filename}</span>
278
- {f.quantization && <Badge variant="default" className="text-2xs">{f.quantization}</Badge>}
279
- <span className="text-text-2 text-2xs w-16 text-right">{formatBytes(f.size)}</span>
279
+ <span className="text-text-2 truncate flex-1 min-w-0">{f.filename}</span>
280
+ {f.quantization && <span className="text-text-4 flex-shrink-0">{f.quantization}</span>}
281
+ <span className="text-text-3 flex-shrink-0 tabular-nums">{formatBytes(f.size)}</span>
280
282
  {f.estimatedRamGb && (
281
- <span className={cn(
282
- 'text-2xs w-20 text-right font-medium',
283
- !canRun ? 'text-red-400' : tight ? 'text-yellow-400' : 'text-green-400',
284
- )}>
285
- ~{f.estimatedRamGb} GB RAM
286
- </span>
283
+ <span className="text-text-4 flex-shrink-0 tabular-nums">~{f.estimatedRamGb} GB</span>
287
284
  )}
288
- {!canRun && <span className="text-2xs text-red-400 font-medium">too large</span>}
285
+ {!canRun && <span className="text-2xs text-text-4">too large</span>}
289
286
  <button
290
287
  onClick={() => handleDownload(f)}
291
288
  disabled={downloading === f.filename || !canRun}
292
- className={cn(
293
- 'p-1 rounded transition-colors cursor-pointer',
294
- canRun ? 'text-accent hover:bg-accent/10' : 'text-text-4 cursor-not-allowed',
295
- 'disabled:opacity-40',
296
- )}
289
+ className="p-0.5 rounded text-text-3 hover:text-text-1 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
297
290
  >
298
- {downloading === f.filename ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />}
291
+ {downloading === f.filename ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
299
292
  </button>
300
293
  </div>
301
294
  );
@@ -314,12 +307,11 @@ export default function ModelsView() {
314
307
  const [downloads, setDownloads] = useState([]);
315
308
  const [expandedResult, setExpandedResult] = useState(null);
316
309
  const [serverAction, setServerAction] = useState(null);
317
- const [loadingModel, setLoadingModel] = useState(null);
318
310
  const [unloadingModel, setUnloadingModel] = useState(null);
319
311
  const [deletingModel, setDeletingModel] = useState(null);
320
312
  const [ggufModels, setGgufModels] = useState([]);
321
313
  const [deletingGguf, setDeletingGguf] = useState(null);
322
- const [importingGguf, setImportingGguf] = useState(null);
314
+ const [startingModel, setStartingModel] = useState(null);
323
315
  const [filter, setFilter] = useState('all');
324
316
  const [discoveryOpen, setDiscoveryOpen] = useState(true);
325
317
  const [discoveryTab, setDiscoveryTab] = useState('recommended');
@@ -333,6 +325,12 @@ export default function ModelsView() {
333
325
  const catalog = useGrooveStore((s) => s.ollamaCatalog);
334
326
  const pullProgress = useGrooveStore((s) => s.ollamaPullProgress);
335
327
  const labActiveModel = useGrooveStore((s) => s.labActiveModel);
328
+ const labRuntimes = useGrooveStore((s) => s.labRuntimes);
329
+ const labLocalModels = useGrooveStore((s) => s.labLocalModels);
330
+ const fetchLabRuntimes = useGrooveStore((s) => s.fetchLabRuntimes);
331
+ const fetchLabLocalModels = useGrooveStore((s) => s.fetchLabLocalModels);
332
+ const launchLocalModel = useGrooveStore((s) => s.launchLocalModel);
333
+ const setActiveView = useGrooveStore((s) => s.setActiveView);
336
334
  const fetchOllamaStatus = useGrooveStore((s) => s.fetchOllamaStatus);
337
335
  const startServer = useGrooveStore((s) => s.startOllamaServer);
338
336
  const stopServer = useGrooveStore((s) => s.stopOllamaServer);
@@ -345,14 +343,14 @@ export default function ModelsView() {
345
343
 
346
344
  const pollingRef = useRef(null);
347
345
 
348
- // Poll Ollama status
349
346
  useEffect(() => {
350
347
  fetchOllamaStatus();
348
+ fetchLabRuntimes();
349
+ fetchLabLocalModels();
351
350
  pollingRef.current = setInterval(fetchOllamaStatus, 10000);
352
351
  return () => clearInterval(pollingRef.current);
353
- }, [fetchOllamaStatus]);
352
+ }, [fetchOllamaStatus, fetchLabRuntimes, fetchLabLocalModels]);
354
353
 
355
- // Fetch recommended + GGUF on mount
356
354
  useEffect(() => {
357
355
  api.get('/models/recommended').then((data) => {
358
356
  setRecommended(data.models || []);
@@ -362,7 +360,6 @@ export default function ModelsView() {
362
360
  }).catch(() => {});
363
361
  }, []);
364
362
 
365
- // Poll active downloads
366
363
  useEffect(() => {
367
364
  const poll = setInterval(() => {
368
365
  api.get('/models/downloads').then(setDownloads).catch(() => {});
@@ -370,7 +367,6 @@ export default function ModelsView() {
370
367
  return () => clearInterval(poll);
371
368
  }, []);
372
369
 
373
- // WebSocket events for GGUF downloads
374
370
  useEffect(() => {
375
371
  function handleWs(event) {
376
372
  try {
@@ -404,8 +400,6 @@ export default function ModelsView() {
404
400
  return () => { if (ws) ws.removeEventListener('message', handleWs); };
405
401
  }, [toast]);
406
402
 
407
- // ── Handlers ──────────────────────────────────────────────────
408
-
409
403
  async function handleServerStart() {
410
404
  setServerAction('starting');
411
405
  try { await startServer(); } catch {}
@@ -424,12 +418,6 @@ export default function ModelsView() {
424
418
  setServerAction(null);
425
419
  }
426
420
 
427
- async function handleLoadModel(modelId) {
428
- setLoadingModel(modelId);
429
- try { await loadModel(modelId); } catch {}
430
- setLoadingModel(null);
431
- }
432
-
433
421
  async function handleUnloadModel(modelId) {
434
422
  setUnloadingModel(modelId);
435
423
  try { await unloadModel(modelId); } catch {}
@@ -442,17 +430,21 @@ export default function ModelsView() {
442
430
  setDeletingModel(null);
443
431
  }
444
432
 
445
- async function handleImportToOllama(modelId) {
446
- setImportingGguf(modelId);
433
+ async function handleStartRuntime(modelId, runtime) {
434
+ setStartingModel(modelId);
447
435
  try {
448
- const result = await api.post(`/models/${encodeURIComponent(modelId)}/import-to-ollama`);
449
- toast.success(`Imported as "${result.ollamaName}" — now available in Ollama`);
436
+ if (runtime.type === 'ollama') {
437
+ await loadModel(modelId);
438
+ } else {
439
+ await launchLocalModel(modelId);
440
+ }
450
441
  fetchOllamaStatus();
451
- setGgufModels((prev) => prev.filter((m) => m.id !== modelId));
452
- } catch (err) {
453
- toast.error(`Import failed: ${err.message}`);
454
- }
455
- setImportingGguf(null);
442
+ } catch {}
443
+ setStartingModel(null);
444
+ }
445
+
446
+ function handleCreateRuntime() {
447
+ setActiveView('model-lab');
456
448
  }
457
449
 
458
450
  async function handleDeleteGguf(modelId) {
@@ -486,8 +478,6 @@ export default function ModelsView() {
486
478
  setSearching(false);
487
479
  }
488
480
 
489
- // ── Computed: catalog lookup ───────────────────────────────────
490
-
491
481
  const catalogByBase = useMemo(() => {
492
482
  const map = {};
493
483
  for (const c of catalog) {
@@ -503,16 +493,12 @@ export default function ModelsView() {
503
493
  return catalogByBase[modelId.split(':')[0]] || null;
504
494
  }
505
495
 
506
- // ── Computed: lab model check ──────────────────────────────────
507
-
508
496
  function isModelInLab(modelId) {
509
497
  if (!labActiveModel) return false;
510
498
  if (typeof labActiveModel === 'string') return labActiveModel === modelId;
511
499
  return labActiveModel.name === modelId || labActiveModel.id === modelId;
512
500
  }
513
501
 
514
- // ── Computed: unified model list ──────────────────────────────
515
-
516
502
  const unifiedModels = useMemo(() => {
517
503
  const models = [];
518
504
  const seen = new Set();
@@ -570,6 +556,24 @@ export default function ModelsView() {
570
556
  });
571
557
  }
572
558
 
559
+ for (const m of labLocalModels) {
560
+ if (seen.has(m.id)) continue;
561
+ seen.add(m.id);
562
+ const tag = m.type === 'mlx' ? 'MLX' : m.type === 'hf' ? 'HF' : 'GGUF';
563
+ models.push({
564
+ id: m.id,
565
+ name: m.name || m.id.replace(/^(mlx|hf|gguf):/, ''),
566
+ source: m.type || 'hf',
567
+ status: 'downloaded',
568
+ size: m.sizeBytes ? formatBytes(m.sizeBytes) : '—',
569
+ parameters: m.parameters,
570
+ quantization: m.quantization,
571
+ formatTag: tag,
572
+ compatibleBackends: m.compatibleBackends,
573
+ isInLab: isModelInLab(m.id),
574
+ });
575
+ }
576
+
573
577
  for (const d of downloads) {
574
578
  if (seen.has(d.filename)) continue;
575
579
  models.push({
@@ -593,9 +597,7 @@ export default function ModelsView() {
593
597
  }
594
598
 
595
599
  return models;
596
- }, [runningModels, installedModels, ggufModels, downloads, pullProgress, labActiveModel, catalog]);
597
-
598
- // ── Computed: filter + search ──────────────────────────────────
600
+ }, [runningModels, installedModels, ggufModels, labLocalModels, downloads, pullProgress, labActiveModel, catalog]);
599
601
 
600
602
  const filteredModels = useMemo(() => {
601
603
  let list = unifiedModels;
@@ -617,129 +619,91 @@ export default function ModelsView() {
617
619
  }), [unifiedModels]);
618
620
 
619
621
  const hasNoModels = unifiedModels.length === 0;
620
-
621
- // ── Render ─────────────────────────────────────────────────────
622
+ const hw = ollamaStatus.hardware;
622
623
 
623
624
  return (
624
- <div className="h-full flex flex-col bg-surface-0">
625
- {/* ════ ZONE 1: Sticky Toolbar ════ */}
626
- <div className="flex-shrink-0 px-5 pt-4 pb-3 border-b border-border space-y-3">
625
+ <div className="h-full flex flex-col">
626
+ {/* Toolbar */}
627
+ <div className="flex-shrink-0 px-5 pt-3 pb-2.5 bg-surface-1 border-b border-border-subtle space-y-2.5">
627
628
 
628
- {/* Server status row */}
629
+ {/* Server status */}
629
630
  {!ollamaStatus.installed ? (
630
- <div className="flex items-center gap-2 bg-surface-1 border border-border-subtle rounded-lg px-3 py-2">
631
+ <div className="flex items-center gap-2 text-xs font-mono">
631
632
  <span className="w-1.5 h-1.5 rounded-full bg-text-4 flex-shrink-0" />
632
- <span className="text-xs font-sans text-text-3 font-medium">Ollama Not Installed</span>
633
+ <span className="text-text-3">Ollama not installed</span>
633
634
  <div className="flex-1" />
634
- <a
635
- href="https://ollama.ai/download"
636
- target="_blank"
637
- rel="noopener noreferrer"
638
- className="text-2xs font-sans text-accent hover:underline flex items-center gap-1"
639
- >
640
- Install <ExternalLink size={10} />
635
+ <a href="https://ollama.ai/download" target="_blank" rel="noopener noreferrer"
636
+ className="text-2xs text-text-3 hover:text-text-1 flex items-center gap-1 transition-colors">
637
+ Install <ExternalLink size={9} />
641
638
  </a>
642
639
  </div>
643
- ) : ollamaStatus.serverRunning ? (
644
- <div className="flex items-center gap-2 flex-wrap">
640
+ ) : (
641
+ <div className="flex items-center gap-2 text-xs font-mono flex-wrap">
645
642
  <span className="relative flex-shrink-0 w-1.5 h-1.5">
646
- <span className="absolute inset-0 rounded-full bg-success" />
647
- <span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />
643
+ <span className={cn('absolute inset-0 rounded-full', ollamaStatus.serverRunning ? 'bg-success' : 'bg-text-4')} />
644
+ {ollamaStatus.serverRunning && <span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />}
648
645
  </span>
649
- <span className="text-xs font-sans text-text-1 font-medium">Ollama</span>
650
- <span className="text-2xs font-mono text-text-4">:11434</span>
651
-
652
- {ollamaStatus.hardware && (
653
- <div className="flex items-center gap-1.5 ml-2">
654
- <div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-2 text-2xs font-sans text-text-2">
655
- <MemoryStick size={10} className="text-text-3" />
656
- {ollamaStatus.hardware.totalRamGb} GB
657
- </div>
658
- <div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-2 text-2xs font-sans text-text-2">
659
- <Cpu size={10} className="text-text-3" />
660
- {ollamaStatus.hardware.cores} cores
661
- </div>
662
- {ollamaStatus.hardware.gpu && (
663
- <div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-2 text-2xs font-sans text-text-2">
664
- <HardDrive size={10} className="text-text-3" />
665
- {ollamaStatus.hardware.gpu.name}
666
- {ollamaStatus.hardware.gpu.vram ? ` (${ollamaStatus.hardware.gpu.vram} GB)` : ''}
667
- </div>
668
- )}
669
- {ollamaStatus.hardware.isAppleSilicon && (
670
- <Badge variant="accent" className="text-2xs">Unified Memory</Badge>
671
- )}
672
- </div>
646
+ <span className="text-text-1 font-semibold">Ollama</span>
647
+ <span className="text-text-4">{ollamaStatus.serverRunning ? ':11434' : 'stopped'}</span>
648
+
649
+ {ollamaStatus.serverRunning && hw && (
650
+ <span className="text-text-4 ml-1">
651
+ {hw.totalRamGb} GB · {hw.cores} cores
652
+ {hw.gpu ? ` · ${hw.gpu.name}${hw.gpu.vram ? ` ${hw.gpu.vram} GB` : ''}` : ''}
653
+ {hw.isAppleSilicon ? ' · unified' : ''}
654
+ </span>
673
655
  )}
674
656
 
675
657
  <div className="flex-1" />
676
- <button
677
- onClick={handleServerRestart}
678
- disabled={!!serverAction}
679
- className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-accent cursor-pointer transition-colors disabled:opacity-40"
680
- >
681
- <RefreshCw size={10} className={serverAction === 'restarting' ? 'animate-spin' : ''} />
682
- {serverAction === 'restarting' ? 'Restarting...' : 'Restart'}
683
- </button>
684
- <button
685
- onClick={handleServerStop}
686
- disabled={!!serverAction}
687
- className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-danger cursor-pointer transition-colors disabled:opacity-40"
688
- >
689
- <Square size={10} />
690
- {serverAction === 'stopping' ? 'Stopping...' : 'Stop'}
691
- </button>
692
- </div>
693
- ) : (
694
- <div className="flex items-center gap-2 bg-danger/8 border border-danger/20 rounded-lg px-3 py-2">
695
- <span className="w-1.5 h-1.5 rounded-full bg-danger flex-shrink-0" />
696
- <span className="text-xs font-sans text-danger font-semibold">Ollama Stopped</span>
697
- <span className="text-2xs font-mono text-text-4">:11434</span>
698
- <div className="flex-1" />
699
- <Button
700
- variant="primary"
701
- size="sm"
702
- onClick={handleServerStart}
703
- disabled={!!serverAction}
704
- className="h-6 px-2.5 text-2xs gap-1"
705
- >
706
- <Play size={10} />
707
- {serverAction === 'starting' ? 'Starting...' : 'Start Server'}
708
- </Button>
658
+ {ollamaStatus.serverRunning ? (
659
+ <div className="flex items-center gap-2">
660
+ <button onClick={handleServerRestart} disabled={!!serverAction}
661
+ className="text-2xs text-text-4 hover:text-text-2 transition-colors cursor-pointer disabled:opacity-40 flex items-center gap-1">
662
+ <RefreshCw size={9} className={serverAction === 'restarting' ? 'animate-spin' : ''} />
663
+ {serverAction === 'restarting' ? 'Restarting' : 'Restart'}
664
+ </button>
665
+ <button onClick={handleServerStop} disabled={!!serverAction}
666
+ className="text-2xs text-text-4 hover:text-text-2 transition-colors cursor-pointer disabled:opacity-40 flex items-center gap-1">
667
+ <Square size={9} />
668
+ {serverAction === 'stopping' ? 'Stopping' : 'Stop'}
669
+ </button>
670
+ </div>
671
+ ) : (
672
+ <button onClick={handleServerStart} disabled={!!serverAction}
673
+ className="text-2xs text-text-2 hover:text-text-1 transition-colors cursor-pointer disabled:opacity-40 flex items-center gap-1">
674
+ {serverAction === 'starting' ? <Loader2 size={9} className="animate-spin" /> : <Play size={9} />}
675
+ {serverAction === 'starting' ? 'Starting' : 'Start'}
676
+ </button>
677
+ )}
709
678
  </div>
710
679
  )}
711
680
 
712
- {/* Search + Filter row */}
681
+ {/* Search + Filters */}
713
682
  <div className="flex items-center gap-2">
714
- <div className="relative flex-1 max-w-md">
715
- <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4" />
683
+ <div className="relative flex-1 min-w-[160px]">
684
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-4" />
716
685
  <input
717
686
  ref={searchInputRef}
718
687
  value={searchQuery}
719
688
  onChange={(e) => setSearchQuery(e.target.value)}
720
689
  onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
721
690
  placeholder="Search models or HuggingFace..."
722
- className="w-full h-8 pl-9 pr-3 text-sm rounded-md bg-surface-1 border border-border text-text-0 font-sans placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
691
+ className="w-full h-7 pl-8 pr-3 text-xs font-mono rounded bg-surface-1 border border-border text-text-1 placeholder:text-text-4 focus:outline-none focus:border-text-3"
723
692
  />
724
693
  </div>
725
-
726
- <div className="flex items-center gap-1">
694
+ <div className="flex items-center gap-0.5 flex-shrink-0">
727
695
  {FILTERS.map((f) => (
728
696
  <button
729
697
  key={f.id}
730
698
  onClick={() => setFilter(f.id)}
731
699
  className={cn(
732
- 'px-2.5 py-1 rounded-md text-2xs font-sans font-medium transition-colors cursor-pointer',
733
- filter === f.id
734
- ? 'bg-accent/12 text-accent'
735
- : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
700
+ 'px-2 py-1 rounded text-2xs font-mono transition-colors cursor-pointer',
701
+ filter === f.id ? 'text-text-1 bg-surface-3' : 'text-text-4 hover:text-text-2',
736
702
  )}
737
703
  >
738
704
  {f.label}
739
705
  {filterCounts[f.id] > 0 && (
740
- <span className={cn('ml-1', filter === f.id ? 'text-accent/60' : 'text-text-4')}>
741
- {filterCounts[f.id]}
742
- </span>
706
+ <span className="ml-1 text-text-4 tabular-nums">{filterCounts[f.id]}</span>
743
707
  )}
744
708
  </button>
745
709
  ))}
@@ -747,222 +711,181 @@ export default function ModelsView() {
747
711
  </div>
748
712
  </div>
749
713
 
750
- {/* ════ ZONE 2 + 3: Scrollable Content ════ */}
751
- <div className="flex-1 min-h-0 overflow-y-auto">
752
- <div className="p-5 space-y-6">
753
-
754
- {/* Empty State */}
755
- {hasNoModels && !searchQuery.trim() && filter === 'all' ? (
756
- <div className="flex flex-col items-center justify-center py-16 px-8">
757
- <Box size={48} className="text-text-4 mb-4" />
758
- <h2 className="text-lg font-sans font-bold text-text-0 mb-1">Get started with local models</h2>
759
- <p className="text-sm text-text-3 font-sans text-center max-w-md mb-6">
760
- Run AI models locally for privacy, speed, and zero API costs.
761
- Pull popular models from Ollama or download GGUF files from HuggingFace.
762
- </p>
763
- <div className="flex gap-3">
764
- <Button
765
- variant="primary"
766
- onClick={() => {
767
- setDiscoveryOpen(true);
768
- setDiscoveryTab('recommended');
769
- discoveryRef.current?.scrollIntoView({ behavior: 'smooth' });
770
- }}
771
- className="gap-2"
772
- >
773
- <Download size={14} /> Pull from Ollama
774
- </Button>
775
- <Button
776
- variant="secondary"
777
- onClick={() => {
778
- searchInputRef.current?.focus();
779
- }}
780
- className="gap-2"
781
- >
782
- <Search size={14} /> Search HuggingFace
783
- </Button>
784
- </div>
785
- </div>
786
- ) : filteredModels.length === 0 ? (
787
- <div className="text-center py-12">
788
- <Search size={32} className="mx-auto text-text-4 mb-2" />
789
- <p className="text-sm text-text-2 font-sans font-medium">No models match your filter</p>
790
- <p className="text-xs text-text-3 font-sans mt-1">
791
- Try changing the filter or clearing your search.
792
- </p>
714
+ {/* Content */}
715
+ <div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
716
+
717
+ {/* Empty state */}
718
+ {hasNoModels && !searchQuery.trim() && filter === 'all' ? (
719
+ <div className="flex flex-col items-center justify-center py-16 px-8">
720
+ <Box size={28} className="text-text-4 mb-3" />
721
+ <div className="text-sm font-mono font-semibold text-text-1 mb-1">No local models</div>
722
+ <div className="text-xs font-mono text-text-3 text-center max-w-sm mb-5">
723
+ Pull from Ollama or search HuggingFace for GGUF models to run locally.
793
724
  </div>
794
- ) : (
795
- /* ── Card Grid ── */
796
- <div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
797
- {filteredModels.map((model) => (
798
- <UnifiedModelCard
799
- key={model.id}
800
- model={model}
801
- serverRunning={ollamaStatus.serverRunning}
802
- onStart={handleLoadModel}
803
- onStop={handleUnloadModel}
804
- onSpawn={spawnFromModel}
805
- onDelete={handleDeleteUnified}
806
- onImport={handleImportToOllama}
807
- isLoading={loadingModel === model.id}
808
- isUnloading={unloadingModel === model.id || unloadingModel === model.name}
809
- isDeleting={deletingModel === model.id || deletingGguf === model.id}
810
- isImporting={importingGguf === model.id}
811
- />
812
- ))}
725
+ <div className="flex gap-2">
726
+ <Button variant="primary" onClick={() => {
727
+ setDiscoveryOpen(true);
728
+ setDiscoveryTab('recommended');
729
+ discoveryRef.current?.scrollIntoView({ behavior: 'smooth' });
730
+ }} className="gap-1.5 text-xs">
731
+ <Download size={12} /> Pull from Ollama
732
+ </Button>
733
+ <Button variant="secondary" onClick={() => searchInputRef.current?.focus()} className="gap-1.5 text-xs">
734
+ <Search size={12} /> Search HuggingFace
735
+ </Button>
813
736
  </div>
814
- )}
737
+ </div>
738
+ ) : filteredModels.length === 0 ? (
739
+ <div className="text-center py-16">
740
+ <div className="text-sm font-sans text-text-4">No models match this filter</div>
741
+ </div>
742
+ ) : (
743
+ /* Model grid */
744
+ <div className="px-5 py-4 grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))' }}>
745
+ {filteredModels.map((model) => (
746
+ <ModelCard
747
+ key={model.id}
748
+ model={model}
749
+ runtimes={labRuntimes}
750
+ onStop={handleUnloadModel}
751
+ onSpawn={spawnFromModel}
752
+ onDelete={handleDeleteUnified}
753
+ onStartRuntime={handleStartRuntime}
754
+ onCreateRuntime={handleCreateRuntime}
755
+ isStarting={startingModel === model.id}
756
+ isUnloading={unloadingModel === model.id || unloadingModel === model.name}
757
+ isDeleting={deletingModel === model.id || deletingGguf === model.id}
758
+ />
759
+ ))}
760
+ </div>
761
+ )}
815
762
 
816
- {/* ════ ZONE 3: Discovery ════ */}
817
- <div ref={discoveryRef} className="border-t border-border-subtle pt-4">
818
- <button
819
- onClick={() => setDiscoveryOpen(!discoveryOpen)}
820
- className="flex items-center gap-2 mb-3 cursor-pointer group"
821
- >
822
- {discoveryOpen
823
- ? <ChevronDown size={14} className="text-text-3 group-hover:text-text-1 transition-colors" />
824
- : <ChevronRight size={14} className="text-text-3 group-hover:text-text-1 transition-colors" />}
825
- <Sparkles size={14} className="text-text-3" />
826
- <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">
827
- Discover Models
828
- </span>
829
- </button>
763
+ {/* Discovery section */}
764
+ <div ref={discoveryRef} className="border-t border-border mt-2">
765
+ <button
766
+ onClick={() => setDiscoveryOpen(!discoveryOpen)}
767
+ className="flex items-center gap-2 px-5 py-2.5 cursor-pointer group w-full text-left"
768
+ >
769
+ {discoveryOpen
770
+ ? <ChevronDown size={12} className="text-text-4 group-hover:text-text-2 transition-colors" />
771
+ : <ChevronRight size={12} className="text-text-4 group-hover:text-text-2 transition-colors" />}
772
+ <span className="text-2xs font-mono text-text-3 uppercase tracking-wider">Discover Models</span>
773
+ </button>
830
774
 
831
- {discoveryOpen && (
832
- <div className="space-y-4">
833
- {/* Discovery tabs */}
834
- <div className="flex gap-1">
835
- {[
836
- { id: 'recommended', label: `Recommended (${recommended.length})` },
837
- { id: 'search', label: `Search Results (${searchResults.length})` },
838
- ].map((t) => (
839
- <button
840
- key={t.id}
841
- onClick={() => setDiscoveryTab(t.id)}
842
- className={cn(
843
- 'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
844
- discoveryTab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
845
- )}
846
- >
847
- {t.label}
848
- </button>
849
- ))}
850
- </div>
775
+ {discoveryOpen && (
776
+ <div className="px-5 pb-4 space-y-3">
777
+ <div className="flex gap-0.5">
778
+ {[
779
+ { id: 'recommended', label: `Recommended (${recommended.length})` },
780
+ { id: 'search', label: `Search (${searchResults.length})` },
781
+ ].map((t) => (
782
+ <button
783
+ key={t.id}
784
+ onClick={() => setDiscoveryTab(t.id)}
785
+ className={cn(
786
+ 'px-2 py-1 rounded text-2xs font-mono transition-colors cursor-pointer',
787
+ discoveryTab === t.id ? 'text-text-1 bg-surface-3' : 'text-text-4 hover:text-text-2',
788
+ )}
789
+ >
790
+ {t.label}
791
+ </button>
792
+ ))}
793
+ </div>
851
794
 
852
- {/* Recommended — horizontal scroll */}
853
- {discoveryTab === 'recommended' && (
854
- <>
855
- {recommended.length === 0 ? (
856
- <div className="text-center py-8">
857
- <Cpu size={32} className="mx-auto text-text-4 mb-2" />
858
- <p className="text-sm text-text-2 font-sans font-medium">Detecting hardware...</p>
859
- <p className="text-xs text-text-3 font-sans mt-1">Make sure Ollama is installed so we can check your system.</p>
795
+ {/* Recommended */}
796
+ {discoveryTab === 'recommended' && (
797
+ <>
798
+ {recommended.length === 0 ? (
799
+ <div className="py-6 text-center">
800
+ <div className="text-xs font-mono text-text-3">Detecting hardware...</div>
801
+ <div className="text-2xs font-mono text-text-4 mt-1">Make sure Ollama is installed.</div>
802
+ </div>
803
+ ) : (
804
+ <>
805
+ <div className="text-2xs font-mono text-text-4">
806
+ For your system ({hw?.totalRamGb || '?'} GB RAM)
860
807
  </div>
861
- ) : (
862
- <>
863
- <div className="text-xs text-text-3 font-sans">
864
- Top models for your system ({ollamaStatus.hardware?.totalRamGb || '?'} GB RAM). Click Pull to download via Ollama.
865
- </div>
866
- <div className="flex gap-3 overflow-x-auto pb-2">
867
- {recommended.map((m) => {
868
- const baseId = m.id.split(':')[0];
869
- const isInstalled = installedModels.some((im) =>
870
- im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
871
- );
872
- const headroom = ollamaStatus.hardware?.totalRamGb
873
- ? Math.round((1 - m.ramGb / ollamaStatus.hardware.totalRamGb) * 100)
874
- : null;
875
- const isPulling = !!pullProgress[m.id];
876
-
877
- return (
878
- <div
879
- key={m.id}
880
- className={cn(
881
- 'flex-shrink-0 w-[240px] p-3 rounded-xl border transition-colors',
882
- isInstalled
883
- ? 'bg-success/5 border-success/20'
884
- : 'bg-surface-1 border-border-subtle hover:border-accent/30',
885
- )}
886
- >
887
- <div className="flex items-center gap-2 mb-1">
888
- <span className="text-sm font-mono font-bold text-text-0 truncate">{m.name}</span>
889
- {isInstalled && <Check size={12} className="text-success flex-shrink-0" />}
808
+ <div className="space-y-0">
809
+ {recommended.map((m) => {
810
+ const baseId = m.id.split(':')[0];
811
+ const isInstalled = installedModels.some((im) =>
812
+ im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
813
+ );
814
+ const isPulling = !!pullProgress[m.id];
815
+
816
+ return (
817
+ <div key={m.id} className="flex items-center gap-3 py-1.5 border-b border-border last:border-0">
818
+ <div className="flex-1 min-w-0">
819
+ <div className="flex items-center gap-2">
820
+ <span className="text-xs font-mono font-semibold text-text-1 truncate">{m.name}</span>
821
+ {isInstalled && <Check size={10} className="text-success flex-shrink-0" />}
890
822
  </div>
891
- <div className="text-2xs text-text-3 font-sans line-clamp-1 mb-2">{m.description}</div>
892
- <div className="flex items-center gap-2 text-2xs font-sans mb-2">
893
- <span className="text-text-2">{m.sizeGb} GB</span>
894
- <span className="text-green-400 font-medium">{m.ramGb} GB RAM</span>
895
- {headroom !== null && <span className="text-text-4">{headroom}%</span>}
896
- </div>
897
- {isInstalled ? (
898
- <Badge variant="success" className="text-2xs">Installed</Badge>
899
- ) : (
900
- <button
901
- onClick={() => pullModel(m.id)}
902
- disabled={isPulling}
903
- className="w-full flex items-center justify-center gap-1.5 h-7 rounded-md text-xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer disabled:opacity-40"
904
- >
905
- {isPulling ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
906
- Pull
907
- </button>
908
- )}
823
+ <div className="text-2xs font-mono text-text-4 truncate">{m.description}</div>
909
824
  </div>
910
- );
911
- })}
912
- </div>
913
- </>
914
- )}
915
- </>
916
- )}
917
-
918
- {/* Search results */}
919
- {discoveryTab === 'search' && (
920
- <>
921
- {searching ? (
922
- <div className="text-center py-8">
923
- <Loader2 size={24} className="mx-auto text-accent animate-spin mb-2" />
924
- <p className="text-sm text-text-3 font-sans">Searching HuggingFace...</p>
825
+ <span className="text-2xs font-mono text-text-3 tabular-nums flex-shrink-0">{m.sizeGb} GB</span>
826
+ <span className="text-2xs font-mono text-text-4 tabular-nums flex-shrink-0">{m.ramGb} GB RAM</span>
827
+ {isInstalled ? (
828
+ <span className="text-2xs font-mono text-text-4 w-12 text-right">installed</span>
829
+ ) : (
830
+ <button
831
+ onClick={() => pullModel(m.id)}
832
+ disabled={isPulling}
833
+ className="text-2xs font-mono text-text-2 hover:text-text-1 transition-colors cursor-pointer disabled:opacity-40 flex items-center gap-1 w-12 justify-end"
834
+ >
835
+ {isPulling ? <Loader2 size={10} className="animate-spin" /> : <Download size={10} />}
836
+ Pull
837
+ </button>
838
+ )}
839
+ </div>
840
+ );
841
+ })}
925
842
  </div>
926
- ) : searchResults.length === 0 ? (
927
- <div className="text-center py-8">
928
- <Search size={32} className="mx-auto text-text-4 mb-2" />
929
- <p className="text-sm text-text-2 font-sans font-medium">Search for GGUF models</p>
930
- <p className="text-xs text-text-3 font-sans mt-1">
931
- Type a query above and press Enter — try "qwen coder", "deepseek", "codestral", "llama"
932
- </p>
933
- </div>
934
- ) : (
935
- <div className="space-y-2">
936
- {searchResults.map((r) => (
937
- <div key={r.id} className="space-y-1">
938
- <button
939
- onClick={() => setExpandedResult(expandedResult === r.id ? null : r.id)}
940
- className="w-full text-left px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg hover:border-accent/30 transition-colors cursor-pointer"
941
- >
942
- <div className="flex items-center gap-2">
943
- <span className="text-sm font-mono font-bold text-text-0 truncate flex-1">{r.name}</span>
944
- <span className="text-2xs text-text-4 font-sans">{r.author}</span>
945
- {expandedResult === r.id
946
- ? <ChevronDown size={14} className="text-text-3" />
947
- : <ChevronRight size={14} className="text-text-3" />}
948
- </div>
949
- <div className="text-2xs text-text-3 font-sans mt-0.5 flex gap-3">
950
- <span>{r.downloads?.toLocaleString()} downloads</span>
951
- <span>{r.likes} likes</span>
952
- </div>
953
- </button>
954
- {expandedResult === r.id && (
955
- <FilePicker repoId={r.id} systemRamGb={ollamaStatus.hardware?.totalRamGb} />
956
- )}
957
- </div>
958
- ))}
843
+ </>
844
+ )}
845
+ </>
846
+ )}
847
+
848
+ {/* Search results */}
849
+ {discoveryTab === 'search' && (
850
+ <>
851
+ {searching ? (
852
+ <div className="py-6 text-center">
853
+ <Loader2 size={16} className="mx-auto text-text-3 animate-spin mb-2" />
854
+ <div className="text-xs font-mono text-text-3">Searching HuggingFace...</div>
855
+ </div>
856
+ ) : searchResults.length === 0 ? (
857
+ <div className="py-6 text-center">
858
+ <div className="text-xs font-mono text-text-3">Search for GGUF models</div>
859
+ <div className="text-2xs font-mono text-text-4 mt-1">
860
+ Try "qwen coder", "deepseek", "codestral", "llama"
959
861
  </div>
960
- )}
961
- </>
962
- )}
963
- </div>
964
- )}
965
- </div>
862
+ </div>
863
+ ) : (
864
+ <div className="space-y-0">
865
+ {searchResults.map((r) => (
866
+ <div key={r.id}>
867
+ <button
868
+ onClick={() => setExpandedResult(expandedResult === r.id ? null : r.id)}
869
+ className="w-full text-left flex items-center gap-2 py-1.5 border-b border-border hover:bg-surface-2/50 transition-colors cursor-pointer"
870
+ >
871
+ <span className="text-xs font-mono font-semibold text-text-1 truncate flex-1">{r.name}</span>
872
+ <span className="text-2xs font-mono text-text-4">{r.author}</span>
873
+ <span className="text-2xs font-mono text-text-4 tabular-nums">{r.downloads?.toLocaleString()}</span>
874
+ {expandedResult === r.id
875
+ ? <ChevronDown size={10} className="text-text-4" />
876
+ : <ChevronRight size={10} className="text-text-4" />}
877
+ </button>
878
+ {expandedResult === r.id && (
879
+ <FilePicker repoId={r.id} systemRamGb={hw?.totalRamGb} />
880
+ )}
881
+ </div>
882
+ ))}
883
+ </div>
884
+ )}
885
+ </>
886
+ )}
887
+ </div>
888
+ )}
966
889
  </div>
967
890
  </div>
968
891
  </div>