groove-dev 0.27.142 → 0.27.144

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