groove-dev 0.27.140 → 0.27.142

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 (98) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/integrations-registry.json +12 -44
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +100 -23
  5. package/node_modules/@groove-dev/daemon/src/integrations.js +10 -0
  6. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +171 -1
  8. package/node_modules/@groove-dev/daemon/src/keeper.js +2 -2
  9. package/node_modules/@groove-dev/daemon/src/memory.js +8 -5
  10. package/node_modules/@groove-dev/daemon/src/model-lab.js +11 -0
  11. package/node_modules/@groove-dev/daemon/src/process.js +65 -0
  12. package/node_modules/@groove-dev/daemon/src/rotator.js +25 -8
  13. package/node_modules/@groove-dev/daemon/src/validate.js +8 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/{codemirror-BQqYnZfL.js → codemirror-BYKpdS2W.js} +10 -10
  15. package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +984 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/index.html +3 -3
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/app.jsx +0 -2
  20. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +3 -4
  21. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -2
  22. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +12 -8
  23. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +79 -5
  24. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +5 -4
  25. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +109 -12
  26. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +111 -0
  27. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +70 -33
  28. package/node_modules/@groove-dev/gui/src/components/editor/ai-panel.jsx +77 -6
  29. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +2 -68
  30. package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +2 -49
  31. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +15 -4
  32. package/node_modules/@groove-dev/gui/src/components/keeper/global-modals.jsx +10 -10
  33. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +1 -2
  34. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +151 -3
  35. package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +223 -18
  36. package/node_modules/@groove-dev/gui/src/stores/groove.js +107 -29
  37. package/node_modules/@groove-dev/gui/src/views/agents.jsx +114 -56
  38. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +2 -0
  39. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +3 -71
  40. package/node_modules/@groove-dev/gui/src/views/memory.jsx +9 -9
  41. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +1 -6
  42. package/node_modules/@groove-dev/gui/src/views/models.jsx +658 -565
  43. package/package.json +1 -1
  44. package/packages/cli/package.json +1 -1
  45. package/packages/daemon/integrations-registry.json +12 -44
  46. package/packages/daemon/package.json +1 -1
  47. package/packages/daemon/src/api.js +100 -23
  48. package/packages/daemon/src/integrations.js +10 -0
  49. package/packages/daemon/src/introducer.js +1 -1
  50. package/packages/daemon/src/journalist.js +171 -1
  51. package/packages/daemon/src/keeper.js +2 -2
  52. package/packages/daemon/src/memory.js +8 -5
  53. package/packages/daemon/src/model-lab.js +11 -0
  54. package/packages/daemon/src/process.js +65 -0
  55. package/packages/daemon/src/rotator.js +25 -8
  56. package/packages/daemon/src/validate.js +8 -0
  57. package/packages/gui/dist/assets/{codemirror-BQqYnZfL.js → codemirror-BYKpdS2W.js} +10 -10
  58. package/packages/gui/dist/assets/index-Bjd91ufV.js +984 -0
  59. package/packages/gui/dist/assets/index-BqdwIFn4.css +1 -0
  60. package/packages/gui/dist/index.html +3 -3
  61. package/packages/gui/package.json +1 -1
  62. package/packages/gui/src/app.jsx +0 -2
  63. package/packages/gui/src/components/agents/agent-chat.jsx +3 -4
  64. package/packages/gui/src/components/agents/agent-feed.jsx +8 -2
  65. package/packages/gui/src/components/agents/agent-file-tree.jsx +12 -8
  66. package/packages/gui/src/components/agents/agent-panel.jsx +79 -5
  67. package/packages/gui/src/components/agents/code-review.jsx +5 -4
  68. package/packages/gui/src/components/agents/workspace-mode.jsx +109 -12
  69. package/packages/gui/src/components/dashboard/context-gauges.jsx +111 -0
  70. package/packages/gui/src/components/dashboard/routing-chart.jsx +70 -33
  71. package/packages/gui/src/components/editor/ai-panel.jsx +77 -6
  72. package/packages/gui/src/components/editor/code-editor.jsx +2 -68
  73. package/packages/gui/src/components/editor/file-tree.jsx +2 -49
  74. package/packages/gui/src/components/editor/terminal.jsx +15 -4
  75. package/packages/gui/src/components/keeper/global-modals.jsx +10 -10
  76. package/packages/gui/src/components/layout/activity-bar.jsx +1 -2
  77. package/packages/gui/src/components/layout/terminal-panel.jsx +151 -3
  78. package/packages/gui/src/components/marketplace/integration-wizard.jsx +223 -18
  79. package/packages/gui/src/stores/groove.js +107 -29
  80. package/packages/gui/src/views/agents.jsx +114 -56
  81. package/packages/gui/src/views/dashboard.jsx +2 -0
  82. package/packages/gui/src/views/marketplace.jsx +3 -71
  83. package/packages/gui/src/views/memory.jsx +9 -9
  84. package/packages/gui/src/views/model-lab.jsx +1 -6
  85. package/packages/gui/src/views/models.jsx +658 -565
  86. package/plan_files/keeper-manual.md +53 -42
  87. package/node_modules/@groove-dev/gui/dist/assets/index-BV9CAiw1.css +0 -1
  88. package/node_modules/@groove-dev/gui/dist/assets/index-DK6UIz0n.js +0 -8698
  89. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +0 -78
  90. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +0 -144
  91. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +0 -187
  92. package/node_modules/@groove-dev/gui/src/views/toys.jsx +0 -162
  93. package/packages/gui/dist/assets/index-BV9CAiw1.css +0 -1
  94. package/packages/gui/dist/assets/index-DK6UIz0n.js +0 -8698
  95. package/packages/gui/src/components/toys/toy-card.jsx +0 -78
  96. package/packages/gui/src/components/toys/toy-creator.jsx +0 -144
  97. package/packages/gui/src/components/toys/toy-launcher.jsx +0 -187
  98. package/packages/gui/src/views/toys.jsx +0 -162
