groove-dev 0.27.59 → 0.27.61

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 (68) 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 +73 -56
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +78 -35
  5. package/node_modules/@groove-dev/daemon/src/journalist.js +1 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +17 -7
  7. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  8. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +63 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +55 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +53 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/providers/index.js +16 -1
  13. package/node_modules/@groove-dev/daemon/src/providers/local.js +44 -0
  14. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +44 -0
  15. package/node_modules/@groove-dev/daemon/src/rotator.js +4 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/assets/index-DWao9glo.js +8614 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +3 -2
  21. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +1 -1
  22. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +13 -7
  23. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +245 -0
  24. package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +1 -1
  25. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +1 -1
  26. package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +5 -5
  27. package/node_modules/@groove-dev/gui/src/components/network/node-details.jsx +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/ui/update-modal.jsx +70 -0
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +66 -6
  30. package/node_modules/@groove-dev/gui/src/views/network.jsx +99 -17
  31. package/package.json +1 -1
  32. package/packages/cli/package.json +1 -1
  33. package/packages/daemon/package.json +1 -1
  34. package/packages/daemon/src/api.js +73 -56
  35. package/packages/daemon/src/conversations.js +78 -35
  36. package/packages/daemon/src/journalist.js +1 -0
  37. package/packages/daemon/src/process.js +17 -7
  38. package/packages/daemon/src/providers/base.js +4 -0
  39. package/packages/daemon/src/providers/claude-code.js +63 -0
  40. package/packages/daemon/src/providers/codex.js +55 -0
  41. package/packages/daemon/src/providers/gemini.js +53 -0
  42. package/packages/daemon/src/providers/groove-network.js +1 -1
  43. package/packages/daemon/src/providers/index.js +16 -1
  44. package/packages/daemon/src/providers/local.js +44 -0
  45. package/packages/daemon/src/providers/ollama.js +44 -0
  46. package/packages/daemon/src/rotator.js +4 -0
  47. package/packages/gui/dist/assets/index-B3AqeyS4.css +1 -0
  48. package/packages/gui/dist/assets/index-DWao9glo.js +8614 -0
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/components/chat/chat-view.jsx +3 -2
  52. package/packages/gui/src/components/chat/model-picker.jsx +1 -1
  53. package/packages/gui/src/components/layout/status-bar.jsx +13 -7
  54. package/packages/gui/src/components/network/activity-chart.jsx +245 -0
  55. package/packages/gui/src/components/network/compute-header.jsx +1 -1
  56. package/packages/gui/src/components/network/network-health.jsx +1 -1
  57. package/packages/gui/src/components/network/network-status.jsx +5 -5
  58. package/packages/gui/src/components/network/node-details.jsx +1 -1
  59. package/packages/gui/src/components/ui/update-modal.jsx +70 -0
  60. package/packages/gui/src/stores/groove.js +66 -6
  61. package/packages/gui/src/views/network.jsx +99 -17
  62. package/default/fix-beta-endpoint-deployment.md +0 -68
  63. package/default/groovedev-beta-auth-endpoint.md +0 -166
  64. package/default/security-review-prompt.md +0 -98
  65. package/node_modules/@groove-dev/gui/dist/assets/index-BrfCzrxJ.css +0 -1
  66. package/node_modules/@groove-dev/gui/dist/assets/index-BycOlqLx.js +0 -8614
  67. package/packages/gui/dist/assets/index-BrfCzrxJ.css +0 -1
  68. package/packages/gui/dist/assets/index-BycOlqLx.js +0 -8614
@@ -6,12 +6,12 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-BycOlqLx.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DWao9glo.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
14
- <link rel="stylesheet" crossorigin href="/assets/index-BrfCzrxJ.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-B3AqeyS4.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.59",
3
+ "version": "0.27.61",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -38,6 +38,7 @@ export function ChatView() {
38
38
  const stopAgent = useGrooveStore((s) => s.stopAgent);
39
39
  const stopChatStreaming = useGrooveStore((s) => s.stopChatStreaming);
40
40
  const setConversationMode = useGrooveStore((s) => s.setConversationMode);
41
+ const setConversationModel = useGrooveStore((s) => s.setConversationModel);
41
42
 
42
43
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
43
44
 
@@ -74,11 +75,11 @@ export function ChatView() {
74
75
 
75
76
  const handleModelChange = useCallback(async (selection) => {
76
77
  if (activeConversationId) {
77
- // TODO: Update conversation model via API
78
+ await setConversationModel(activeConversationId, selection.provider, selection.model);
78
79
  } else {
79
80
  await handleNewChat(selection.provider, selection.model);
80
81
  }
81
- }, [activeConversationId, handleNewChat]);
82
+ }, [activeConversationId, setConversationModel, handleNewChat]);
82
83
 
