groove-dev 0.8.0

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 (84) hide show
  1. package/CLAUDE.md +197 -0
  2. package/LICENSE +40 -0
  3. package/README.md +115 -0
  4. package/docs/GUI_DESIGN_SPEC.md +402 -0
  5. package/favicon.png +0 -0
  6. package/groove-logo-short.png +0 -0
  7. package/groove-logo.png +0 -0
  8. package/package.json +70 -0
  9. package/packages/cli/bin/groove.js +98 -0
  10. package/packages/cli/package.json +15 -0
  11. package/packages/cli/src/client.js +25 -0
  12. package/packages/cli/src/commands/agents.js +38 -0
  13. package/packages/cli/src/commands/approve.js +50 -0
  14. package/packages/cli/src/commands/config.js +35 -0
  15. package/packages/cli/src/commands/kill.js +15 -0
  16. package/packages/cli/src/commands/nuke.js +19 -0
  17. package/packages/cli/src/commands/providers.js +40 -0
  18. package/packages/cli/src/commands/rotate.js +16 -0
  19. package/packages/cli/src/commands/spawn.js +91 -0
  20. package/packages/cli/src/commands/start.js +31 -0
  21. package/packages/cli/src/commands/status.js +38 -0
  22. package/packages/cli/src/commands/stop.js +15 -0
  23. package/packages/cli/src/commands/team.js +77 -0
  24. package/packages/daemon/package.json +18 -0
  25. package/packages/daemon/src/adaptive.js +237 -0
  26. package/packages/daemon/src/api.js +533 -0
  27. package/packages/daemon/src/classifier.js +126 -0
  28. package/packages/daemon/src/credentials.js +121 -0
  29. package/packages/daemon/src/firstrun.js +93 -0
  30. package/packages/daemon/src/index.js +208 -0
  31. package/packages/daemon/src/introducer.js +238 -0
  32. package/packages/daemon/src/journalist.js +600 -0
  33. package/packages/daemon/src/lockmanager.js +58 -0
  34. package/packages/daemon/src/pm.js +108 -0
  35. package/packages/daemon/src/process.js +361 -0
  36. package/packages/daemon/src/providers/aider.js +72 -0
  37. package/packages/daemon/src/providers/base.js +38 -0
  38. package/packages/daemon/src/providers/claude-code.js +167 -0
  39. package/packages/daemon/src/providers/codex.js +68 -0
  40. package/packages/daemon/src/providers/gemini.js +62 -0
  41. package/packages/daemon/src/providers/index.js +38 -0
  42. package/packages/daemon/src/providers/ollama.js +94 -0
  43. package/packages/daemon/src/registry.js +89 -0
  44. package/packages/daemon/src/rotator.js +185 -0
  45. package/packages/daemon/src/router.js +132 -0
  46. package/packages/daemon/src/state.js +34 -0
  47. package/packages/daemon/src/supervisor.js +178 -0
  48. package/packages/daemon/src/teams.js +203 -0
  49. package/packages/daemon/src/terminal/base.js +27 -0
  50. package/packages/daemon/src/terminal/generic.js +27 -0
  51. package/packages/daemon/src/terminal/tmux.js +64 -0
  52. package/packages/daemon/src/tokentracker.js +124 -0
  53. package/packages/daemon/src/validate.js +122 -0
  54. package/packages/daemon/templates/api-builder.json +18 -0
  55. package/packages/daemon/templates/fullstack.json +18 -0
  56. package/packages/daemon/templates/monorepo.json +24 -0
  57. package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
  58. package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
  59. package/packages/gui/dist/favicon.png +0 -0
  60. package/packages/gui/dist/groove-logo-short.png +0 -0
  61. package/packages/gui/dist/groove-logo.png +0 -0
  62. package/packages/gui/dist/index.html +13 -0
  63. package/packages/gui/index.html +12 -0
  64. package/packages/gui/package.json +22 -0
  65. package/packages/gui/public/favicon.png +0 -0
  66. package/packages/gui/public/groove-logo-short.png +0 -0
  67. package/packages/gui/public/groove-logo.png +0 -0
  68. package/packages/gui/src/App.jsx +215 -0
  69. package/packages/gui/src/components/AgentActions.jsx +347 -0
  70. package/packages/gui/src/components/AgentChat.jsx +479 -0
  71. package/packages/gui/src/components/AgentNode.jsx +117 -0
  72. package/packages/gui/src/components/AgentPanel.jsx +115 -0
  73. package/packages/gui/src/components/AgentStats.jsx +333 -0
  74. package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
  75. package/packages/gui/src/components/EmptyState.jsx +100 -0
  76. package/packages/gui/src/components/SpawnPanel.jsx +515 -0
  77. package/packages/gui/src/components/TeamSelector.jsx +162 -0
  78. package/packages/gui/src/main.jsx +9 -0
  79. package/packages/gui/src/stores/groove.js +247 -0
  80. package/packages/gui/src/theme.css +67 -0
  81. package/packages/gui/src/views/AgentTree.jsx +148 -0
  82. package/packages/gui/src/views/CommandCenter.jsx +620 -0
  83. package/packages/gui/src/views/JournalistFeed.jsx +149 -0
  84. package/packages/gui/vite.config.js +19 -0