@@ -1,5 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { useState, useEffect, useRef, useMemo } from 'react';
3
3
  import { ScrollArea } from '../components/ui/scroll-area';
4
4
  import { Badge } from '../components/ui/badge';
5
5
  import { Button } from '../components/ui/button';
@@ -9,12 +9,11 @@ import { useGrooveStore } from '../stores/groove';
9
9
  import {
10
10
  Search, Download, Trash2, HardDrive, Cpu, MemoryStick,
11
11
  Check, Loader2, Box, ChevronDown, ChevronRight,
12
- RefreshCw, Play, Square, Zap, AlertCircle, Monitor, Rocket,
12
+ RefreshCw, Play, Square, Rocket, MoreHorizontal,
13
+ Sparkles, FlaskConical, ExternalLink,
13
14
  } from 'lucide-react';
14
15
  import { cn } from '../lib/cn';
15
16
 
16
- const TIER_COLORS = { light: 'text-green-400', medium: 'text-blue-400', heavy: 'text-orange-400' };
17
-
18
17
  function formatBytes(bytes) {
19
18
  if (!bytes) return '—';
20
19
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -28,338 +27,211 @@ function formatSpeed(bytesPerSec) {
28
27
  return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
29
28
  }
30
29
 
31
- // ---- Server Status Bar ----
32
- function ServerStatusBar({ serverRunning, installed, onStart, onStop, onRestart, actionInProgress }) {
33
- if (!installed) {
34
- return (
35
- <div className="flex items-center gap-2 bg-surface-1 border border-border-subtle rounded-lg px-3 py-2">
36
- <span className="w-[6px] h-[6px] rounded-full bg-text-4 flex-shrink-0" />
37
- <span className="text-xs font-sans text-text-3 font-medium">Ollama Not Installed</span>
38
- <div className="flex-1" />
39
- <a
40
- href="https://ollama.ai/download"
41
- target="_blank"
42
- rel="noopener noreferrer"
43
- className="text-2xs font-sans text-accent hover:underline"
44
- >
45
- Install Ollama
46
- </a>
47
- </div>
48
- );
49
- }
30
+ const FILTERS = [
31
+ { id: 'all', label: 'All' },
32
+ { id: 'running', label: 'Running' },
33
+ { id: 'ready', label: 'Ready' },
34
+ { id: 'downloaded', label: 'Downloaded' },
35
+ ];
36
+
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' },
42
+ };
43
+
44
+ // ── Unified Model Card ──────────────────────────────────────────
45
+
46
+ function UnifiedModelCard({
47
+ model, serverRunning,
48
+ onStart, onStop, onSpawn, onDelete, onImport,
49
+ isLoading, isUnloading, isDeleting, isImporting,
50
+ }) {
51
+ const [menuOpen, setMenuOpen] = useState(false);
52
+ const menuRef = useRef(null);
50
53
 
51
- if (serverRunning) {
52
- return (
53
- <div className="flex items-center gap-2 bg-success/8 border border-success/20 rounded-lg px-3 py-2">
54
- <span className="relative flex-shrink-0 w-[6px] h-[6px]">
55
- <span className="absolute inset-0 rounded-full bg-success" />
56
- <span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />
57
- </span>
58
- <span className="text-xs font-sans text-success font-semibold">Server Running</span>
59
- <span className="text-2xs font-mono text-text-4">:11434</span>
60
- <div className="flex-1" />
61
- <button
62
- onClick={onRestart}
63
- disabled={!!actionInProgress}
64
- className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-accent cursor-pointer transition-colors disabled:opacity-40"
65
- >
66
- <RefreshCw size={10} className={actionInProgress === 'restarting' ? 'animate-spin' : ''} />
67
- {actionInProgress === 'restarting' ? 'Restarting...' : 'Restart'}
68
- </button>
69
- <button
70
- onClick={onStop}
71
- disabled={!!actionInProgress}
72
- className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-danger cursor-pointer transition-colors disabled:opacity-40"
73
- >
74
- <Square size={10} />
75
- {actionInProgress === 'stopping' ? 'Stopping...' : 'Stop'}
76
- </button>
77
- </div>
78
- );
79
- }
54
+ useEffect(() => {
55
+ if (!menuOpen) return;
56
+ const close = (e) => {
57
+ if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
58
+ };
59
+ document.addEventListener('mousedown', close);
60
+ return () => document.removeEventListener('mousedown', close);
61
+ }, [menuOpen]);
80
62
 
81
- return (
82
- <div className="flex items-center gap-2 bg-danger/8 border border-danger/20 rounded-lg px-3 py-2">
83
- <span className="w-[6px] h-[6px] rounded-full bg-danger flex-shrink-0" />
84
- <span className="text-xs font-sans text-danger font-semibold">Server Stopped</span>
85
- <span className="text-2xs font-mono text-text-4">:11434</span>
86
- <div className="flex-1" />
87
- <Button
88
- variant="primary"
89
- size="sm"
90
- onClick={onStart}
91
- disabled={!!actionInProgress}
92
- className="h-6 px-2.5 text-2xs gap-1"
93
- >
94
- <Play size={10} />
95
- {actionInProgress === 'starting' ? 'Starting...' : 'Start Server'}
96
- </Button>
97
- </div>
98
- );
99
- }
63
+ const status = STATUS_CONFIG[model.status] || STATUS_CONFIG.ready;
100
64
 
101
- // ---- Hardware Info ----
102
- function HardwareBar({ hardware }) {
103
- if (!hardware) return null;
104
65
  return (
105
- <div className="flex items-center gap-4 px-3 py-2 bg-surface-1 border border-border-subtle rounded-lg text-xs font-sans text-text-2">
106
- <div className="flex items-center gap-1.5">
107
- <MemoryStick size={14} className="text-text-3" />
108
- <span>{hardware.totalRamGb} GB RAM</span>
109
- </div>
110
- <div className="flex items-center gap-1.5">
111
- <Cpu size={14} className="text-text-3" />
112
- <span>{hardware.cores} cores</span>
113
- </div>
114
- {hardware.gpu && (
115
- <div className="flex items-center gap-1.5">
116
- <HardDrive size={14} className="text-text-3" />
117
- <span>{hardware.gpu.name}{hardware.gpu.vram ? ` (${hardware.gpu.vram} GB)` : ''}</span>
66
+ <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',
71
+ )}>
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>
118
89
  </div>
119
- )}
120
- {hardware.isAppleSilicon && (
121
- <Badge variant="accent" className="text-2xs ml-auto">Unified Memory</Badge>
122
- )}
123
- </div>
124
- );
125
- }
126
90
 
