groove-dev 0.27.57 → 0.27.59

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 (46) 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 +126 -7
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +2 -5
  5. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -1
  6. package/node_modules/@groove-dev/gui/dist/assets/index-BrfCzrxJ.css +1 -0
  7. package/{packages/gui/dist/assets/index-X58BAjGp.js → node_modules/@groove-dev/gui/dist/assets/index-BycOlqLx.js} +1742 -1742
  8. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  9. package/node_modules/@groove-dev/gui/package.json +1 -1
  10. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +1 -1
  11. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +1 -1
  12. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +49 -24
  13. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +9 -36
  14. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -2
  15. package/node_modules/@groove-dev/gui/src/components/network/activity-stream.jsx +105 -0
  16. package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +166 -0
  17. package/node_modules/@groove-dev/gui/src/components/network/fleet-table.jsx +190 -0
  18. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +135 -0
  19. package/node_modules/@groove-dev/gui/src/components/network/node-toggle.jsx +1 -1
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +57 -4
  21. package/node_modules/@groove-dev/gui/src/views/network.jsx +128 -55
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/api.js +126 -7
  26. package/packages/daemon/src/conversations.js +2 -5
  27. package/packages/daemon/src/providers/groove-network.js +1 -1
  28. package/packages/gui/dist/assets/index-BrfCzrxJ.css +1 -0
  29. package/{node_modules/@groove-dev/gui/dist/assets/index-X58BAjGp.js → packages/gui/dist/assets/index-BycOlqLx.js} +1742 -1742
  30. package/packages/gui/dist/index.html +2 -2
  31. package/packages/gui/package.json +1 -1
  32. package/packages/gui/src/components/chat/chat-header.jsx +1 -1
  33. package/packages/gui/src/components/chat/chat-input.jsx +1 -1
  34. package/packages/gui/src/components/chat/chat-messages.jsx +49 -24
  35. package/packages/gui/src/components/chat/chat-view.jsx +9 -36
  36. package/packages/gui/src/components/layout/activity-bar.jsx +2 -2
  37. package/packages/gui/src/components/network/activity-stream.jsx +105 -0
  38. package/packages/gui/src/components/network/compute-header.jsx +166 -0
  39. package/packages/gui/src/components/network/fleet-table.jsx +190 -0
  40. package/packages/gui/src/components/network/network-health.jsx +135 -0
  41. package/packages/gui/src/components/network/node-toggle.jsx +1 -1
  42. package/packages/gui/src/stores/groove.js +57 -4
  43. package/packages/gui/src/views/network.jsx +128 -55
  44. package/ai-chat/CHAT_MASTER_PLAN.md +0 -184
  45. package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +0 -1
  46. package/packages/gui/dist/assets/index-C5WTeZO4.css +0 -1
@@ -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-X58BAjGp.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-BycOlqLx.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-C5WTeZO4.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BrfCzrxJ.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.57",
3
+ "version": "0.27.59",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -50,7 +50,7 @@ export function ChatHeader({ conversation, model, onModelChange, onModeChange })
50
50
  const mode = conversation.mode || 'api';
51
51
 