@@ -0,0 +1,115 @@
1
+ // GROOVE GUI — Agent Control Panel (tabbed: Chat / Stats / Actions)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React, { useState } from 'react';
5
+ import { useGrooveStore } from '../stores/groove';
6
+ import AgentChat from './AgentChat';
7
+ import AgentStats from './AgentStats';
8
+ import AgentActions from './AgentActions';
9
+
10
+ const TABS = [
11
+ { id: 'chat', label: 'Chat' },
12
+ { id: 'stats', label: 'Stats' },
13
+ { id: 'actions', label: 'Actions' },
14
+ ];
15
+
16
+ const STATUS_COLORS = {
17
+ running: 'var(--green)',
18
+ starting: 'var(--amber)',
19
+ stopped: 'var(--text-dim)',
20
+ crashed: 'var(--red)',
21
+ completed: 'var(--accent)',
22
+ killed: 'var(--text-dim)',
23
+ };
24
+
25
+ export default function AgentPanel() {
26
+ const [activeTab, setActiveTab] = useState('chat');
27
+ const detailPanel = useGrooveStore((s) => s.detailPanel);
28
+ const agents = useGrooveStore((s) => s.agents);
29
+
30
+ const agent = agents.find((a) => a.id === detailPanel?.agentId);
31
+ if (!agent) return <div style={styles.empty}>Agent not found</div>;
32
+
33
+ const color = STATUS_COLORS[agent.status] || 'var(--text-dim)';
34
+ const isAlive = agent.status === 'running' || agent.status === 'starting';
35
+
36
+ return (
37
+ <div style={styles.container}>
38
+ {/* Agent header */}
39
+ <div style={styles.agentHeader}>
40
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
41
+ <div style={{
42
+ width: 6, height: 6, borderRadius: '50%', background: color,
43
+ animation: isAlive ? 'pulse 2s infinite' : 'none',
44
+ }} />
45
+ <span style={styles.agentName}>{agent.name}</span>
46
+ <span style={{
47
+ fontSize: 10, fontWeight: 600, textTransform: 'uppercase',
48
+ letterSpacing: 0.5, color,
49
+ padding: '1px 6px', borderRadius: 2,
50
+ background: `color-mix(in srgb, ${color} 12%, transparent)`,
51
+ }}>
52
+ {agent.status}
53
+ </span>
54
+ </div>
55
+ <span style={styles.agentMeta}>
56
+ {agent.role} / {agent.provider}
57
+ </span>
58
+ </div>
59
+
60
+ {/* Tab bar */}
61
+ <div style={styles.tabBar}>
62
+ {TABS.map((tab) => (
63
+ <button
64
+ key={tab.id}
65
+ onClick={() => setActiveTab(tab.id)}
66
+ style={{
67
+ ...styles.tab,
68
+ color: activeTab === tab.id ? 'var(--text-bright)' : 'var(--text-dim)',
69
+ borderBottom: activeTab === tab.id ? '2px solid var(--accent)' : '2px solid transparent',
70
+ }}
71
+ >
72
+ {tab.label}
73
+ </button>
74
+ ))}
75
+ </div>
76
+
77
+ {/* Tab content */}
78
+ <div style={styles.tabContent}>
79
+ {activeTab === 'chat' && <AgentChat agent={agent} />}
80
+ {activeTab === 'stats' && <AgentStats agent={agent} />}
81
+ {activeTab === 'actions' && <AgentActions agent={agent} />}
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ const styles = {
88
+ container: {
89
+ display: 'flex', flexDirection: 'column', height: '100%',
90
+ },
91
+ agentHeader: {
92
+ padding: '12px 0 8px',
93
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
94
+ borderBottom: '1px solid var(--border)',
95
+ },
96
+ agentName: { fontSize: 14, fontWeight: 600, color: 'var(--text-bright)' },
97
+ agentMeta: { fontSize: 11, color: 'var(--text-dim)' },
98
+ tabBar: {
99
+ display: 'flex', gap: 0,
100
+ borderBottom: '1px solid var(--border)',
101
+ },
102
+ tab: {
103
+ padding: '8px 16px',
104
+ background: 'transparent', border: 'none',
105
+ borderBottom: '2px solid transparent',
106
+ fontSize: 11, fontWeight: 600, textTransform: 'uppercase',
107
+ letterSpacing: 1,
108
+ fontFamily: 'var(--font)', cursor: 'pointer',
109
+ },
110
+ tabContent: {
111
+ flex: 1, overflow: 'hidden',
112
+ display: 'flex', flexDirection: 'column',
113
+ },
114
+ empty: { color: 'var(--text-dim)', fontSize: 12, padding: 16 },
115
+ };
@@ -0,0 +1,333 @@
1
+ // GROOVE GUI — Agent Stats Tab
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React, { useEffect, useRef } from 'react';
5
+ import { useGrooveStore } from '../stores/groove';
6
+
7
+ export default function AgentStats({ agent }) {
8
+ const activityLog = useGrooveStore((s) => s.activityLog);
9
+ const tokenTimeline = useGrooveStore((s) => s.tokenTimeline);
10
+ const activity = activityLog[agent.id] || [];
11
+ const timeline = tokenTimeline[agent.id] || [];
12
+
13
+ const contextPct = Math.round((agent.contextUsage || 0) * 100);
14
+ const uptime = agent.spawnedAt ? formatDuration(Date.now() - new Date(agent.spawnedAt).getTime()) : '-';
15
+ const tokensPerMin = agent.spawnedAt && agent.tokensUsed > 0
16
+ ? Math.round(agent.tokensUsed / ((Date.now() - new Date(agent.spawnedAt).getTime()) / 60000))
17
+ : 0;
18
+
19
+ return (
20
+ <div style={styles.container}>
21
+ {/* Key metrics */}
22
+ <div style={styles.metricsGrid}>
23
+ <Metric label="Tokens" value={agent.tokensUsed?.toLocaleString() || '0'} />
24
+ <Metric label="Burn Rate" value={`${tokensPerMin}/min`} />
25
+ <Metric label="Uptime" value={uptime} />
26
+ <Metric label="Activity" value={`${activity.length} events`} />
27
+ </div>
28
+
29
+ {/* Live token heartbeat chart */}
30
+ <div style={{ marginTop: 16 }}>
31
+ <div style={styles.sectionLabel}>TOKEN HEARTBEAT</div>
32
+ <HeartbeatChart data={timeline} isAlive={agent.status === 'running'} />
33
+ </div>
34
+
35
+ {/* Context gauge */}
36
+ <div style={{ marginTop: 16 }}>
37
+ <div style={styles.sectionLabel}>CONTEXT USAGE</div>
38
+ <div style={styles.gaugeRow}>
39
+ <div style={styles.gaugeTrack}>
40
+ <div style={{
41
+ height: '100%', width: `${contextPct}%`, borderRadius: 1,
42
+ background: contextPct > 80 ? 'var(--red)' : contextPct > 60 ? 'var(--amber)' : 'var(--green)',
43
+ transition: 'width 0.3s',
44
+ }} />
45
+ </div>
46
+ <span style={styles.gaugeLabel}>{contextPct}%</span>
47
+ </div>
48
+ </div>
49
+
50
+ {/* Activity sparkline */}
51
+ <div style={{ marginTop: 16 }}>
52
+ <div style={styles.sectionLabel}>ACTIVITY PULSE</div>
53
+ <ActivityChart activity={activity} />
54
+ </div>
55
+
56
+ {/* Info grid */}
57
+ <div style={{ marginTop: 16 }}>
58
+ <div style={styles.sectionLabel}>DETAILS</div>
59
+ <div style={styles.infoList}>
60
+ <InfoRow label="ID" value={agent.id} />
61
+ <InfoRow label="Role" value={agent.role} />
62
+ <InfoRow label="Provider" value={agent.provider} />
63
+ <InfoRow label="Model" value={agent.model || 'default'} />
64
+ <InfoRow label="Scope" value={(agent.scope || []).join(', ') || 'unrestricted'} />
65
+ <InfoRow label="Spawned" value={agent.spawnedAt ? new Date(agent.spawnedAt).toLocaleTimeString() : '-'} />
66
+ <InfoRow label="Last Active" value={agent.lastActivity ? new Date(agent.lastActivity).toLocaleTimeString() : '-'} />
67
+ </div>
68
+ </div>
69
+
70
+ {/* Prompt */}
71
+ {agent.prompt && (
72
+ <div style={{ marginTop: 16 }}>
73
+ <div style={styles.sectionLabel}>ORIGINAL PROMPT</div>
74
+ <div style={styles.promptBox}>{agent.prompt}</div>
75
+ </div>
76
+ )}
77
+
78
+ {/* Recent log */}
79
+ <div style={{ marginTop: 16 }}>
80
+ <div style={styles.sectionLabel}>RECENT LOG ({activity.length})</div>
81
+ <div style={styles.logScroll}>
82
+ {activity.length === 0 && (
83
+ <div style={{ color: 'var(--text-dim)', fontSize: 11, padding: 8 }}>No activity yet...</div>
84
+ )}
85
+ {activity.slice(-30).reverse().map((entry, i) => (
86
+ <div key={i} style={styles.logEntry}>
87
+ <span style={styles.logTime}>{new Date(entry.timestamp).toLocaleTimeString()}</span>
88
+ <span style={styles.logText}>{entry.text?.slice(0, 200)}</span>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ // Live token heartbeat — draws a real-time line chart of token accumulation
98
+ function HeartbeatChart({ data, isAlive }) {
99
+ const canvasRef = useRef();
100
+
101
+ useEffect(() => {
102
+ const canvas = canvasRef.current;
103
+ if (!canvas) return;
104
+ const ctx = canvas.getContext('2d');
105
+ const w = canvas.width;
106
+ const h = canvas.height;
107
+
108
+ ctx.clearRect(0, 0, w, h);
109
+
110
+ // Grid lines
111
+ ctx.strokeStyle = '#3e4451';
112
+ ctx.lineWidth = 0.5;
113
+ for (let y = 0; y < h; y += h / 4) {
114
+ ctx.beginPath();
115
+ ctx.moveTo(0, y);
116
+ ctx.lineTo(w, y);
117
+ ctx.stroke();
118
+ }
119
+
120
+ if (data.length < 2) {
121
+ // Flat line
122
+ ctx.strokeStyle = isAlive ? '#5c6370' : '#3e4451';
123
+ ctx.lineWidth = 1;
124
+ ctx.beginPath();
125
+ ctx.moveTo(0, h / 2);
126
+ ctx.lineTo(w, h / 2);
127
+ ctx.stroke();
128
+
129
+ if (isAlive) {
130
+ ctx.fillStyle = '#5c6370';
131
+ ctx.font = '10px monospace';
132
+ ctx.fillText('waiting for data...', 8, h / 2 - 6);
133
+ }
134
+ return;
135
+ }
136
+
137
+ const values = data.map((d) => d.v);
138
+ const minV = Math.min(...values);
139
+ const maxV = Math.max(...values);
140
+ const range = maxV - minV || 1;
141
+
142
+ // Detect spikes (>20% jump between consecutive points)
143
+ const spikes = [];
144
+ for (let i = 1; i < values.length; i++) {
145
+ const jump = values[i] - values[i - 1];
146
+ if (jump > range * 0.2 && jump > 0) {
147
+ spikes.push(i);
148
+ }
149
+ }
150
+
151
+ // Draw fill gradient under line
152
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
153
+ gradient.addColorStop(0, isAlive ? 'rgba(51, 175, 188, 0.15)' : 'rgba(92, 99, 112, 0.1)');
154
+ gradient.addColorStop(1, 'transparent');
155
+
156
+ ctx.beginPath();
157
+ ctx.moveTo(0, h);
158
+ for (let i = 0; i < data.length; i++) {
159
+ const x = (i / (data.length - 1)) * w;
160
+ const y = h - 4 - ((values[i] - minV) / range) * (h - 8);
161
+ if (i === 0) ctx.lineTo(x, y);
162
+ else ctx.lineTo(x, y);
163
+ }
164
+ ctx.lineTo(w, h);
165
+ ctx.closePath();
166
+ ctx.fillStyle = gradient;
167
+ ctx.fill();
168
+
169
+ // Draw main line
170
+ ctx.strokeStyle = isAlive ? '#33afbc' : '#5c6370';
171
+ ctx.lineWidth = 1.5;
172
+ ctx.beginPath();
173
+ for (let i = 0; i < data.length; i++) {
174
+ const x = (i / (data.length - 1)) * w;
175
+ const y = h - 4 - ((values[i] - minV) / range) * (h - 8);
176
+ if (i === 0) ctx.moveTo(x, y);
177
+ else ctx.lineTo(x, y);
178
+ }
179
+ ctx.stroke();
180
+
181
+ // Draw spike markers
182
+ for (const idx of spikes) {
183
+ const x = (idx / (data.length - 1)) * w;
184
+ const y = h - 4 - ((values[idx] - minV) / range) * (h - 8);
185
+ ctx.fillStyle = '#e5c07b';
186
+ ctx.beginPath();
187
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
188
+ ctx.fill();
189
+ }
190
+
191
+ // Current value dot (pulsing effect via CSS)
192
+ if (isAlive && data.length > 0) {
193
+ const lastX = w;
194
+ const lastY = h - 4 - ((values[values.length - 1] - minV) / range) * (h - 8);
195
+ ctx.fillStyle = '#33afbc';
196
+ ctx.beginPath();
197
+ ctx.arc(lastX - 2, lastY, 3, 0, Math.PI * 2);
198
+ ctx.fill();
199
+ }
200
+
201
+ // Y-axis labels
202
+ ctx.fillStyle = '#5c6370';
203
+ ctx.font = '9px monospace';
204
+ ctx.fillText(maxV.toLocaleString(), 4, 10);
205
+ ctx.fillText(minV.toLocaleString(), 4, h - 2);
206
+
207
+ }, [data, isAlive]);
208
+
209
+ return (
210
+ <div style={styles.chartContainer}>
211
+ <canvas
212
+ ref={canvasRef}
213
+ width={400}
214
+ height={60}
215
+ style={{ width: '100%', height: 60, display: 'block' }}
216
+ />
217
+ </div>
218
+ );
219
+ }
220
+
221
+ // Activity frequency chart
222
+ function ActivityChart({ activity }) {
223
+ const points = 40;
224
+ const now = Date.now();
225
+ const windowMs = 5 * 60 * 1000;
226
+ const bucketSize = windowMs / points;
227
+ const buckets = new Array(points).fill(0);
228
+
229
+ for (const entry of activity) {
230
+ const age = now - entry.timestamp;
231
+ if (age > windowMs) continue;
232
+ const idx = Math.min(Math.floor((windowMs - age) / bucketSize), points - 1);
233
+ buckets[idx]++;
234
+ }
235
+
236
+ const max = Math.max(...buckets, 1);
237
+ const sparkPoints = buckets.map((count, i) => {
238
+ const x = (i / (points - 1)) * 300;
239
+ const y = 28 - (count / max) * 24;
240
+ return `${x},${y}`;
241
+ }).join(' ');
242
+
243
+ return (
244
+ <div style={styles.chartContainer}>
245
+ <svg width="100%" height="32" viewBox="0 0 300 32" preserveAspectRatio="none" style={{ display: 'block' }}>
246
+ <polyline points={sparkPoints} fill="none" stroke="var(--green)" strokeWidth="1" opacity="0.6" />
247
+ <line x1="0" y1="31" x2="300" y2="31" stroke="var(--text-muted)" strokeWidth="0.5" />
248
+ </svg>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ function Metric({ label, value }) {
254
+ return (
255
+ <div style={styles.metric}>
256
+ <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-bright)' }}>{value}</div>
257
+ <div style={{ fontSize: 10, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: 1 }}>{label}</div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ function InfoRow({ label, value }) {
263
+ return (
264
+ <div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
265
+ <span style={{ color: 'var(--text-dim)', fontSize: 11 }}>{label}</span>
266
+ <span style={{
267
+ color: 'var(--text-primary)', fontSize: 11,
268
+ maxWidth: '65%', textAlign: 'right',
269
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
270
+ }}>
271
+ {value}
272
+ </span>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ function formatDuration(ms) {
278
+ const s = Math.floor(ms / 1000);
279
+ const m = Math.floor(s / 60);
280
+ const h = Math.floor(m / 60);
281
+ if (h > 0) return `${h}h ${m % 60}m`;
282
+ if (m > 0) return `${m}m ${s % 60}s`;
283
+ return `${s}s`;
284
+ }
285
+
286
+ const styles = {
287
+ container: {
288
+ flex: 1, overflowY: 'auto', padding: '10px 0',
289
+ },
290
+ metricsGrid: {
291
+ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6,
292
+ },
293
+ metric: {
294
+ background: 'var(--bg-surface)', border: '1px solid var(--border)', borderRadius: 2,
295
+ padding: 10, textAlign: 'center',
296
+ },
297
+ sectionLabel: {
298
+ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase',
299
+ letterSpacing: 1.5, marginBottom: 6, fontWeight: 600,
300
+ },
301
+ gaugeRow: {
302
+ display: 'flex', alignItems: 'center', gap: 8,
303
+ },
304
+ gaugeTrack: {
305
+ flex: 1, height: 4, background: 'var(--text-muted)', borderRadius: 2, overflow: 'hidden',
306
+ },
307
+ gaugeLabel: {
308
+ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600, minWidth: 36, textAlign: 'right',
309
+ },
310
+ chartContainer: {
311
+ background: 'var(--bg-base)', border: '1px solid var(--border)', borderRadius: 2,
312
+ padding: '6px 8px', overflow: 'hidden',
313
+ },
314
+ infoList: {
315
+ borderTop: '1px solid var(--border)', paddingTop: 6,
316
+ },
317
+ promptBox: {
318
+ background: 'var(--bg-base)', border: '1px solid var(--border)', borderRadius: 2,
319
+ padding: 8, fontSize: 12, color: 'var(--text-primary)', lineHeight: 1.5,
320
+ whiteSpace: 'pre-wrap',
321
+ },
322
+ logScroll: {
323
+ maxHeight: 160, overflowY: 'auto',
324
+ background: 'var(--bg-base)', borderRadius: 2,
325
+ border: '1px solid var(--border)',
326
+ },
327
+ logEntry: {
328
+ padding: '3px 8px', borderBottom: '1px solid var(--bg-surface)',
329
+ fontSize: 10, display: 'flex', gap: 6,
330
+ },
331
+ logTime: { color: 'var(--text-dim)', whiteSpace: 'nowrap' },
332
+ logText: { color: 'var(--text-primary)', wordBreak: 'break-word' },
333
+ };
@@ -0,0 +1,156 @@
1
+ // GROOVE GUI — PM Review History (Approvals Tab)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React, { useState, useEffect } from 'react';
5
+
6
+ export default function ApprovalQueue() {
7
+ const [data, setData] = useState(null);
8
+
9
+ useEffect(() => {
10
+ fetchHistory();
11
+ const interval = setInterval(fetchHistory, 4000);
12
+ return () => clearInterval(interval);
13
+ }, []);
14
+
15
+ async function fetchHistory() {
16
+ try {
17
+ const res = await fetch('/api/pm/history');
18
+ setData(await res.json());
19
+ } catch { /* ignore */ }
20
+ }
21
+
22
+ const history = data?.history || [];
23
+ const stats = data?.stats || {};
24
+
25
+ return (
26
+ <div style={styles.container}>
27
+ <div style={styles.header}>
28
+ <div style={styles.title}>PM REVIEW LOG</div>
29
+ <div style={styles.subtitle}>AI Project Manager reviews risky agent operations in Auto mode</div>
30
+ </div>
31
+
32
+ {/* Stats bar */}
33
+ <div style={styles.statsBar}>
34
+ <Stat label="REVIEWS" value={stats.totalReviews || 0} />
35
+ <Stat label="APPROVED" value={stats.approved || 0} color="var(--green)" />
36
+ <Stat label="REJECTED" value={stats.rejected || 0} color="var(--red)" />
37
+ <Stat label="AVG TIME" value={stats.avgDurationMs ? `${(stats.avgDurationMs / 1000).toFixed(1)}s` : '-'} />
38
+ </div>
39
+
40
+ {/* History */}
41
+ {history.length === 0 ? (
42
+ <div style={styles.empty}>
43
+ <div style={styles.emptyTitle}>No reviews yet</div>
44
+ <div style={styles.emptyDesc}>
45
+ Spawn agents with Auto permission mode. The AI PM will review risky operations
46
+ (new files, deletions, config changes) before they happen.
47
+ </div>
48
+ </div>
49
+ ) : (
50
+ <div style={styles.list}>
51
+ {history.slice().reverse().map((r, i) => (
52
+ <div key={i} style={styles.entry}>
53
+ <div style={styles.entryTop}>
54
+ <span style={{
55
+ ...styles.verdict,
56
+ color: r.approved ? 'var(--green)' : 'var(--red)',
57
+ borderColor: r.approved ? 'rgba(74,225,104,0.3)' : 'rgba(224,108,117,0.3)',
58
+ background: r.approved ? 'rgba(74,225,104,0.08)' : 'rgba(224,108,117,0.08)',
59
+ }}>
60
+ {r.approved ? 'APPROVED' : 'REJECTED'}
61
+ </span>
62
+ <span style={styles.entryAgent}>{r.agent}</span>
63
+ <span style={styles.entryAction}>{r.action}</span>
64
+ <span style={styles.entryTime}>{timeAgo(r.timestamp)}</span>
65
+ </div>
66
+ <div style={styles.entryFile}>{r.file}</div>
67
+ {r.description && <div style={styles.entryDesc}>{r.description}</div>}
68
+ <div style={styles.entryReason}>{r.reason}</div>
69
+ </div>
70
+ ))}
71
+ </div>
72
+ )}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ function Stat({ label, value, color }) {
78
+ return (
79
+ <div style={styles.stat}>
80
+ <div style={{ fontSize: 16, fontWeight: 700, color: color || 'var(--text-bright)' }}>{value}</div>
81
+ <div style={{ fontSize: 7, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: 1 }}>{label}</div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function timeAgo(ts) {
87
+ const m = Math.floor((Date.now() - new Date(ts).getTime()) / 60000);
88
+ if (m >= 60) return `${Math.floor(m / 60)}h ago`;
89
+ if (m > 0) return `${m}m ago`;
90
+ return 'just now';
91
+ }
92
+
93
+ const styles = {
94
+ container: {
95
+ padding: 24, maxWidth: 700, margin: '0 auto',
96
+ },
97
+ header: {
98
+ marginBottom: 16,
99
+ },
100
+ title: {
101
+ fontSize: 11, fontWeight: 700, color: 'var(--text-dim)',
102
+ textTransform: 'uppercase', letterSpacing: 1.5,
103
+ },
104
+ subtitle: {
105
+ fontSize: 10, color: 'var(--text-dim)', marginTop: 4,
106
+ },
107
+ statsBar: {
108
+ display: 'flex', gap: 8, marginBottom: 16,
109
+ },
110
+ stat: {
111
+ flex: 1, background: 'var(--bg-surface)', border: '1px solid var(--border)',
112
+ padding: '8px 10px', textAlign: 'center',
113
+ },
114
+ empty: {
115
+ textAlign: 'center', padding: '40px 20px',
116
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
117
+ },
118
+ emptyTitle: {
119
+ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600, marginBottom: 8,
120
+ },
121
+ emptyDesc: {
122
+ fontSize: 10, color: 'var(--text-dim)', lineHeight: 1.6, maxWidth: 400, margin: '0 auto',
123
+ },
124
+ list: {
125
+ display: 'flex', flexDirection: 'column', gap: 4,
126
+ },
127
+ entry: {
128
+ background: 'var(--bg-surface)', border: '1px solid var(--border)',
129
+ padding: '8px 10px',
130
+ },
131
+ entryTop: {
132
+ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4,
133
+ },
134
+ verdict: {
135
+ fontSize: 8, fontWeight: 700, letterSpacing: 0.5,
136
+ padding: '1px 5px', border: '1px solid',
137
+ },
138
+ entryAgent: {
139
+ fontSize: 11, fontWeight: 600, color: 'var(--text-bright)',
140
+ },
141
+ entryAction: {
142
+ fontSize: 10, color: 'var(--text-dim)',
143
+ },
144
+ entryTime: {
145
+ fontSize: 9, color: 'var(--text-dim)', marginLeft: 'auto',
146
+ },
147
+ entryFile: {
148
+ fontSize: 10, color: 'var(--accent)', fontFamily: 'var(--font)',
149
+ },
150
+ entryDesc: {
151
+ fontSize: 10, color: 'var(--text-primary)', marginTop: 2,
152
+ },
153
+ entryReason: {
154
+ fontSize: 10, color: 'var(--text-dim)', marginTop: 3, fontStyle: 'italic',
155
+ },
156
+ };