127
- // ---- Running Model Card ----
128
- function RunningModelCard({ model, onUnload, onSpawn, unloading }) {
129
- const sizeGb = model.size ? (model.size / (1024 ** 3)).toFixed(1) : '?';
130
- const vramGb = model.vram ? (model.vram / (1024 ** 3)).toFixed(1) : sizeGb;
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>
118
+ )}
119
+ </div>
120
+ )}
121
+ </div>
131
122
 
132
- return (
133
- <div className="flex items-center gap-3 px-4 py-3 bg-success/5 border border-success/20 rounded-lg">
134
- <span className="relative flex-shrink-0 w-2 h-2">
135
- <span className="absolute inset-0 rounded-full bg-success" />
136
- <span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />
137
- </span>
138
- <div className="flex-1 min-w-0">
139
- <div className="flex items-center gap-2">
140
- <span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
141
- <Badge variant="success" className="text-2xs">Running</Badge>
142
- </div>
143
- <div className="text-2xs text-text-3 font-sans mt-0.5">
144
- {vramGb} GB VRAM &middot; loaded in memory
145
- </div>
123
+ {/* 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
+ )}
146
144
  </div>
147
- <button
148
- onClick={() => onSpawn(model.name)}
149
- 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"
150
- >
151
- <Rocket size={11} />
152
- Spawn Agent
153
- </button>
154
- <button
155
- onClick={() => onUnload(model.name)}
156
- disabled={unloading === model.name}
157
- className="p-1.5 rounded-md text-text-4 hover:text-warning hover:bg-warning/10 transition-colors cursor-pointer disabled:opacity-40"
158
- title="Unload from memory"
159
- >
160
- {unloading === model.name ? <Loader2 size={14} className="animate-spin" /> : <Square size={14} />}
161
- </button>
162
- </div>
163
- );
164
- }
165
145
 
166
- // ---- Installed Model Card ----
167
- function InstalledModelCard({ model, catalogEntry, isRunning, onStart, onSpawn, onDelete, loading, deleting, serverRunning }) {
168
- return (
169
- <div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg">
170
- <Box size={18} className={cn('flex-shrink-0', isRunning ? 'text-success' : 'text-accent')} />
171
- <div className="flex-1 min-w-0">
172
- <div className="flex items-center gap-2">
173
- <span className="text-sm font-mono font-bold text-text-0 truncate">{model.id}</span>
174
- {model.tier && (
175
- <span className={cn('text-2xs font-semibold capitalize', TIER_COLORS[model.tier] || 'text-text-3')}>
176
- {model.tier}
177
- </span>
178
- )}
179
- {model.category && model.category !== 'other' && (
180
- <Badge variant="subtle" className="text-2xs">{model.category}</Badge>
181
- )}
182
- {isRunning && <Badge variant="success" className="text-2xs">Running</Badge>}
146
+ {/* Download progress inline */}
147
+ {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)}
161
+ </div>
183
162
  </div>