52
52
  return (
53
- <div className="h-11 flex items-center gap-3 px-4 border-b border-border bg-surface-1 flex-shrink-0">
53
+ <div className="h-11 flex items-center gap-3 px-4 border-b border-border-subtle bg-surface-0/80 flex-shrink-0">
54
54
  <Hash size={14} className="text-text-4 flex-shrink-0" />
55
55
 
56
56
  {editing ? (
@@ -53,7 +53,7 @@ export function ChatInput({ onSend, onStop, sending, streaming, disabled }) {
53
53
 
54
54
  return (
55
55
  <div className="px-4 py-3">
56
- <div className="flex items-end gap-2 rounded-2xl bg-surface-3/60 border border-border-subtle px-3 py-2 focus-within:border-accent/30 transition-colors">
56
+ <div className="flex items-end gap-2 rounded-2xl bg-surface-1/80 border border-accent/8 px-3 py-2 focus-within:border-accent/30 transition-colors">
57
57
  <input
58
58
  ref={fileInputRef}
59
59
  type="file"
@@ -1,10 +1,17 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useRef, useEffect, useState } from 'react';
3
- import { Copy, Check, ArrowRight, MessageCircle, Sparkles } from 'lucide-react';
3
+ import { Copy, Check, ArrowRight } from 'lucide-react';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { timeAgo } from '../../lib/format';
6
6
  import { ThinkingIndicator } from '../ui/thinking-indicator';
7
7
 
8
+ const API_STATUS_MESSAGES = [
9
+ 'Generating response...',
10
+ 'Processing...',
11
+ 'Thinking...',
12
+ 'Almost there...',
13
+ ];
14
+
8
15
  function CopyButton({ text }) {
9
16
  const [copied, setCopied] = useState(false);
10
17
  function handleCopy() {
@@ -242,7 +249,7 @@ function UserMessage({ msg }) {
242
249
  return (
243
250
  <div className="flex justify-end">
244
251
  <div className="max-w-[75%]">
245
- <div className="px-4 py-3 rounded-2xl rounded-br-md bg-accent/10 border border-accent/15">
252
+ <div className="px-4 py-3 rounded-xl bg-surface-3/80 border border-border-subtle">
246
253
  <p className="text-sm text-text-0 font-sans whitespace-pre-wrap break-words leading-relaxed">{msg.text}</p>
247
254
  </div>
248
255
  <div className="text-2xs text-text-4 font-sans mt-1 text-right">{timeAgo(msg.timestamp)}</div>
@@ -253,17 +260,12 @@ function UserMessage({ msg }) {
253
260
 
254
261
  function AssistantMessage({ msg, model }) {
255
262
  return (
256
- <div className="flex gap-3">
257
- <div className="w-7 h-7 rounded-full bg-surface-4 border border-border-subtle flex items-center justify-center flex-shrink-0 mt-0.5">
258
- <Sparkles size={13} className="text-accent" />
259
- </div>
260
- <div className="flex-1 min-w-0 max-w-[85%]">
261
- {model && <div className="text-2xs text-text-3 font-sans mb-1 font-medium">{model}</div>}
262
- <div className="px-4 py-3 rounded-2xl rounded-bl-md bg-surface-4 border border-border-subtle">
263
- <RenderedMarkdown text={msg.text} />
264
- </div>
265
- <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
263
+ <div className="max-w-[85%]">
264
+ {model && <div className="text-2xs text-text-3 font-sans mb-1 font-medium">{model}</div>}
265
+ <div className="border-l-2 border-accent/30 pl-3.5">
266
+ <RenderedMarkdown text={msg.text} />
266
267
  </div>
268
+ <div className="text-2xs text-text-4 font-sans mt-1">{timeAgo(msg.timestamp)}</div>
267
269
  </div>
268
270
  );
269
271
  }
@@ -288,18 +290,42 @@ function StreamingCursor() {
288
290
  function WelcomeMessage() {
289
291
  return (
290
292
  <div className="flex flex-col items-center justify-center h-full text-center py-16">
291
- <div className="w-16 h-16 rounded-full bg-accent/8 flex items-center justify-center mb-5">
292
- <MessageCircle size={28} className="text-accent" />
293
+ <p className="text-sm text-text-3 font-sans">Send a message to start</p>
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function ApiTypingIndicator() {
299
+ const [idx, setIdx] = useState(0);
300
+ const [fade, setFade] = useState(true);
301
+
302
+ useEffect(() => {
303
+ const t = setInterval(() => {
304
+ setFade(false);
305
+ setTimeout(() => {
306
+ setIdx((i) => (i + 1) % API_STATUS_MESSAGES.length);
307
+ setFade(true);
308
+ }, 250);
309
+ }, 2800);
310
+ return () => clearInterval(t);
311
+ }, []);
312
+
313
+ return (
314
+ <div className="border-l-2 border-accent/30 pl-3.5 py-1 flex items-center gap-2.5">
315
+ <div className="relative w-3.5 h-3.5 flex-shrink-0">
316
+ <span className="absolute inset-0 rounded-full border border-transparent border-t-accent animate-spin" style={{ animationDuration: '0.9s' }} />
293
317
  </div>
294
- <h2 className="text-xl font-bold text-text-0 font-sans mb-2">Start a conversation</h2>
295
- <p className="text-sm text-text-2 font-sans max-w-sm leading-relaxed">
296
- Send a message to begin. Your conversation history is saved locally and syncs across sessions.
297
- </p>
318
+ <span
319
+ className="text-[12px] font-sans text-text-3 transition-opacity duration-[250ms]"
320
+ style={{ opacity: fade ? 1 : 0 }}
321
+ >
322
+ {API_STATUS_MESSAGES[idx]}
323
+ </span>
298
324
  </div>
299
325
  );
300
326
  }
301
327
 
302
- export function ChatMessages({ messages, isStreaming, model }) {
328
+ export function ChatMessages({ messages, isStreaming, model, mode }) {
303
329
  const scrollRef = useRef(null);
304
330
  const isAtBottomRef = useRef(true);
305
331
 
@@ -335,12 +361,11 @@ export function ChatMessages({ messages, isStreaming, model }) {
335
361
  return <AssistantMessage key={i} msg={msg} model={model} />;
336
362
  })}
337
363
  {isStreaming && (
338
- <div className="flex gap-3">
339
- <div className="w-7 h-7 rounded-full bg-surface-4 border border-border-subtle flex items-center justify-center flex-shrink-0">
340
- <Sparkles size={13} className="text-accent" />
341
- </div>
364
+ mode === 'agent' ? (
342
365
  <ThinkingIndicator className="py-1" />
343
- </div>
366
+ ) : (
367
+ <ApiTypingIndicator />
368
+ )
344
369
  )}
345
370
  </div>
346
371
  );
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useCallback } from 'react';
3
- import { MessageCircle, Plus, Sparkles, Zap } from 'lucide-react';
3
+ import { Plus } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { ConversationList } from './conversation-list';
@@ -11,44 +11,16 @@ import { ChatInput } from './chat-input';
11
11
  function EmptyState({ onNewChat }) {
12
12
  return (
13
13
  <div className="flex-1 flex items-center justify-center">
14
- <div className="max-w-md w-full text-center space-y-8 px-8">
15
- <div className="relative mx-auto w-20 h-20">
16
- <div className="absolute inset-0 rounded-full bg-accent/8 animate-pulse" />
17
- <div className="absolute inset-1 rounded-full bg-surface-3 border border-border-subtle flex items-center justify-center shadow-lg shadow-accent/5">
18
- <MessageCircle size={32} className="text-accent" />
19
- </div>
20
- </div>
21
-
22
- <div className="space-y-3">
23
- <h1 className="text-2xl font-bold text-text-0 font-sans tracking-tight">Groove Chat</h1>
24
- <p className="text-sm text-text-2 font-sans max-w-sm mx-auto leading-relaxed">
25
- A command center disguised as a conversation. Every provider, every model, full project context.
26
- </p>
27
- </div>
28
-
14
+ <div className="text-center space-y-4">
15
+ <h1 className="text-lg font-semibold text-text-1 font-sans">Groove Chat</h1>
16
+ <p className="text-sm text-text-3 font-sans">Every provider, every model, full project context.</p>
29
17
  <button
30
18
  onClick={onNewChat}
31
- className="inline-flex items-center gap-2 h-10 px-6 rounded-lg bg-accent/15 text-accent text-sm font-semibold font-sans hover:bg-accent/25 transition-colors cursor-pointer border border-accent/20"
19
+ className="inline-flex items-center gap-2 h-9 px-5 rounded-lg bg-accent/15 text-accent text-sm font-semibold font-sans hover:bg-accent/25 transition-colors cursor-pointer border border-accent/20"
32
20
  >
33
- <Plus size={16} />
21
+ <Plus size={14} />
34
22
  New Chat
35
23
  </button>
36
-
37
- <div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
38
- <div className="flex items-center gap-2 p-3 rounded-lg bg-surface-3 border border-border-subtle">
39
- <Sparkles size={14} className="text-purple flex-shrink-0" />
40
- <span className="text-2xs text-text-2 font-sans">Multi-model routing</span>
41
- </div>
42
- <div className="flex items-center gap-2 p-3 rounded-lg bg-surface-3 border border-border-subtle">
43
- <Zap size={14} className="text-warning flex-shrink-0" />
44
- <span className="text-2xs text-text-2 font-sans">Streaming responses</span>
45
- </div>
46
- </div>
47
-
48
- <p className="text-xs text-text-4 font-sans">
49
- <kbd className="font-mono bg-surface-4 px-1.5 py-0.5 rounded text-text-3">Cmd+Shift+N</kbd>
50
- <span className="mx-1.5">new chat</span>
51
- </p>
52
24
  </div>
53
25
  </div>
54
26
  );
@@ -113,10 +85,10 @@ export function ChatView() {
113
85
  : null;
114
86
 
115
87
  return (
116
- <div className="flex h-full bg-surface-2">
88
+ <div className="flex h-full bg-surface-0">
117
89
  {/* Conversation sidebar */}
118
90
  <div className={cn(
119
- 'flex-shrink-0 border-r border-border bg-surface-1 transition-all duration-200 overflow-hidden',
91
+ 'flex-shrink-0 border-r border-accent/12 bg-surface-1 transition-all duration-200 overflow-hidden',
120
92
  sidebarCollapsed ? 'w-0' : 'w-64',
121
93
  )}>
122
94
  <ConversationList onNewChat={() => handleNewChat()} />
@@ -131,6 +103,7 @@ export function ChatView() {
131
103
  messages={messages}
132
104
  isStreaming={isStreaming}
133
105
  model={activeConversation.model}
106
+ mode={activeConversation.mode || 'api'}
134
107
  />
135
108
  <ChatInput
136
109
  onSend={handleSend}
@@ -7,13 +7,13 @@ import { isElectron, getPlatform } from '../../lib/electron';
7
7
 
8
8
  const BASE_NAV_ITEMS = [
9
9
  { id: 'agents', icon: Network, label: 'Agents' },
10
+ { id: 'chat', icon: MessageCircle, label: 'Chat' },
10
11
  { id: 'editor', icon: Code2, label: 'Editor' },
11
12
  { id: 'dashboard', icon: ChartSpline, label: 'Dashboard' },
13
+ { id: 'teams', icon: Users, label: 'Teams' },
12
14
  { id: 'marketplace', icon: Puzzle, label: 'Marketplace' },
13
15
  { id: 'toys', icon: Gamepad2, label: 'Toys' },
14
16
  { id: 'models', icon: Box, label: 'Models' },
15
- { id: 'teams', icon: Users, label: 'Teams' },
16
- { id: 'chat', icon: MessageCircle, label: 'Chat' },
17
17
  ];
18
18
 
19
19
  const NETWORK_NAV_ITEM = { id: 'network', icon: Globe, label: 'Network' };
@@ -0,0 +1,105 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { memo, useState, useEffect, useRef } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { cn } from '../../lib/cn';
5
+ import { HEX } from '../../lib/theme-hex';
6
+ import { ScrollArea } from '../ui/scroll-area';
7
+
8
+ const FILTERS = ['All', 'Sessions', 'Errors', 'Connections'];
9
+
10
+ const LEVEL_FILTER = {
11
+ Sessions: ['session'],
12
+ Errors: ['error', 'warning'],
13
+ Connections: ['connected', 'disconnected'],
14
+ };
15
+
16
+ const LEVEL_COLOR = {
17
+ info: HEX.text3,
18
+ success: HEX.success,
19
+ warning: HEX.warning,
20
+ error: HEX.danger,
21
+ connected: HEX.success,
22
+ disconnected: HEX.warning,
23
+ session: HEX.accent,
24
+ };
25
+
26
+ function fmtTime(ts) {
27
+ if (!ts) return '--:--:--';
28
+ const d = new Date(ts);
29
+ return d.toLocaleTimeString('en-GB', { hour12: false });
30
+ }
31
+
32
+ function levelTag(level) {
33
+ const tags = {
34
+ info: 'info', success: ' ok ', warning: 'warn',
35
+ error: 'ERR!', connected: 'conn', disconnected: 'disc', session: 'sess',
36
+ };
37
+ return tags[level] || level || 'info';
38
+ }
39
+
40
+ export const ActivityStream = memo(function ActivityStream() {
41
+ const events = useGrooveStore((s) => s.networkEvents);
42
+ const [filter, setFilter] = useState('All');
43
+ const bottomRef = useRef(null);
44
+
45
+ const filtered = filter === 'All'
46
+ ? events
47
+ : events.filter((ev) => {
48
+ const level = ev.level || ev.type || 'info';
49
+ return (LEVEL_FILTER[filter] || []).includes(level);
50
+ });
51
+
52
+ const display = filtered.slice(-200);
53
+
54
+ useEffect(() => {
55
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
56
+ }, [display.length]);
57
+
58
+ return (
59
+ <div className="flex flex-col h-full">
60
+ <div className="flex items-center gap-2 px-3 pt-2.5 pb-1 flex-shrink-0">
61
+ <span className="text-2xs font-mono text-text-3 uppercase tracking-widest">Activity</span>
62
+ <div className="flex-1" />
63
+ <div className="flex items-center gap-0.5">
64
+ {FILTERS.map((f) => (
65
+ <button
66
+ key={f}
67
+ onClick={() => setFilter(f)}
68
+ className={cn(
69
+ 'px-2 py-0.5 text-2xs font-mono rounded-sm transition-colors cursor-pointer',
70
+ filter === f
71
+ ? 'bg-[rgba(51,175,188,0.15)] text-accent'
72
+ : 'bg-surface-4 text-text-3 hover:text-text-2',
73
+ )}
74
+ >
75
+ {f}
76
+ </button>
77
+ ))}
78
+ </div>
79
+ </div>
80
+
81
+ <ScrollArea className="flex-1 min-h-0">
82
+ {display.length === 0 ? (
83
+ <div className="px-3 py-6 text-2xs font-mono text-text-4 text-center">
84
+ No events yet — toggle your node on to start.
85
+ </div>
86
+ ) : (
87
+ <div className="px-2 py-1">
88
+ {display.map((ev, i) => {
89
+ const level = ev.level || ev.type || 'info';
90
+ const color = LEVEL_COLOR[level] || HEX.text3;
91
+ return (
92
+ <div key={i} className="flex items-start gap-0 font-mono text-2xs leading-relaxed">
93
+ <span className="text-text-4 flex-shrink-0 w-[62px]">[{fmtTime(ev.timestamp || ev.ts)}]</span>
94
+ <span className="flex-shrink-0 w-[36px]" style={{ color }}>{levelTag(level)}</span>
95
+ <span className="text-text-2 break-words min-w-0">{ev.msg || ev.message || ev.text || 'event'}</span>
96
+ </div>
97
+ );
98
+ })}
99
+ <div ref={bottomRef} />
100
+ </div>
101
+ )}
102
+ </ScrollArea>
103
+ </div>
104
+ );
105
+ });
@@ -0,0 +1,166 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { memo } from 'react';
3
+ import { useGrooveStore } from '../../stores/groove';
4
+ import { cn } from '../../lib/cn';
5
+ import { HEX } from '../../lib/theme-hex';
6
+ import { Tooltip } from '../ui/tooltip';
7
+ import { HelpCircle } from 'lucide-react';
8
+
9
+ const BAR_WIDTH = 28;
10
+
11
+ function gaugeColor(ratio) {
12
+ if (ratio > 0.9) return HEX.danger;
13
+ if (ratio > 0.7) return HEX.warning;
14
+ return HEX.success;
15
+ }
16
+
17
+ function fmtMbToGb(mb) {
18
+ if (!mb) return '0';
19
+ return (mb / 1024).toFixed(1);
20
+ }
21
+
22
+ function AsciiBar({ label, value, max, unit, nodeCount }) {
23
+ const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
24
+ const filled = Math.round(ratio * BAR_WIDTH);
25
+ const empty = BAR_WIDTH - filled;
26
+ const bar = '\u2502'.repeat(filled) + '\u2500'.repeat(empty);
27
+ const color = gaugeColor(ratio);
28
+
29
+ let displayVal, displayMax;
30
+ if (unit === 'GB') {
31
+ displayVal = fmtMbToGb(value);
32
+ displayMax = fmtMbToGb(max);
33
+ } else if (unit === 'cores' || unit === 'Mbps') {
34
+ displayVal = Math.round(value);
35
+ displayMax = Math.round(max);
36
+ } else {
37
+ displayVal = value.toFixed(1);
38
+ displayMax = max.toFixed(1);
39
+ }
40
+
41
+ return (
42
+ <div className="flex items-center gap-2 font-mono text-xs leading-tight">
43
+ <span className="w-[40px] text-right text-text-3 uppercase text-2xs tracking-wider flex-shrink-0">
44
+ {label}
45
+ </span>
46
+ <span className="text-text-4">[</span>
47
+ <span style={{ color: ratio > 0 ? color : undefined }} className={cn('whitespace-pre', !ratio && 'text-text-4')}>
48
+ {bar}
49
+ </span>
50
+ <span className="text-text-4">]</span>
51
+ <span className="text-text-1 tabular-nums whitespace-nowrap text-2xs">
52
+ {displayVal} / {displayMax} {unit}
53
+ </span>
54
+ {nodeCount != null && (
55
+ <span className="text-text-4 text-2xs whitespace-nowrap">({nodeCount} nodes)</span>
56
+ )}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function MiniSparkline({ data, color = HEX.accent, width = 72, height = 22 }) {
62
+ if (!data || data.length < 2) return <div style={{ width, height }} />;
63
+ const vals = data.map((d) => (typeof d === 'number' ? d : d.v));
64
+ const min = Math.min(...vals);
65
+ const max = Math.max(...vals);
66
+ const range = max - min || 1;
67
+ const points = vals.map((v, i) => {
68
+ const x = (i / (vals.length - 1)) * width;
69
+ const y = height - ((v - min) / range) * (height - 2) - 1;
70
+ return `${x},${y}`;
71
+ }).join(' ');
72
+ const gradId = `net-${color.replace('#', '')}`;
73
+ return (
74
+ <svg width={width} height={height} className="flex-shrink-0">
75
+ <defs>
76
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
77
+ <stop offset="0%" stopColor={color} stopOpacity="0.2" />
78
+ <stop offset="100%" stopColor={color} stopOpacity="0" />
79
+ </linearGradient>
80
+ </defs>
81
+ <polygon points={`0,${height} ${points} ${width},${height}`} fill={`url(#${gradId})`} />
82
+ <polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeOpacity="0.8" />
83
+ </svg>
84
+ );
85
+ }
86
+
87
+ function KpiCard({ label, value, color = HEX.accent, hint, className }) {
88
+ return (
89
+ <div className={cn('flex items-center gap-2.5 px-3 py-2.5 min-w-0 bg-surface-1', className)}>
90
+ <div className="flex-1 min-w-0">
91
+ <div className="text-2xs font-mono text-text-3 uppercase tracking-wider mb-0.5 truncate flex items-center gap-1">
92
+ {label}
93
+ {hint && (
94
+ <Tooltip content={<span className="max-w-[220px] block leading-relaxed">{hint}</span>} side="bottom">
95
+ <HelpCircle size={10} className="text-text-4 hover:text-text-2 cursor-help flex-shrink-0 transition-colors" />
96
+ </Tooltip>
97
+ )}
98
+ </div>
99
+ <div className="text-base font-semibold font-mono text-text-0 tabular-nums leading-none">{value}</div>
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ const MAX_RAM_MB = 256 * 1024;
106
+ const MAX_VRAM_MB = 128 * 1024;
107
+ const MAX_CPU = 128;
108
+ const MAX_LOAD = 4.0;
109
+
110
+ export const ComputeHeader = memo(function ComputeHeader() {
111
+ const compute = useGrooveStore((s) => s.networkCompute);
112
+ const nodes = useGrooveStore((s) => s.networkStatus.nodes || []);
113
+ const models = useGrooveStore((s) => s.networkStatus.models || []);
114
+ const allZero = !compute.totalRamMb && !compute.totalVramMb && !compute.totalCpuCores;
115
+
116
+ const activeNodes = nodes.filter((n) => n.status === 'active');
117
+ const avgGpuUtil = activeNodes.length > 0
118
+ ? activeNodes.reduce((s, n) => s + (n.gpu_utilization_pct || 0), 0) / activeNodes.length
119
+ : 0;
120
+ const gpuColor = avgGpuUtil > 80 ? HEX.danger : avgGpuUtil > 50 ? HEX.warning : HEX.success;
121
+ const loadColor = compute.avgLoad > 2.0 ? HEX.danger : compute.avgLoad > 1.0 ? HEX.warning : HEX.success;
122
+ const activeModel = models.length > 0
123
+ ? (typeof models[0] === 'string' ? models[0] : models[0].name)
124
+ : 'google/gemma-3-4b';
125
+
126
+ const kpis = [
127
+ { label: 'RAM', value: `${fmtMbToGb(compute.totalRamMb)} GB`, color: HEX.accent, hint: 'Total RAM across all network nodes.' },
128
+ { label: 'VRAM', value: `${fmtMbToGb(compute.totalVramMb)} GB`, color: HEX.info, hint: 'Total GPU VRAM across all network nodes.' },
129
+ { label: 'CPU Cores', value: `${compute.totalCpuCores}`, color: HEX.purple, hint: 'Total CPU cores across all network nodes.' },
130
+ { label: 'GPU Util', value: avgGpuUtil > 0 ? `${Math.round(avgGpuUtil)}%` : '--', color: gpuColor, hint: 'Average GPU utilization across active nodes. Green <50%, yellow 50-80%, red >80%.' },
131
+ { label: 'Nodes', value: `${compute.activeNodes}/${compute.totalNodes}`, color: HEX.accent, hint: 'Active nodes out of total registered.' },
132
+ { label: 'Load', value: compute.avgLoad > 0 ? compute.avgLoad.toFixed(2) : '0.00', color: loadColor, hint: 'Average load across active nodes. Green <1.0, yellow 1.0-2.0, red >2.0.' },
133
+ { label: 'Model', value: activeModel, color: HEX.info, hint: 'Active inference model on the network.' },
134
+ ];
135
+
136
+ return (
137
+ <div className="flex-shrink-0">
138
+ <div className="flex flex-wrap border-b border-border" style={{ background: 'var(--color-surface-0)' }}>
139
+ {kpis.map((kpi) => (
140
+ <KpiCard
141
+ key={kpi.label}
142
+ label={kpi.label}
143
+ value={kpi.value}
144
+ color={kpi.color}
145
+ hint={kpi.hint}
146
+ className={cn('flex-1 basis-[14.2%] min-w-[110px]', 'border-b border-r border-border')}
147
+ />
148
+ ))}
149
+ </div>
150
+
151
+ <div className="bg-surface-1 border-b border-border px-4 py-2.5">
152
+ {allZero ? (
153
+ <div className="text-2xs font-mono text-text-4">Waiting for network data...</div>
154
+ ) : (
155
+ <div className="flex flex-col gap-0.5">
156
+ <AsciiBar label="RAM" value={compute.totalRamMb} max={MAX_RAM_MB} unit="GB" nodeCount={compute.totalNodes} />
157
+ <AsciiBar label="VRAM" value={compute.totalVramMb} max={MAX_VRAM_MB} unit="GB" nodeCount={compute.totalNodes} />
158
+ <AsciiBar label="CPU" value={compute.totalCpuCores} max={MAX_CPU} unit="cores" />
159
+ <AsciiBar label="GPU%" value={avgGpuUtil} max={100} unit="%" />
160
+ <AsciiBar label="LOAD" value={compute.avgLoad} max={MAX_LOAD} unit="" />
161
+ </div>
162
+ )}
163
+ </div>
164
+ </div>
165
+ );
166
+ });