83
84
  const currentModel = activeConversation
84
85
  ? { provider: activeConversation.provider, model: activeConversation.model }
@@ -83,7 +83,7 @@ export function ModelPicker({ value, onChange, disabled }) {
83
83
  </button>
84
84
 
85
85
  {open && (
86
- <div className="absolute top-full left-0 mt-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
86
+ <div className="absolute top-full right-0 mt-1 w-72 max-h-80 overflow-y-auto rounded-lg border border-border bg-surface-1 shadow-xl z-50">
87
87
  {providers.length === 0 && (
88
88
  <div className="px-4 py-6 text-center text-xs text-text-3 font-sans">No providers available</div>
89
89
  )}
@@ -2,9 +2,11 @@
2
2
  import { Terminal, BookOpen, Radio, Plug, Globe, ArrowUpCircle, X, Unplug } from 'lucide-react';
3
3
  import { cn } from '../../lib/cn';
4
4
  import { StatusDot } from '../ui/status-dot';
5
+ import { Badge } from '../ui/badge';
5
6
  import { fmtUptime } from '../../lib/format';
6
7
  import { useGrooveStore } from '../../stores/groove';
7
8
  import { isElectron, openExternal } from '../../lib/electron';
9
+ import { UpdateModal } from '../ui/update-modal';
8
10
 
9
11
  export function StatusBar({
10
12
  connected,
@@ -18,7 +20,8 @@ export function StatusBar({
18
20
  const tunneled = useGrooveStore((s) => s.tunneled);
19
21
  const version = useGrooveStore((s) => s.version);
20
22
  const updateReady = useGrooveStore((s) => s.updateReady);
21
- const installUpdate = useGrooveStore((s) => s.installUpdate);
23
+ const updateProgress = useGrooveStore((s) => s.updateProgress);
24
+ const setUpdateModalOpen = useGrooveStore((s) => s.setUpdateModalOpen);
22
25
  const subscription = useGrooveStore((s) => s.subscription);
23
26
  const navigate = useGrooveStore((s) => s.setActiveView);
24
27
  const activeTunnel = savedTunnels.find((t) => t.active);
@@ -110,14 +113,16 @@ export function StatusBar({
110
113
  <div className="flex-1" />
111
114
 
112
115
  {/* Right: version + docs + terminal toggle */}
113
- {updateReady ? (
116
+ {updateReady || updateProgress ? (
114
117
  <button
115
- onClick={installUpdate}
116
- className="flex items-center gap-1.5 px-2 h-full text-success hover:bg-success/10 transition-colors cursor-pointer"
117
- title={`Update to v${updateReady}`}
118
+ onClick={() => setUpdateModalOpen(true)}
119
+ className="flex items-center gap-1 px-2 h-full cursor-pointer"
120
+ title={updateReady ? `Update to v${updateReady}` : 'Downloading update\u2026'}
118
121
  >
119
- <ArrowUpCircle size={12} />
120
- <span>v{updateReady}</span>
122
+ <Badge variant="warning" className="cursor-pointer">
123
+ <ArrowUpCircle size={10} />
124
+ {updateReady ? 'Update Available' : 'Downloading\u2026'}
125
+ </Badge>
121
126
  </button>
122
127
  ) : version ? (
123
128
  <span className="text-text-4 px-2">v{version}</span>
@@ -146,6 +151,7 @@ export function StatusBar({
146
151
  <span>Terminal</span>
147
152
  <kbd className="font-mono text-text-4 ml-0.5">Cmd+J</kbd>
148
153
  </button>
154
+ <UpdateModal />
149
155
  </footer>
150
156
  );
151
157
  }
@@ -0,0 +1,245 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useRef, useEffect, useState, useCallback, useMemo, memo } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { HEX, hexAlpha } from '../../lib/theme-hex';
5
+ import { Badge } from '../ui/badge';
6
+
7
+ function shortAddr(addr) {
8
+ if (!addr || typeof addr !== 'string') return '—';
9
+ if (addr.length < 14) return addr;
10
+ return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
11
+ }
12
+
13
+ export const ActivityChart = memo(function ActivityChart() {
14
+ const snapshots = useGrooveStore((s) => s.networkSnapshots);
15
+ const nodes = useGrooveStore((s) => s.networkStatus.nodes || []);
16
+ const ownNodeId = useGrooveStore((s) => s.networkNode.nodeId);
17
+
18
+ const containerRef = useRef(null);
19
+ const canvasRef = useRef(null);
20
+ const [size, setSize] = useState({ width: 0, height: 0 });
21
+ const [hover, setHover] = useState(null);
22
+
23
+ const { width, height } = size;
24
+ const pad = { top: 28, right: 12, bottom: 8, left: 12 };
25
+ const w = Math.max(width - pad.left - pad.right, 0);
26
+ const h = Math.max(height - pad.top - pad.bottom, 0);
27
+
28
+ const chartData = useMemo(() => {
29
+ if (!snapshots || snapshots.length < 2) return [];
30
+ return snapshots;
31
+ }, [snapshots]);
32
+
33
+ useEffect(() => {
34
+ const el = containerRef.current;
35
+ if (!el) return;
36
+ const observer = new ResizeObserver((entries) => {
37
+ const { width: cw, height: ch } = entries[0].contentRect;
38
+ if (cw > 0 && ch > 0) setSize({ width: Math.floor(cw), height: Math.floor(ch) });
39
+ });
40
+ observer.observe(el);
41
+ return () => observer.disconnect();
42
+ }, []);
43
+
44
+ const onMouseMove = useCallback((e) => {
45
+ const canvas = canvasRef.current;
46
+ if (!canvas || !chartData.length || w <= 0) return;
47
+ const rect = canvas.getBoundingClientRect();
48
+ const x = e.clientX - rect.left - pad.left;
49
+ if (x < 0 || x > w) { setHover(null); return; }
50
+ const index = Math.round((x / w) * (chartData.length - 1));
51
+ setHover({ x: pad.left + (index / Math.max(chartData.length - 1, 1)) * w, index });
52
+ }, [chartData, w, pad.left]);
53
+
54
+ const onMouseLeave = useCallback(() => setHover(null), []);
55
+
56
+ useEffect(() => {
57
+ const canvas = canvasRef.current;
58
+ if (!canvas || !chartData.length || width <= 0 || height <= 0 || w <= 0 || h <= 0) return;
59
+ const ctx = canvas.getContext('2d');
60
+ const dpr = window.devicePixelRatio || 1;
61
+
62
+ canvas.width = width * dpr;
63
+ canvas.height = height * dpr;
64
+ ctx.scale(dpr, dpr);
65
+ ctx.clearRect(0, 0, width, height);
66
+
67
+ const globalSessions = chartData.map((d) => d.globalSessions);
68
+ const mySessions = chartData.map((d) => d.mySessions);
69
+ const maxVal = Math.max(...globalSessions, ...mySessions, 1);
70
+
71
+ const xAt = (i) => pad.left + (i / Math.max(chartData.length - 1, 1)) * w;
72
+ const yAt = (v) => pad.top + h - (v / maxVal) * h;
73
+
74
+ // Horizontal grid lines
75
+ ctx.setLineDash([2, 4]);
76
+ ctx.strokeStyle = hexAlpha(HEX.text4, 0.2);
77
+ ctx.lineWidth = 1;
78
+ for (let i = 1; i <= 3; i++) {
79
+ const y = pad.top + (h / 4) * i;
80
+ ctx.beginPath();
81
+ ctx.moveTo(pad.left, y);
82
+ ctx.lineTo(pad.left + w, y);
83
+ ctx.stroke();
84
+ }
85
+ ctx.setLineDash([]);
86
+
87
+ // Y-axis labels
88
+ ctx.font = "9px 'JetBrains Mono Variable', monospace";
89
+ ctx.textAlign = 'left';
90
+ ctx.fillStyle = hexAlpha(HEX.text3, 0.5);
91
+ ctx.fillText(String(maxVal), pad.left + 4, pad.top + 10);
92
+ ctx.fillText(String(Math.round(maxVal / 2)), pad.left + 4, pad.top + h / 2 + 4);
93
+
94
+ // Network line — gradient fill
95
+ ctx.beginPath();
96
+ ctx.moveTo(pad.left, pad.top + h);
97
+ for (let i = 0; i < chartData.length; i++) {
98
+ ctx.lineTo(xAt(i), yAt(globalSessions[i]));
99
+ }
100
+ ctx.lineTo(xAt(chartData.length - 1), pad.top + h);
101
+ ctx.closePath();
102
+ const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + h);
103
+ grad.addColorStop(0, hexAlpha(HEX.purple, 0.2));
104
+ grad.addColorStop(0.7, hexAlpha(HEX.purple, 0.04));
105
+ grad.addColorStop(1, hexAlpha(HEX.purple, 0));
106
+ ctx.fillStyle = grad;
107
+ ctx.fill();
108
+
109
+ // Network line — stroke
110
+ ctx.beginPath();
111
+ ctx.strokeStyle = HEX.purple;
112
+ ctx.lineWidth = 1.5;
113
+ ctx.lineJoin = 'round';
114
+ for (let i = 0; i < chartData.length; i++) {
115
+ const x = xAt(i);
116
+ const y = yAt(globalSessions[i]);
117
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
118
+ }
119
+ ctx.stroke();
120
+
121
+ // Your Node line — stroke only
122
+ ctx.beginPath();
123
+ ctx.strokeStyle = HEX.accent;
124
+ ctx.lineWidth = 1.5;
125
+ ctx.lineJoin = 'round';
126
+ for (let i = 0; i < chartData.length; i++) {
127
+ const x = xAt(i);
128
+ const y = yAt(mySessions[i]);
129
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
130
+ }
131
+ ctx.stroke();
132
+
133
+ // Inline legend (top-right)
134
+ ctx.font = "9px 'Inter Variable', sans-serif";
135
+ ctx.textAlign = 'right';
136
+ let rx = width - pad.right - 4;
137
+ const ly = 14;
138
+
139
+ ctx.fillStyle = HEX.accent;
140
+ ctx.fillText('Your Node', rx, ly);
141
+ rx -= ctx.measureText('Your Node').width + 4;
142
+ ctx.beginPath(); ctx.arc(rx, ly - 3, 2.5, 0, Math.PI * 2); ctx.fill();
143
+ rx -= 14;
144
+
145
+ ctx.fillStyle = HEX.purple;
146
+ ctx.fillText('Network', rx, ly);
147
+ rx -= ctx.measureText('Network').width + 4;
148
+ ctx.beginPath(); ctx.arc(rx, ly - 3, 2.5, 0, Math.PI * 2); ctx.fill();
149
+
150
+ // Hover
151
+ if (hover && hover.index >= 0 && hover.index < chartData.length) {
152
+ const hx = hover.x;
153
+ const d = chartData[hover.index];
154
+
155
+ // Crosshair
156
+ ctx.beginPath();
157
+ ctx.moveTo(hx, pad.top);
158
+ ctx.lineTo(hx, pad.top + h);
159
+ ctx.strokeStyle = hexAlpha(HEX.text1, 0.15);
160
+ ctx.lineWidth = 1;
161
+ ctx.stroke();
162
+
163
+ // Dots
164
+ ctx.beginPath(); ctx.arc(hx, yAt(d.globalSessions), 3, 0, Math.PI * 2);
165
+ ctx.fillStyle = HEX.purple; ctx.fill();
166
+ ctx.beginPath(); ctx.arc(hx, yAt(d.mySessions), 3, 0, Math.PI * 2);
167
+ ctx.fillStyle = HEX.accent; ctx.fill();
168
+
169
+ // Tooltip
170
+ const lines = [
171
+ { label: 'Network', value: String(d.globalSessions), color: HEX.purple },
172
+ { label: 'Your Node', value: String(d.mySessions), color: HEX.accent },
173
+ { label: 'Nodes', value: String(d.nodeCount), color: HEX.text2 },
174
+ ];
175
+ const tooltipW = 104;
176
+ const tooltipH = lines.length * 16 + 12;
177
+ let tx = hx + 12;
178
+ if (tx + tooltipW > width - 8) tx = hx - tooltipW - 12;
179
+ const ty = Math.max(pad.top, yAt(d.globalSessions) - tooltipH / 2);
180
+
181
+ ctx.fillStyle = hexAlpha(HEX.surface0, 0.92);
182
+ ctx.beginPath(); ctx.roundRect(tx, ty, tooltipW, tooltipH, 4); ctx.fill();
183
+ ctx.strokeStyle = hexAlpha(HEX.text4, 0.2);
184
+ ctx.lineWidth = 1; ctx.stroke();
185
+
186
+ ctx.textAlign = 'left';
187
+ lines.forEach((line, i) => {
188
+ const rowY = ty + 14 + i * 16;
189
+ ctx.beginPath(); ctx.arc(tx + 8, rowY - 3, 2, 0, Math.PI * 2);
190
+ ctx.fillStyle = line.color; ctx.fill();
191
+ ctx.font = "8px 'Inter Variable', sans-serif";
192
+ ctx.fillStyle = HEX.text3; ctx.fillText(line.label, tx + 14, rowY);
193
+ ctx.font = "9px 'JetBrains Mono Variable', monospace";
194
+ ctx.fillStyle = HEX.text0; ctx.textAlign = 'right';
195
+ ctx.fillText(line.value, tx + tooltipW - 8, rowY);
196
+ ctx.textAlign = 'left';
197
+ });
198
+ }
199
+ }, [chartData, width, height, hover, w, h, pad]);
200
+
201
+ const activeNodes = nodes.filter((n) => n.status === 'active');
202
+
203
+ return (
204
+ <div className="flex flex-col h-full">
205
+ <div className="px-3 pt-2.5 pb-1 flex-shrink-0 flex items-center justify-between">
206
+ <span className="text-2xs font-mono text-text-3 uppercase tracking-widest">Network Activity</span>
207
+ <span className="text-2xs font-mono text-text-3 tabular-nums">{activeNodes.length} nodes</span>
208
+ </div>
209
+
210
+ <div className="relative flex-1 min-h-0">
211
+ {chartData.length < 2 ? (
212
+ <div className="absolute inset-0 flex items-center justify-center">
213
+ <span className="text-xs font-mono text-text-3">Collecting network data…</span>
214
+ </div>
215
+ ) : (
216
+ <div ref={containerRef} className="absolute inset-0">
217
+ {width > 0 && height > 0 && (
218
+ <canvas
219
+ ref={canvasRef}
220
+ style={{ width, height }}
221
+ className="block cursor-crosshair"
222
+ onMouseMove={onMouseMove}
223
+ onMouseLeave={onMouseLeave}
224
+ />
225
+ )}
226
+ </div>
227
+ )}
228
+ </div>
229
+
230
+ <div className="px-3 py-1.5 border-t border-border-subtle flex items-center gap-2 flex-shrink-0 font-mono text-2xs" style={{ background: hexAlpha(HEX.accent, 0.04) }}>
231
+ <span className="text-text-3">{activeNodes.length} node{activeNodes.length !== 1 ? 's' : ''} online</span>
232
+ {activeNodes.map((n) => {
233
+ const id = n.node_id || n.nodeId || '';
234
+ const isSelf = ownNodeId && id === ownNodeId;
235
+ const layers = Array.isArray(n.layers) ? `${n.layers[0]}-${n.layers[1]}` : '';
236
+ return (
237
+ <span key={id} className={isSelf ? 'text-accent' : 'text-text-2'}>
238
+ {shortAddr(id)}{isSelf ? ' (You' : '('}{layers ? ` · ${layers}` : ''})
239
+ </span>
240
+ );
241
+ })}
242
+ </div>
243
+ </div>
244
+ );
245
+ });
@@ -121,7 +121,7 @@ export const ComputeHeader = memo(function ComputeHeader() {
121
121
  const loadColor = compute.avgLoad > 2.0 ? HEX.danger : compute.avgLoad > 1.0 ? HEX.warning : HEX.success;
122
122
  const activeModel = models.length > 0
123
123
  ? (typeof models[0] === 'string' ? models[0] : models[0].name)
124
- : 'google/gemma-3-4b';
124
+ : 'Qwen/Qwen3-4B';
125
125
 
126
126
  const kpis = [
127
127
  { label: 'RAM', value: `${fmtMbToGb(compute.totalRamMb)} GB`, color: HEX.accent, hint: 'Total RAM across all network nodes.' },
@@ -22,7 +22,7 @@ export const NetworkHealth = memo(function NetworkHealth() {
22
22
  const node = useGrooveStore((s) => s.networkNode);
23
23
 
24
24
  const nodes = Array.isArray(status.nodes) ? status.nodes : [];
25
- const totalLayers = status.totalLayers || 34;
25
+ const totalLayers = status.totalLayers || 36;
26
26
  const covered = status.coverage || 0;
27
27
  const coverage = coverageState(covered, totalLayers);
28
28
  const coveragePct = totalLayers ? Math.min(100, (covered / totalLayers) * 100) : 0;
@@ -24,7 +24,7 @@ function coverageState(covered, total) {
24
24
 
25
25
  function KpiTile({ icon: Icon, label, value, sub }) {
26
26
  return (
27
- <div className="rounded-lg border border-border bg-surface-1 px-3 py-2.5 flex items-center gap-2.5 min-w-0">
27
+ <div className="rounded-sm border border-border bg-surface-1 px-3 py-2.5 flex items-center gap-2.5 min-w-0">
28
28
  <div className="w-8 h-8 rounded-md bg-accent/10 text-accent flex items-center justify-center flex-shrink-0">
29
29
  <Icon size={14} />
30
30
  </div>
@@ -53,7 +53,7 @@ export function NetworkStatus() {
53
53
  return (
54
54
  <div className="flex flex-col gap-3">
55
55
  {/* Signal connection indicator */}
56
- <div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-surface-1">
56
+ <div className="flex items-center gap-2 px-3 py-2 rounded-sm border border-border bg-surface-1">
57
57
  <StatusDot status={signalReachable ? 'running' : 'crashed'} size="sm" />
58
58
  <span className="text-2xs font-sans text-text-3">Signal:</span>
59
59
  <span className="text-2xs font-mono text-text-1">signal.groovedev.ai</span>
@@ -71,7 +71,7 @@ export function NetworkStatus() {
71
71
  </div>
72
72
 
73
73
  {/* Coverage bar */}
74
- <div className="rounded-lg border border-border bg-surface-1 px-4 py-3">
74
+ <div className="rounded-sm border border-border bg-surface-1 px-4 py-3">
75
75
  <div className="flex items-center justify-between mb-2">
76
76
  <span className="text-xs font-semibold text-text-1 font-sans">Layer Coverage</span>
77
77
  <Badge variant={coverage.tone}>{coverage.label}</Badge>
@@ -86,7 +86,7 @@ export function NetworkStatus() {
86
86
  </div>
87
87
 
88
88
  {/* Models */}
89
- <div className="rounded-lg border border-border bg-surface-1 overflow-hidden">
89
+ <div className="rounded-sm border border-border bg-surface-1 overflow-hidden">
90
90
  <div className="px-4 py-2.5 border-b border-border-subtle">
91
91
  <span className="text-xs font-semibold text-text-1 font-sans">Models</span>
92
92
  </div>
@@ -111,7 +111,7 @@ export function NetworkStatus() {
111
111
  </div>
112
112
 
113
113
  {/* Nodes table */}
114
- <div className="rounded-lg border border-border bg-surface-1 overflow-hidden">
114
+ <div className="rounded-sm border border-border bg-surface-1 overflow-hidden">
115
115
  <button
116
116
  onClick={() => setNodesOpen((v) => !v)}
117
117
  className="w-full flex items-center gap-2 px-4 py-2.5 border-b border-border-subtle cursor-pointer hover:bg-surface-2/40 transition-colors"
@@ -29,7 +29,7 @@ export function NodeDetails() {
29
29
  const events = useGrooveStore((s) => s.networkEvents);
30
30
 
31
31
  return (
32
- <div className="flex flex-col rounded-lg border border-border bg-surface-1 overflow-hidden min-h-0">
32
+ <div className="flex flex-col rounded-sm border border-border bg-surface-1 overflow-hidden min-h-0">
33
33
  <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border-subtle">
34
34
  <Activity size={12} className="text-text-3" />
35
35
  <span className="text-xs font-semibold text-text-1 font-sans">Node Activity</span>
@@ -0,0 +1,70 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { ArrowUpCircle, Loader2 } from 'lucide-react';
3
+ import { Dialog, DialogContent } from './dialog';
4
+ import { Button } from './button';
5
+ import { useGrooveStore } from '../../stores/groove';
6
+
7
+ export function UpdateModal() {
8
+ const open = useGrooveStore((s) => s.updateModalOpen);
9
+ const setOpen = useGrooveStore((s) => s.setUpdateModalOpen);
10
+ const version = useGrooveStore((s) => s.version);
11
+ const updateReady = useGrooveStore((s) => s.updateReady);
12
+ const updateProgress = useGrooveStore((s) => s.updateProgress);
13
+ const installUpdate = useGrooveStore((s) => s.installUpdate);
14
+
15
+ const downloading = updateProgress && !updateReady;
16
+ const percent = downloading ? Math.max(0, Math.min(100, updateProgress.percent || 0)) : 100;
17
+
18
+ return (
19
+ <Dialog open={open} onOpenChange={setOpen}>
20
+ <DialogContent title="Update Available" description="Desktop app update">
21
+ <div className="px-5 py-4 flex flex-col gap-3">
22
+ <div className="flex items-center gap-3">
23
+ <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-accent/12">
24
+ <ArrowUpCircle size={20} className="text-accent" />
25
+ </div>
26
+ <div>
27
+ <p className="text-sm text-text-1 font-sans font-medium">
28
+ {downloading ? 'Downloading update\u2026' : `Ready to update`}
29
+ </p>
30
+ <p className="text-xs text-text-3 font-sans mt-0.5">
31
+ {version && <span className="font-mono">{version}</span>}
32
+ {version && updateReady && ' \u2192 '}
33
+ {updateReady && <span className="font-mono text-accent">{updateReady}</span>}
34
+ </p>
35
+ </div>
36
+ </div>
37
+ {downloading && (
38
+ <div className="flex items-center gap-2 mt-1">
39
+ <Loader2 size={12} className="animate-spin text-accent flex-shrink-0" />
40
+ <div className="flex-1 h-1.5 rounded-full bg-surface-3 overflow-hidden">
41
+ <div
42
+ className="h-full rounded-full bg-accent transition-all duration-500 ease-out"
43
+ style={{ width: `${percent}%` }}
44
+ />
45
+ </div>
46
+ <span className="text-2xs font-mono text-text-3 tabular-nums">{percent}%</span>
47
+ </div>
48
+ )}
49
+ {!downloading && (
50
+ <p className="text-xs text-text-3 font-sans leading-relaxed">
51
+ The app will restart to apply the update. Your work is saved automatically.
52
+ </p>
53
+ )}
54
+ </div>
55
+ <div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-border-subtle bg-surface-0">
56
+ <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>Later</Button>
57
+ <Button
58
+ variant="primary"
59
+ size="sm"
60
+ disabled={downloading}
61
+ onClick={() => { installUpdate(); setOpen(false); }}
62
+ >
63
+ <ArrowUpCircle size={12} />
64
+ Update &amp; Restart
65
+ </Button>
66
+ </div>
67
+ </DialogContent>
68
+ </Dialog>
69
+ );
70
+ }
@@ -104,6 +104,7 @@ export const useGrooveStore = create((set, get) => ({
104
104
  networkVersion: { installed: null, latest: null, updateAvailable: false },
105
105
  networkUpdateProgress: { updating: false, step: null, message: null, percent: 0, error: null },
106
106
  networkCompute: { totalRamMb: 0, totalVramMb: 0, totalCpuCores: 0, totalBandwidthMbps: 0, activeNodes: 0, totalNodes: 0, avgLoad: 0 },
107
+ networkSnapshots: [],
107
108
 
108
109
  // ── Marketplace Auth ───────────────────────────────────────
109
110
  marketplaceUser: null, // { id, displayName, avatar, ... } or null
@@ -122,6 +123,8 @@ export const useGrooveStore = create((set, get) => ({
122
123
  // ── Version / Auto-Update ──────────────────────────────────
123
124
  version: null,
124
125
  updateReady: null,
126
+ updateProgress: null,
127
+ updateModalOpen: false,
125
128
 
126
129
  // ── Toasts ────────────────────────────────────────────────
127
130
  toasts: [],
@@ -183,10 +186,14 @@ export const useGrooveStore = create((set, get) => ({
183
186
  if (data) set({ subscription: { ...get().subscription, ...data } });
184
187
  });
185
188
  }
189
+ if (window.groove?.update?.onUpdateProgress) {
190
+ window.groove.update.onUpdateProgress((data) => {
191
+ set({ updateProgress: data });
192
+ });
193
+ }
186
194
  if (window.groove?.update?.onUpdateDownloaded) {
187
195
  window.groove.update.onUpdateDownloaded((data) => {
188
- set({ updateReady: data.version });
189
- get().addToast('info', 'Update available', `v${data.version} downloaded — restart to apply`);
196
+ set({ updateReady: data.version, updateModalOpen: true, updateProgress: null });
190
197
  });
191
198
  }
192
199
  };
@@ -712,6 +719,24 @@ export const useGrooveStore = create((set, get) => ({
712
719
  };
713
720
  }
714
721
  set(nsUpdate);
722
+
723
+ // Push snapshot for activity chart
724
+ const wsNodes = nsData.nodes || [];
725
+ const wsOwnId = get().networkNode.nodeId;
726
+ const wsOwn = wsOwnId ? wsNodes.find((n) => (n.node_id || n.nodeId) === wsOwnId) : null;
727
+ const wsActive = wsNodes.filter((n) => n.status === 'active');
728
+ const wsSnap = {
729
+ t: Date.now(),
730
+ globalSessions: nsData.activeSessions || 0,
731
+ mySessions: wsOwn?.active_sessions ?? wsOwn?.sessions ?? 0,
732
+ nodeCount: wsActive.length,
733
+ avgLoad: wsActive.length > 0 ? wsActive.reduce((s, n) => s + (n.load || 0), 0) / wsActive.length : 0,
734
+ myLoad: wsOwn?.load ?? 0,
735
+ };
736
+ let wsSnapshots = [...get().networkSnapshots, wsSnap];
737
+ if (wsSnapshots.length > 100) wsSnapshots = wsSnapshots.slice(-100);
738
+ set({ networkSnapshots: wsSnapshots });
739
+
715
740
  break;
716
741
  }
717
742
 
@@ -842,7 +867,6 @@ export const useGrooveStore = create((set, get) => ({
842
867
  arr.push({ from: 'assistant', text, timestamp: Date.now() });
843
868
  }
844
869
  msgs[conversationId] = arr.slice(-200);
845
- persistJSON('groove:conversationMessages', msgs);
846
870
  return { conversationMessages: msgs, streamingConversationId: conversationId };
847
871
  });
848
872
  break;
@@ -850,10 +874,10 @@ export const useGrooveStore = create((set, get) => ({
850
874
 
851
875
  case 'conversation:complete': {
852
876
  const { conversationId } = msg.data || msg;
853
- if (conversationId) {
877
+ if (conversationId && get().streamingConversationId === conversationId) {
854
878
  set({ sendingMessage: false, streamingConversationId: null });
855
- persistJSON('groove:conversationMessages', get().conversationMessages);
856
879
  }
880
+ if (conversationId) persistJSON('groove:conversationMessages', get().conversationMessages);
857
881
  break;
858
882
  }
859
883
 
@@ -865,7 +889,8 @@ export const useGrooveStore = create((set, get) => ({
865
889
  if (!msgs[conversationId]) msgs[conversationId] = [];
866
890
  msgs[conversationId] = [...msgs[conversationId], { from: 'system', text: `Error: ${error || 'Unknown error'}`, timestamp: Date.now() }];
867
891
  persistJSON('groove:conversationMessages', msgs);
868
- return { conversationMessages: msgs, sendingMessage: false, streamingConversationId: null };
892
+ const isActive = s.streamingConversationId === conversationId;
893
+ return { conversationMessages: msgs, sendingMessage: isActive ? false : s.sendingMessage, streamingConversationId: isActive ? null : s.streamingConversationId };
869
894
  });
870
895
  }
871
896
  break;
@@ -1052,6 +1077,12 @@ export const useGrooveStore = create((set, get) => ({
1052
1077
  installUpdate() {
1053
1078
  window.groove?.update?.installUpdate();
1054
1079
  },
1080
+ setUpdateModalOpen(open) {
1081
+ set({ updateModalOpen: open });
1082
+ },
1083
+ checkForUpdate() {
1084
+ window.groove?.update?.checkForUpdate();
1085
+ },
1055
1086
 
1056
1087
  // ── Marketplace Auth ────────────────────────────────────────
1057
1088
 
@@ -1699,6 +1730,15 @@ export const useGrooveStore = create((set, get) => ({
1699
1730
  }
1700
1731
  },
1701
1732
 
1733
+ async setConversationModel(id, provider, model) {
1734
+ try {
1735
+ const conv = await api.patch(`/conversations/${encodeURIComponent(id)}`, { provider, model });
1736
+ set((s) => ({ conversations: s.conversations.map((c) => c.id === id ? { ...c, ...conv } : c) }));
1737
+ } catch (err) {
1738
+ get().addToast('error', 'Model change failed', err.message);
1739
+ }
1740
+ },
1741
+
1702
1742
  async stopChatStreaming(conversationId) {
1703
1743
  try {
1704
1744
  await api.post(`/conversations/${encodeURIComponent(conversationId)}/stop`);
@@ -2106,6 +2146,26 @@ export const useGrooveStore = create((set, get) => ({
2106
2146
  };
2107
2147
  }
2108
2148
  set(update);
2149
+
2150
+ // Push snapshot for activity chart
2151
+ if (data) {
2152
+ const ownId = get().networkNode.nodeId;
2153
+ const nodes = data.nodes || [];
2154
+ const ownNode = ownId ? nodes.find((n) => (n.node_id || n.nodeId) === ownId) : null;
2155
+ const activeNodes = nodes.filter((n) => n.status === 'active');
2156
+ const snap = {
2157
+ t: Date.now(),
2158
+ globalSessions: data.activeSessions || 0,
2159
+ mySessions: ownNode?.active_sessions ?? ownNode?.sessions ?? 0,
2160
+ nodeCount: activeNodes.length,
2161
+ avgLoad: activeNodes.length > 0 ? activeNodes.reduce((s, n) => s + (n.load || 0), 0) / activeNodes.length : 0,
2162
+ myLoad: ownNode?.load ?? 0,
2163
+ };
2164
+ let snapshots = [...get().networkSnapshots, snap];
2165
+ if (snapshots.length > 100) snapshots = snapshots.slice(-100);
2166
+ set({ networkSnapshots: snapshots });
2167
+ }
2168
+
2109
2169
  return data;
2110
2170
  } catch {
2111
2171
  set({ networkStatusReachable: false });