184
- <div className="text-2xs text-text-3 font-sans mt-0.5">
185
- {model.size || ''}
186
- {catalogEntry?.ramGb && <> &middot; ~{catalogEntry.ramGb} GB RAM needed</>}
187
- {catalogEntry?.description && <> &middot; {catalogEntry.description}</>}
163
+ )}
164
+ {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>
188
175
  </div>
189
- </div>
190
- <div className="flex items-center gap-1">
191
- {!isRunning && serverRunning && (
176
+ )}
177
+
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 && (
201
+ <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"
205
+ >
206
+ {isLoading ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
207
+ Run
208
+ </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' && (
192
219
  <button
193
- onClick={() => onStart(model.id)}
194
- disabled={!!loading}
195
- className="flex items-center gap-1 px-2 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"
196
- title="Load into memory"
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"
197
223
  >
198
- {loading === model.id ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
199
- Start
224
+ {isImporting ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
225
+ {isImporting ? 'Importing...' : 'Launch'}
200
226
  </button>
201
227
  )}
202
- <button
203
- onClick={() => onSpawn(model.id)}
204
- className="flex items-center gap-1 px-2 py-1.5 rounded-md text-2xs font-sans font-medium text-accent hover:bg-accent/10 transition-colors cursor-pointer"
205
- title="Spawn an agent with this model"
206
- >
207
- <Rocket size={11} />
208
- Spawn
209
- </button>
210
- <button
211
- onClick={() => onDelete(model.id)}
212
- disabled={deleting === model.id}
213
- className="p-1.5 rounded-md text-text-4 hover:text-red-400 hover:bg-red-400/10 transition-colors cursor-pointer disabled:opacity-40"
214
- title="Delete model"
215
- >
216
- {deleting === model.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
217
- </button>
218
- </div>
219
- </div>
220
- );
221
- }
222
-
223
- // ---- Downloaded GGUF Model Card ----
224
- function GgufModelCard({ model, onImport, onDelete, importing, deleting }) {
225
- return (
226
- <div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg">
227
- <Box size={18} className="flex-shrink-0 text-purple-400" />
228
- <div className="flex-1 min-w-0">
229
- <div className="flex items-center gap-2">
230
- <span className="text-sm font-mono font-bold text-text-0 truncate">{model.id}</span>
231
- {model.quantization && (
232
- <Badge variant="subtle" className="text-2xs">{model.quantization}</Badge>
233
- )}
234
- {model.parameters && (
235
- <span className="text-2xs font-semibold text-blue-400">{model.parameters}</span>
236
- )}
237
- <Badge variant="accent" className="text-2xs">GGUF</Badge>
238
- </div>
239
- <div className="text-2xs text-text-3 font-sans mt-0.5">
240
- {model.sizeBytes ? formatBytes(model.sizeBytes) : '—'}
241
- {model.repoId && <> &middot; {model.repoId}</>}
242
- {model.contextWindow && <> &middot; {(model.contextWindow / 1024).toFixed(0)}K context</>}
243
- </div>
244
- </div>
245
- <div className="flex items-center gap-1">
246
- <button
247
- onClick={() => onImport(model.id)}
248
- disabled={importing === model.id || deleting === model.id}
249
- 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"
250
- title="Import into Ollama so you can use it to spawn agents"
251
- >
252
- {importing === model.id ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
253
- {importing === model.id ? 'Importing...' : 'Import to Ollama'}
254
- </button>
255
- <button
256
- onClick={() => onDelete(model.id)}
257
- disabled={deleting === model.id || importing === model.id}
258
- className="p-1.5 rounded-md text-text-4 hover:text-red-400 hover:bg-red-400/10 transition-colors cursor-pointer disabled:opacity-40"
259
- title="Delete model file"
260
- >
261
- {deleting === model.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
262
- </button>
263
- </div>
264
- </div>
265
- );
266
- }
267
-
268
- // ---- Download Progress Bar ----
269
- function DownloadProgress({ download }) {
270
- const pct = Math.round((download.percent || 0) * 100);
271
- return (
272
- <div className="space-y-1">
273
- <div className="flex items-center justify-between text-2xs font-sans text-text-3">
274
- <span>{download.filename}</span>
275
- <span>{pct}% {formatSpeed(download.speed)}</span>
276
- </div>
277
- <div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
278
- <div className="h-full bg-accent rounded-full transition-all" style={{ width: `${pct}%` }} />
279
- </div>
280
- <div className="text-2xs text-text-4">
281
- {formatBytes(download.downloaded)} / {formatBytes(download.totalBytes)}
282
- </div>
283
- </div>
284
- );
285
- }
286
-
287
- // ---- Pull Progress (Ollama) ----
288
- function PullProgress({ modelId, progress }) {
289
- return (
290
- <div className="flex items-center gap-2 px-4 py-2 bg-accent/5 border border-accent/20 rounded-lg">
291
- <Loader2 size={14} className="animate-spin text-accent flex-shrink-0" />
292
- <div className="flex-1 min-w-0">
293
- <span className="text-xs font-mono text-text-0">{modelId}</span>
294
- <div className="text-2xs text-text-3 font-sans truncate">{progress.progress || 'Pulling...'}</div>
295
228
  </div>
296
229
  </div>
297
230
  );
298
231
  }
299
232
 
300
- // ---- Recommended Model Card ----
301
- function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled }) {
302
- const categoryIcons = { code: '{}', general: 'AI' };
303
- const headroom = systemRamGb ? Math.round((1 - model.ramGb / systemRamGb) * 100) : null;
233
+ // ── File Picker (quantization variants for HuggingFace results) ──
304
234
 
305
- return (
306
- <div className={cn(
307
- 'flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors',
308
- isInstalled ? 'bg-success/5 border-success/20' : 'bg-surface-1 border-border-subtle hover:border-accent/20',
309
- )}>
310
- <div className="w-9 h-9 rounded-lg bg-surface-3 flex items-center justify-center text-xs font-mono text-text-2 flex-shrink-0">
311
- {categoryIcons[model.category] || 'AI'}
312
- </div>
313
- <div className="flex-1 min-w-0">
314
- <div className="flex items-center gap-2">
315
- <span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
316
- <span className={cn('text-2xs font-semibold capitalize', TIER_COLORS[model.tier])}>{model.tier}</span>
317
- {isInstalled && <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Installed</Badge>}
318
- </div>
319
- <div className="text-2xs text-text-3 font-sans mt-0.5">{model.description}</div>
320
- <div className="flex items-center gap-3 mt-1 text-2xs font-sans">
321
- <span className="text-text-2">{model.sizeGb} GB download</span>
322
- <span className="text-green-400 font-medium">{model.ramGb} GB RAM</span>
323
- {headroom !== null && <span className="text-text-4">{headroom}% headroom</span>}
324
- </div>
325
- </div>
326
- {isInstalled ? (
327
- <span className="text-xs text-success font-sans font-medium px-3 py-1.5">Ready</span>
328
- ) : (
329
- <button
330
- onClick={() => onPull(model.id)}
331
- disabled={pulling === model.id}
332
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer disabled:opacity-40"
333
- >
334
- {pulling === model.id ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
335
- Pull
336
- </button>
337
- )}
338
- </div>
339
- );
340
- }
341
-
342
- // ---- Search Result Card (HuggingFace) ----
343
- function SearchResult({ result, onExpand, expanded }) {
344
- return (
345
- <button
346
- onClick={() => onExpand(expanded ? null : result.id)}
347
- 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"
348
- >
349
- <div className="flex items-center gap-2">
350
- <span className="text-sm font-mono font-bold text-text-0 truncate flex-1">{result.name}</span>
351
- <span className="text-2xs text-text-4 font-sans">{result.author}</span>
352
- {expanded ? <ChevronDown size={14} className="text-text-3" /> : <ChevronRight size={14} className="text-text-3" />}
353
- </div>
354
- <div className="text-2xs text-text-3 font-sans mt-0.5 flex gap-3">
355
- <span>{result.downloads?.toLocaleString()} downloads</span>
356
- <span>{result.likes} likes</span>
357
- </div>
358
- </button>
359
- );
360
- }
361
-
362
- // ---- File Picker (quantization variants) ----
363
235
  function FilePicker({ repoId, onDownload, systemRamGb }) {
364
236
  const [files, setFiles] = useState(null);
365
237
  const [loading, setLoading] = useState(true);
@@ -389,7 +261,6 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
389
261
  if (loading) {
390
262
  return <div className="py-3 px-4 text-2xs text-text-4 font-sans">Loading quantization variants...</div>;
391
263
  }
392
-
393
264
  if (!files?.length) {
394
265
  return <div className="py-3 px-4 text-2xs text-text-4 font-sans">No GGUF files found in this repo.</div>;
395
266
  }
@@ -405,7 +276,7 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
405
276
  canRun ? 'bg-surface-2' : 'bg-red-500/5 border border-red-500/15',
406
277
  )}>
407
278
  <span className="font-mono text-text-1 truncate flex-1">{f.filename}</span>
408
- {f.quantization && <Badge variant="subtle" className="text-2xs">{f.quantization}</Badge>}
279
+ {f.quantization && <Badge variant="default" className="text-2xs">{f.quantization}</Badge>}
409
280
  <span className="text-text-2 text-2xs w-16 text-right">{formatBytes(f.size)}</span>
410
281
  {f.estimatedRamGb && (
411
282
  <span className={cn(
@@ -420,7 +291,7 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
420
291
  onClick={() => handleDownload(f)}
421
292
  disabled={downloading === f.filename || !canRun}
422
293
  className={cn(
423
- 'p-1 rounded transition-colors',
294
+ 'p-1 rounded transition-colors cursor-pointer',
424
295
  canRun ? 'text-accent hover:bg-accent/10' : 'text-text-4 cursor-not-allowed',
425
296
  'disabled:opacity-40',
426
297
  )}
@@ -434,22 +305,9 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
434
305
  );
435
306
  }
436
307
 
437
- // ---- Section Header ----
438
- function SectionHeader({ title, count, icon: Icon }) {
439
- return (
440
- <div className="flex items-center gap-2 mb-2">
441
- {Icon && <Icon size={14} className="text-text-3" />}
442
- <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">{title}</span>
443
- {count !== undefined && (
444
- <Badge variant="subtle" className="text-2xs">{count}</Badge>
445
- )}
446
- </div>
447
- );
448
- }
308
+ // ── Main View ────────────────────────────────────────────────────
449
309
 
450
- // ---- Main View ----
451
310
  export default function ModelsView() {
452
- const [discoveryTab, setDiscoveryTab] = useState('recommended');
453
311
  const [searchQuery, setSearchQuery] = useState('');
454
312
  const [searchResults, setSearchResults] = useState([]);
455
313
  const [searching, setSearching] = useState(false);
@@ -463,13 +321,19 @@ export default function ModelsView() {
463
321
  const [ggufModels, setGgufModels] = useState([]);
464
322
  const [deletingGguf, setDeletingGguf] = useState(null);
465
323
  const [importingGguf, setImportingGguf] = useState(null);
324
+ const [filter, setFilter] = useState('all');
325
+ const [discoveryOpen, setDiscoveryOpen] = useState(true);
326
+ const [discoveryTab, setDiscoveryTab] = useState('recommended');
466
327
  const toast = useToast();
328
+ const searchInputRef = useRef(null);
329
+ const discoveryRef = useRef(null);
467
330
 
468
331
  const ollamaStatus = useGrooveStore((s) => s.ollamaStatus);
469
332
  const installedModels = useGrooveStore((s) => s.ollamaInstalledModels);
470
333
  const runningModels = useGrooveStore((s) => s.ollamaRunningModels);
471
334
  const catalog = useGrooveStore((s) => s.ollamaCatalog);
472
335
  const pullProgress = useGrooveStore((s) => s.ollamaPullProgress);
336
+ const labActiveModel = useGrooveStore((s) => s.labActiveModel);
473
337
  const fetchOllamaStatus = useGrooveStore((s) => s.fetchOllamaStatus);
474
338
  const startServer = useGrooveStore((s) => s.startOllamaServer);
475
339
  const stopServer = useGrooveStore((s) => s.stopOllamaServer);
@@ -482,14 +346,14 @@ export default function ModelsView() {
482
346
 
483
347
  const pollingRef = useRef(null);
484
348
 
485
- // Fetch status on mount and poll every 10s
349
+ // Poll Ollama status
486
350
  useEffect(() => {
487
351
  fetchOllamaStatus();
488
352
  pollingRef.current = setInterval(fetchOllamaStatus, 10000);
489
353
  return () => clearInterval(pollingRef.current);
490
354
  }, [fetchOllamaStatus]);
491
355
 
492
- // Fetch recommended models and GGUF downloads
356
+ // Fetch recommended + GGUF on mount
493
357
  useEffect(() => {
494
358
  api.get('/models/recommended').then((data) => {
495
359
  setRecommended(data.models || []);
@@ -499,7 +363,7 @@ export default function ModelsView() {
499
363
  }).catch(() => {});
500
364
  }, []);
501
365
 
502
- // Poll active GGUF downloads
366
+ // Poll active downloads
503
367
  useEffect(() => {
504
368
  const poll = setInterval(() => {
505
369
  api.get('/models/downloads').then(setDownloads).catch(() => {});
@@ -507,7 +371,7 @@ export default function ModelsView() {
507
371
  return () => clearInterval(poll);
508
372
  }, []);
509
373
 
510
- // WebSocket events for GGUF download progress
374
+ // WebSocket events for GGUF downloads
511
375
  useEffect(() => {
512
376
  function handleWs(event) {
513
377
  try {
@@ -541,6 +405,8 @@ export default function ModelsView() {
541
405
  return () => { if (ws) ws.removeEventListener('message', handleWs); };
542
406
  }, [toast]);
543
407
 
408
+ // ── Handlers ──────────────────────────────────────────────────
409
+
544
410
  async function handleServerStart() {
545
411
  setServerAction('starting');
546
412
  try { await startServer(); } catch {}
@@ -602,13 +468,15 @@ export default function ModelsView() {
602
468
  setDeletingGguf(null);
603
469
  }
604
470
 
605
- async function handlePull(modelId) {
606
- pullModel(modelId);
471
+ function handleDeleteUnified(model) {
472
+ if (model.source === 'gguf') handleDeleteGguf(model.id);
473
+ else handleDeleteModel(model.id);
607
474
  }
608
475
 
609
476
  async function handleSearch() {
610
477
  if (!searchQuery.trim()) return;
611
478
  setSearching(true);
479
+ setDiscoveryOpen(true);
612
480
  setDiscoveryTab('search');
613
481
  try {
614
482
  const results = await api.get(`/models/search?q=${encodeURIComponent(searchQuery.trim())}`);
@@ -619,257 +487,482 @@ export default function ModelsView() {
619
487
  setSearching(false);
620
488
  }
621
489
 
622
- const installedIds = new Set(installedModels.map((m) => m.id));
623
- const runningIds = new Set(runningModels.map((m) => m.name));
624
- const catalogByBase = {};
625
- for (const c of catalog) {
626
- const base = c.id.split(':')[0];
627
- catalogByBase[base] = c;
628
- catalogByBase[c.id] = c;
629
- }
490
+ // ── Computed: catalog lookup ───────────────────────────────────
491
+
492
+ const catalogByBase = useMemo(() => {
493
+ const map = {};
494
+ for (const c of catalog) {
495
+ const base = c.id.split(':')[0];
496
+ map[base] = c;
497
+ map[c.id] = c;
498
+ }
499
+ return map;
500
+ }, [catalog]);
630
501
 
631
502
  function getCatalogEntry(modelId) {
632
503
  if (catalogByBase[modelId]) return catalogByBase[modelId];
633
- const base = modelId.split(':')[0];
634
- return catalogByBase[base] || null;
504
+ return catalogByBase[modelId.split(':')[0]] || null;
635
505
  }
636
506
 
637
- return (
638
- <div className="h-full flex flex-col bg-surface-0">
639
- {/* Header */}
640
- <div className="flex-shrink-0 px-5 pt-4 pb-3 border-b border-border space-y-3">
641
- <div className="flex items-center justify-between">
642
- <h1 className="text-base font-bold font-sans text-text-0">Local Models</h1>
643
- <div className="flex items-center gap-2">
644
- <Badge variant="subtle" className="text-2xs">{installedModels.length + ggufModels.length} installed</Badge>
645
- {runningModels.length > 0 && (
646
- <Badge variant="success" className="text-2xs">{runningModels.length} running</Badge>
647
- )}
648
- </div>
649
- </div>
507
+ // ── Computed: lab model check ──────────────────────────────────
650
508
 
651
- {/* Server Status Bar */}
652
- <ServerStatusBar
653
- serverRunning={ollamaStatus.serverRunning}
654
- installed={ollamaStatus.installed}
655
- onStart={handleServerStart}
656
- onStop={handleServerStop}
657
- onRestart={handleServerRestart}
658
- actionInProgress={serverAction}
659
- />
660
-
661
- {/* Hardware Bar */}
662
- <HardwareBar hardware={ollamaStatus.hardware} />
663
- </div>
509
+ function isModelInLab(modelId) {
510
+ if (!labActiveModel) return false;
511
+ if (typeof labActiveModel === 'string') return labActiveModel === modelId;
512
+ return labActiveModel.name === modelId || labActiveModel.id === modelId;
513
+ }
664
514
 
665
- {/* Active Downloads (GGUF) */}
666
- {downloads.length > 0 && (
667
- <div className="px-5 py-3 border-b border-border space-y-2">
668
- <div className="text-xs font-sans font-semibold text-text-2">Downloading</div>
669
- {downloads.map((d) => <DownloadProgress key={d.filename} download={d} />)}
670
- </div>
671
- )}
515
+ // ── Computed: unified model list ──────────────────────────────
516
+
517
+ const unifiedModels = useMemo(() => {
518
+ const models = [];
519
+ const seen = new Set();
520
+
521
+ for (const m of runningModels) {
522
+ seen.add(m.name);
523
+ const installed = installedModels.find((im) => im.id === m.name);
524
+ const cat = getCatalogEntry(m.name);
525
+ models.push({
526
+ id: m.name,
527
+ name: m.name,
528
+ source: 'ollama',
529
+ status: 'running',
530
+ size: installed?.size || (m.size ? formatBytes(m.size) : '—'),
531
+ parameters: cat?.parameters || installed?.parameters,
532
+ quantization: installed?.quantization,
533
+ tier: installed?.tier,
534
+ vramGb: m.vram ? (m.vram / (1024 ** 3)).toFixed(1) : m.size ? (m.size / (1024 ** 3)).toFixed(1) : null,
535
+ isInLab: isModelInLab(m.name),
536
+ });
537
+ }
672
538
 
673
- {/* Ollama Pull Progress */}
674
- {Object.keys(pullProgress).length > 0 && (
675
- <div className="px-5 py-3 border-b border-border space-y-2">
676
- <div className="text-xs font-sans font-semibold text-text-2">Pulling Models</div>
677
- {Object.entries(pullProgress).map(([id, prog]) => (
678
- <PullProgress key={id} modelId={id} progress={prog} />
679
- ))}
680
- </div>
681
- )}
539
+ for (const m of installedModels) {
540
+ if (seen.has(m.id)) continue;
541
+ seen.add(m.id);
542
+ const cat = getCatalogEntry(m.id);
543
+ models.push({
544
+ id: m.id,
545
+ name: m.id,
546
+ source: 'ollama',
547
+ status: 'ready',
548
+ size: m.size || '—',
549
+ parameters: cat?.parameters || m.parameters,
550
+ quantization: m.quantization,
551
+ tier: m.tier,
552
+ category: m.category,
553
+ isInLab: isModelInLab(m.id),
554
+ catalogEntry: cat,
555
+ });
556
+ }
682
557
 
683
- {/* Content */}
684
- <ScrollArea className="flex-1">
685
- <div className="px-5 py-4 space-y-6">
686
- {/* Running Models Section */}
687
- <div>
688
- <SectionHeader title="Running Models" count={runningModels.length} icon={Zap} />
689
- {runningModels.length === 0 ? (
690
- <div className="px-4 py-4 bg-surface-1 border border-border-subtle rounded-lg text-center">
691
- <p className="text-xs text-text-3 font-sans">
692
- {ollamaStatus.serverRunning
693
- ? 'No models loaded — start one below'
694
- : 'Start the server to load models'}
695
- </p>
696
- </div>
697
- ) : (
698
- <div className="space-y-2">
699
- {runningModels.map((m) => (
700
- <RunningModelCard
701
- key={m.name}
702
- model={m}
703
- onUnload={handleUnloadModel}
704
- onSpawn={spawnFromModel}
705
- unloading={unloadingModel}
706
- />
707
- ))}
558
+ for (const m of ggufModels) {
559
+ seen.add(m.id);
560
+ models.push({
561
+ id: m.id,
562
+ name: m.id,
563
+ source: 'gguf',
564
+ status: 'downloaded',
565
+ size: m.sizeBytes ? formatBytes(m.sizeBytes) : '—',
566
+ parameters: m.parameters,
567
+ quantization: m.quantization,
568
+ isInLab: isModelInLab(m.id),
569
+ repoId: m.repoId,
570
+ contextWindow: m.contextWindow,
571
+ });
572
+ }
573
+
574
+ for (const d of downloads) {
575
+ if (seen.has(d.filename)) continue;
576
+ models.push({
577
+ id: `dl-${d.filename}`,
578
+ name: d.filename,
579
+ source: 'gguf',
580
+ status: 'downloading',
581
+ download: d,
582
+ });
583
+ }
584
+
585
+ for (const [id, prog] of Object.entries(pullProgress)) {
586
+ if (seen.has(id)) continue;
587
+ models.push({
588
+ id: `pull-${id}`,
589
+ name: id,
590
+ source: 'ollama',
591
+ status: 'downloading',
592
+ pullProgress: prog,
593
+ });
594
+ }
595
+
596
+ return models;
597
+ }, [runningModels, installedModels, ggufModels, downloads, pullProgress, labActiveModel, catalog]);
598
+
599
+ // ── Computed: filter + search ──────────────────────────────────
600
+
601
+ const filteredModels = useMemo(() => {
602
+ let list = unifiedModels;
603
+ if (filter === 'running') list = list.filter((m) => m.status === 'running');
604
+ else if (filter === 'ready') list = list.filter((m) => m.status === 'ready');
605
+ else if (filter === 'downloaded') list = list.filter((m) => m.status === 'downloaded' || m.status === 'downloading');
606
+ if (searchQuery.trim() && discoveryTab !== 'search') {
607
+ const q = searchQuery.toLowerCase();
608
+ list = list.filter((m) => m.name.toLowerCase().includes(q));
609
+ }
610
+ return list;
611
+ }, [unifiedModels, filter, searchQuery, discoveryTab]);
612
+
613
+ const filterCounts = useMemo(() => ({
614
+ all: unifiedModels.length,
615
+ running: unifiedModels.filter((m) => m.status === 'running').length,
616
+ ready: unifiedModels.filter((m) => m.status === 'ready').length,
617
+ downloaded: unifiedModels.filter((m) => m.status === 'downloaded' || m.status === 'downloading').length,
618
+ }), [unifiedModels]);
619
+
620
+ const hasNoModels = unifiedModels.length === 0;
621
+
622
+ // ── Render ─────────────────────────────────────────────────────
623
+
624
+ 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">
628
+
629
+ {/* Server status row */}
630
+ {!ollamaStatus.installed ? (
631
+ <div className="flex items-center gap-2 bg-surface-1 border border-border-subtle rounded-lg px-3 py-2">
632
+ <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>
634
+ <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} />
642
+ </a>
643
+ </div>
644
+ ) : ollamaStatus.serverRunning ? (
645
+ <div className="flex items-center gap-2 flex-wrap">
646
+ <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" />
649
+ </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
+ )}
708
673
  </div>
709
674
  )}
675
+
676
+ <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>
710
710
  </div>
711
+ )}
711
712
 
712
- {/* Installed Models Section */}
713
- <div>
714
- <SectionHeader title="Installed Models" count={installedModels.length} icon={HardDrive} />
715
- {installedModels.length === 0 ? (
716
- <div className="px-4 py-6 bg-surface-1 border border-border-subtle rounded-lg text-center">
717
- <Box size={32} className="mx-auto text-text-4 mb-2" />
718
- <p className="text-sm text-text-2 font-sans font-medium">No models installed</p>
719
- <p className="text-xs text-text-3 font-sans mt-1">
720
- Pull a model from the Recommended section below, or search HuggingFace.
721
- </p>
722
- </div>
723
- ) : (
724
- <div className="space-y-2">
725
- {installedModels.map((m) => (
726
- <InstalledModelCard
727
- key={m.id}
728
- model={m}
729
- catalogEntry={getCatalogEntry(m.id)}
730
- isRunning={runningIds.has(m.id)}
731
- onStart={handleLoadModel}
732
- onSpawn={spawnFromModel}
733
- onDelete={handleDeleteModel}
734
- loading={loadingModel}
735
- deleting={deletingModel}
736
- serverRunning={ollamaStatus.serverRunning}
737
- />
738
- ))}
739
- </div>
740
- )}
713
+ {/* Search + Filter row */}
714
+ <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" />
717
+ <input
718
+ ref={searchInputRef}
719
+ value={searchQuery}
720
+ onChange={(e) => setSearchQuery(e.target.value)}
721
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
722
+ 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"
724
+ />
725
+ </div>
726
+
727
+ <div className="flex items-center gap-1">
728
+ {FILTERS.map((f) => (
729
+ <button
730
+ key={f.id}
731
+ onClick={() => setFilter(f.id)}
732
+ 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',
737
+ )}
738
+ >
739
+ {f.label}
740
+ {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>
744
+ )}
745
+ </button>
746
+ ))}
741
747
  </div>
748
+ </div>
749
+ </div>
742
750
 
743
- {/* Downloaded GGUF Models Section */}
744
- {ggufModels.length > 0 && (
745
- <div>
746
- <SectionHeader title="Downloaded Models (GGUF)" count={ggufModels.length} icon={Download} />
747
- <div className="space-y-2">
748
- {ggufModels.map((m) => (
749
- <GgufModelCard
750
- key={m.id}
751
- model={m}
752
- onImport={handleImportToOllama}
753
- onDelete={handleDeleteGguf}
754
- importing={importingGguf}
755
- deleting={deletingGguf}
756
- />
757
- ))}
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>
758
785
  </div>
759
786
  </div>
760
- )}
761
-
762
- {/* Divider */}
763
- <div className="border-t border-border-subtle" />
764
-
765
- {/* Discovery Section */}
766
- <div>
767
- <div className="flex items-center justify-between mb-3">
768
- <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">Discover Models</span>
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>
769
794
  </div>
770
-
771
- {/* Search */}
772
- <div className="flex gap-2 mb-3">
773
- <div className="relative flex-1">
774
- <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4" />
775
- <input
776
- value={searchQuery}
777
- onChange={(e) => setSearchQuery(e.target.value)}
778
- onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
779
- placeholder="Search HuggingFace for GGUF models..."
780
- 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"
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}
781
812
  />
782
- </div>
783
- <Button onClick={handleSearch} disabled={searching} size="sm" variant="accent">
784
- {searching ? <Loader2 size={14} className="animate-spin" /> : 'Search'}
785
- </Button>
786
- </div>
787
-
788
- {/* Tabs */}
789
- <div className="flex gap-1 mb-3">
790
- {[
791
- { id: 'recommended', label: `Recommended (${recommended.length})` },
792
- { id: 'search', label: `Search (${searchResults.length})` },
793
- ].map((t) => (
794
- <button
795
- key={t.id}
796
- onClick={() => setDiscoveryTab(t.id)}
797
- className={cn(
798
- 'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
799
- discoveryTab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
800
- )}
801
- >
802
- {t.label}
803
- </button>
804
813
  ))}
805
814
  </div>
815
+ )}
806
816
 
807
- {/* Tab content */}
808
- <div className="space-y-2">
809
- {discoveryTab === 'recommended' && (
810
- <>
811
- {recommended.length === 0 ? (
812
- <div className="text-center py-8">
813
- <Cpu size={32} className="mx-auto text-text-4 mb-2" />
814
- <p className="text-sm text-text-2 font-sans font-medium">Detecting hardware...</p>
815
- <p className="text-xs text-text-3 font-sans mt-1">Make sure Ollama is installed so we can check your system.</p>
816
- </div>
817
- ) : (
818
- <>
819
- <div className="text-xs text-text-3 font-sans mb-2">
820
- Top models for your system ({ollamaStatus.hardware?.totalRamGb || '?'} GB RAM). Click Pull to download via Ollama.
821
- </div>
822
- {recommended.map((m) => {
823
- const baseId = m.id.split(':')[0];
824
- const isInstalled = installedModels.some((im) =>
825
- im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
826
- );
827
- return (
828
- <RecommendedModel
829
- key={m.id}
830
- model={m}
831
- systemRamGb={ollamaStatus.hardware?.totalRamGb}
832
- onPull={handlePull}
833
- pulling={pullProgress[m.id] ? m.id : null}
834
- isInstalled={isInstalled}
835
- />
836
- );
837
- })}
838
- </>
839
- )}
840
- </>
841
- )}
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>
842
831
 
843
- {discoveryTab === 'search' && (
844
- <>
845
- {searching ? (
846
- <div className="text-center py-8">
847
- <Loader2 size={24} className="mx-auto text-accent animate-spin mb-2" />
848
- <p className="text-sm text-text-3 font-sans">Searching HuggingFace...</p>
849
- </div>
850
- ) : searchResults.length === 0 ? (
851
- <div className="text-center py-8">
852
- <Search size={32} className="mx-auto text-text-4 mb-2" />
853
- <p className="text-sm text-text-2 font-sans font-medium">Search for GGUF models</p>
854
- <p className="text-xs text-text-3 font-sans mt-1">Try "qwen coder", "deepseek", "codestral", "llama"</p>
855
- </div>
856
- ) : (
857
- searchResults.map((r) => (
858
- <div key={r.id} className="space-y-1">
859
- <SearchResult
860
- result={r}
861
- expanded={expandedResult === r.id}
862
- onExpand={setExpandedResult}
863
- />
864
- {expandedResult === r.id && (
865
- <FilePicker repoId={r.id} systemRamGb={ollamaStatus.hardware?.totalRamGb} />
866
- )}
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>
852
+
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>
867
861
  </div>
868
- ))
869
- )}
870
- </>
871
- )}
872
- </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>}
897
+ </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
+ )}
910
+ </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>
934
+ </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
+ ))}
960
+ </div>
961
+ )}
962
+ </>
963
+ )}
964
+ </div>
965
+ )}
873
966
  </div>
874
967
  </div>
875
968
  </ScrollArea>