groove-dev 0.27.187 → 0.27.189
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.
- package/innerchat/Screenshot_2026-07-23_at_1.30.16_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +6 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +18 -13
- package/node_modules/@groove-dev/daemon/src/process.js +10 -0
- package/node_modules/@groove-dev/daemon/src/teams.js +36 -4
- package/node_modules/@groove-dev/daemon/src/watcher.js +230 -104
- package/node_modules/@groove-dev/daemon/test/teams.test.js +48 -1
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -10
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-DyI84i9Y.js → index-DTY0KsH9.js} +222 -222
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +51 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +11 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +106 -10
- package/node_modules/@groove-dev/gui/src/lib/logpaths.js +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -6
- package/node_modules/@groove-dev/gui/src/stores/helpers.js +28 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +20 -9
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/index.js +6 -0
- package/packages/daemon/src/innerchat-docs.js +18 -13
- package/packages/daemon/src/process.js +10 -0
- package/packages/daemon/src/teams.js +36 -4
- package/packages/daemon/src/watcher.js +230 -104
- package/packages/gui/dist/assets/index-Cat5pJUx.css +1 -0
- package/packages/gui/dist/assets/{index-DyI84i9Y.js → index-DTY0KsH9.js} +222 -222
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +51 -2
- package/packages/gui/src/components/agents/agent-feed.jsx +11 -2
- package/packages/gui/src/components/agents/agent-panel.jsx +106 -10
- package/packages/gui/src/lib/logpaths.js +1 -1
- package/packages/gui/src/stores/groove.js +19 -6
- package/packages/gui/src/stores/helpers.js +28 -0
- package/packages/gui/src/stores/slices/agents-slice.js +20 -9
- package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +0 -1
- package/packages/gui/dist/assets/index-CU8L_r5f.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-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DTY0KsH9.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-26L3JoZv.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/reactflow-DoBZjiHE.js">
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/codemirror-BYKpdS2W.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cat5pJUx.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/packages/gui/src/App.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GROOVE GUI v2 — App Root
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import React, { useEffect, useMemo } from 'react';
|
|
4
|
+
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
|
5
5
|
import { useGrooveStore } from './stores/groove';
|
|
6
6
|
import { AppShell } from './components/layout/app-shell';
|
|
7
7
|
import { SetupWizard } from './components/onboarding/setup-wizard';
|
|
@@ -133,10 +133,59 @@ function TunneledFolderPicker() {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function LoadingScreen() {
|
|
136
|
+
const connected = useGrooveStore((s) => s.connected);
|
|
137
|
+
const [slow, setSlow] = useState(false);
|
|
138
|
+
const [reconnecting, setReconnecting] = useState(false);
|
|
139
|
+
const triedRef = useRef(false);
|
|
140
|
+
|
|
141
|
+
// If we haven't come up within a few seconds, the connection (or the SSH
|
|
142
|
+
// tunnel behind it) is likely stalled — surface a reload rather than an
|
|
143
|
+
// endless pulse the user can't act on.
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const t = setTimeout(() => setSlow(true), 6000);
|
|
146
|
+
return () => clearTimeout(t);
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
// If the socket can't even open (not just un-hydrated), the tunnel behind it
|
|
150
|
+
// is probably dead — ask the desktop shell to re-establish it. Once only, and
|
|
151
|
+
// only when disconnected, so a merely-slow load isn't disturbed.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!slow || connected || triedRef.current) return;
|
|
154
|
+
if (!window.groove?.reconnect) return;
|
|
155
|
+
triedRef.current = true;
|
|
156
|
+
setReconnecting(true);
|
|
157
|
+
window.groove.reconnect().catch(() => {}).finally(() => setReconnecting(false));
|
|
158
|
+
}, [slow, connected]);
|
|
159
|
+
|
|
160
|
+
async function manualReconnect() {
|
|
161
|
+
if (window.groove?.reconnect) {
|
|
162
|
+
setReconnecting(true);
|
|
163
|
+
try { await window.groove.reconnect(); } catch { /* falls through to reload */ }
|
|
164
|
+
setReconnecting(false);
|
|
165
|
+
}
|
|
166
|
+
window.location.reload();
|
|
167
|
+
}
|
|
168
|
+
|
|
136
169
|
return (
|
|
137
170
|
<div className="h-screen bg-surface-0 flex flex-col items-center justify-center gap-4">
|
|
138
171
|
<img src="/favicon.png" alt="" className="w-10 h-10 opacity-60 animate-pulse" />
|
|
139
|
-
<p className="text-sm text-text-3 font-sans">
|
|
172
|
+
<p className="text-sm text-text-3 font-sans">
|
|
173
|
+
{reconnecting ? 'Re-establishing connection…' : connected ? 'Loading…' : 'Connecting to daemon…'}
|
|
174
|
+
</p>
|
|
175
|
+
{slow && (
|
|
176
|
+
<div className="flex flex-col items-center gap-2 mt-1">
|
|
177
|
+
<p className="text-xs text-text-4 font-sans max-w-xs text-center">
|
|
178
|
+
Still trying to reach the daemon. If you just woke your machine, the connection is re-establishing.
|
|
179
|
+
</p>
|
|
180
|
+
<button
|
|
181
|
+
onClick={manualReconnect}
|
|
182
|
+
disabled={reconnecting}
|
|
183
|
+
className="px-3 py-1.5 rounded-md text-xs font-medium font-sans border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 disabled:opacity-50 transition-colors cursor-pointer"
|
|
184
|
+
>
|
|
185
|
+
{reconnecting ? 'Reconnecting…' : 'Reconnect'}
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
140
189
|
</div>
|
|
141
190
|
);
|
|
142
191
|
}
|
|
@@ -390,6 +390,15 @@ function shq(s) {
|
|
|
390
390
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// Quote a path but keep a leading ~/ OUTSIDE the quotes so the shell still
|
|
394
|
+
// expands it — a single-quoted ~ is a literal directory that doesn't exist,
|
|
395
|
+
// which is why `tail -f '~/x.log'` fails.
|
|
396
|
+
function shqPath(p) {
|
|
397
|
+
if (p === '~') return '~';
|
|
398
|
+
if (p.startsWith('~/')) return `~/${shq(p.slice(2))}`;
|
|
399
|
+
return shq(p);
|
|
400
|
+
}
|
|
401
|
+
|
|
393
402
|
// Build a `tail -f` that actually resolves. Agents usually write a log path
|
|
394
403
|
// relative to wherever they ran it — often a bare filename several directories
|
|
395
404
|
// deep — but the terminal opens in a different cwd, so a naive `tail -f name`
|
|
@@ -397,9 +406,9 @@ function shq(s) {
|
|
|
397
406
|
// agent's working directory, falling back to a `find` by basename when the file
|
|
398
407
|
// sits below that directory.
|
|
399
408
|
function tailCommand(path, workdir) {
|
|
400
|
-
if (/^[/~]/.test(path) || !workdir) return `tail -f ${
|
|
409
|
+
if (/^[/~]/.test(path) || !workdir) return `tail -f ${shqPath(path)}`;
|
|
401
410
|
const base = path.split('/').pop();
|
|
402
|
-
return `cd ${
|
|
411
|
+
return `cd ${shqPath(workdir)} 2>/dev/null; `
|
|
403
412
|
+ `if [ -f ${shq(path)} ]; then tail -f ${shq(path)}; `
|
|
404
413
|
+ `else tail -f "$(find . -name ${shq(base)} -type f 2>/dev/null | head -1)"; fi`;
|
|
405
414
|
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
-
import { useState, useRef } from 'react';
|
|
2
|
+
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
3
3
|
import { useGrooveStore } from '../../stores/groove';
|
|
4
4
|
import { Badge } from '../ui/badge';
|
|
5
5
|
import { AgentFeed } from './agent-feed';
|
|
6
6
|
import { AgentConfig } from './agent-config';
|
|
7
7
|
import { AgentTelemetry } from './agent-telemetry';
|
|
8
8
|
import { AgentMdFiles } from './agent-mdfiles';
|
|
9
|
-
import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X } from 'lucide-react';
|
|
9
|
+
import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X, ChevronDown, Search } from 'lucide-react';
|
|
10
10
|
import { fmtNum, fmtUptime } from '../../lib/format';
|
|
11
11
|
import { cn } from '../../lib/cn';
|
|
12
12
|
import { roleColor } from '../../lib/status';
|
|
13
13
|
import { api } from '../../lib/api';
|
|
14
14
|
|
|
15
|
+
const SWITCH_STATUS_DOT = {
|
|
16
|
+
running: 'bg-accent', starting: 'bg-warning', completed: 'bg-info',
|
|
17
|
+
crashed: 'bg-danger', stopped: 'bg-text-4', killed: 'bg-text-4', rotating: 'bg-accent',
|
|
18
|
+
};
|
|
19
|
+
|
|
15
20
|
const STATUS_VARIANT = {
|
|
16
21
|
running: 'success', starting: 'warning', stopped: 'default',
|
|
17
22
|
crashed: 'danger', completed: 'accent', killed: 'default', rotating: 'purple',
|
|
@@ -28,11 +33,46 @@ const TABS = [
|
|
|
28
33
|
{ id: 'mdfiles', label: 'Files', icon: FileText },
|
|
29
34
|
];
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
// The agent name doubles as a switcher — click it to jump the panel to any
|
|
37
|
+
// other agent without closing the terminal or hunting through the tree. The
|
|
38
|
+
// hover pencil still renames in place.
|
|
39
|
+
function AgentSwitcher({ agent }) {
|
|
32
40
|
const addToast = useGrooveStore((s) => s.addToast);
|
|
41
|
+
const agents = useGrooveStore((s) => s.agents);
|
|
42
|
+
const teams = useGrooveStore((s) => s.teams);
|
|
43
|
+
const switchAgentPanel = useGrooveStore((s) => s.switchAgentPanel);
|
|
44
|
+
|
|
33
45
|
const [editing, setEditing] = useState(false);
|
|
34
46
|
const [name, setName] = useState(agent.name);
|
|
35
|
-
const
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const [query, setQuery] = useState('');
|
|
49
|
+
const wrapRef = useRef(null);
|
|
50
|
+
|
|
51
|
+
// Close the dropdown on outside click or Escape.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
const onDown = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
|
|
55
|
+
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
|
56
|
+
document.addEventListener('mousedown', onDown);
|
|
57
|
+
document.addEventListener('keydown', onKey);
|
|
58
|
+
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
|
59
|
+
}, [open]);
|
|
60
|
+
|
|
61
|
+
const grouped = useMemo(() => {
|
|
62
|
+
const q = query.trim().toLowerCase();
|
|
63
|
+
const byTeam = new Map();
|
|
64
|
+
for (const a of agents) {
|
|
65
|
+
if (q && !a.name.toLowerCase().includes(q) && !a.role.toLowerCase().includes(q)) continue;
|
|
66
|
+
const key = a.teamId || '_none';
|
|
67
|
+
if (!byTeam.has(key)) byTeam.set(key, []);
|
|
68
|
+
byTeam.get(key).push(a);
|
|
69
|
+
}
|
|
70
|
+
return Array.from(byTeam.entries()).map(([teamId, list]) => ({
|
|
71
|
+
teamId,
|
|
72
|
+
name: teams.find((t) => t.id === teamId)?.name || 'No team',
|
|
73
|
+
agents: list.sort((a, b) => a.name.localeCompare(b.name)),
|
|
74
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
+
}, [agents, teams, query]);
|
|
36
76
|
|
|
37
77
|
async function save() {
|
|
38
78
|
const trimmed = name.trim();
|
|
@@ -47,11 +87,16 @@ function InlineName({ agent }) {
|
|
|
47
87
|
setEditing(false);
|
|
48
88
|
}
|
|
49
89
|
|
|
90
|
+
function pick(id) {
|
|
91
|
+
setOpen(false);
|
|
92
|
+
setQuery('');
|
|
93
|
+
if (id !== agent.id) switchAgentPanel(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
if (editing) {
|
|
51
97
|
return (
|
|
52
98
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
|
53
99
|
<input
|
|
54
|
-
ref={inputRef}
|
|
55
100
|
value={name}
|
|
56
101
|
onChange={(e) => setName(e.target.value)}
|
|
57
102
|
onKeyDown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setName(agent.name); setEditing(false); } }}
|
|
@@ -65,14 +110,61 @@ function InlineName({ agent }) {
|
|
|
65
110
|
}
|
|
66
111
|
|
|
67
112
|
return (
|
|
68
|
-
<div className="flex items-center gap-1.5 flex-1 min-w-0 group">
|
|
69
|
-
<
|
|
113
|
+
<div ref={wrapRef} className="relative flex items-center gap-1.5 flex-1 min-w-0 group">
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => setOpen((v) => !v)}
|
|
116
|
+
className="flex items-center gap-1 min-w-0 cursor-pointer rounded px-1 -mx-1 py-0.5 hover:bg-surface-3 transition-colors"
|
|
117
|
+
title="Switch agent"
|
|
118
|
+
>
|
|
119
|
+
<h2 className="text-sm font-bold text-text-0 font-sans truncate">{agent.name}</h2>
|
|
120
|
+
<ChevronDown size={13} className={cn('text-text-4 flex-shrink-0 transition-transform', open && 'rotate-180')} />
|
|
121
|
+
</button>
|
|
70
122
|
<button
|
|
71
123
|
onClick={() => { setName(agent.name); setEditing(true); }}
|
|
72
|
-
className="p-0.5 text-text-4 opacity-0 group-hover:opacity-100 hover:text-text-1 cursor-pointer transition-opacity"
|
|
124
|
+
className="p-0.5 text-text-4 opacity-0 group-hover:opacity-100 hover:text-text-1 cursor-pointer transition-opacity flex-shrink-0"
|
|
125
|
+
title="Rename agent"
|
|
73
126
|
>
|
|
74
127
|
<Pencil size={10} />
|
|
75
128
|
</button>
|
|
129
|
+
|
|
130
|
+
{open && (
|
|
131
|
+
<div className="absolute top-full left-0 mt-1.5 w-72 max-h-[70vh] flex flex-col bg-surface-1 border border-border rounded-lg shadow-xl z-50 overflow-hidden">
|
|
132
|
+
<div className="flex items-center gap-1.5 px-2.5 py-2 border-b border-border-subtle">
|
|
133
|
+
<Search size={12} className="text-text-4 flex-shrink-0" />
|
|
134
|
+
<input
|
|
135
|
+
value={query}
|
|
136
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
137
|
+
placeholder="Switch to agent…"
|
|
138
|
+
autoFocus
|
|
139
|
+
className="flex-1 min-w-0 bg-transparent text-xs text-text-0 font-sans placeholder:text-text-4 focus:outline-none"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="overflow-y-auto py-1">
|
|
143
|
+
{grouped.length === 0 && (
|
|
144
|
+
<div className="px-3 py-3 text-xs text-text-4 font-sans text-center">No agents match</div>
|
|
145
|
+
)}
|
|
146
|
+
{grouped.map((g) => (
|
|
147
|
+
<div key={g.teamId}>
|
|
148
|
+
<div className="px-2.5 pt-1.5 pb-0.5 text-2xs font-semibold text-text-4 font-sans uppercase tracking-wide">{g.name}</div>
|
|
149
|
+
{g.agents.map((a) => (
|
|
150
|
+
<button
|
|
151
|
+
key={a.id}
|
|
152
|
+
onClick={() => pick(a.id)}
|
|
153
|
+
className={cn(
|
|
154
|
+
'w-full flex items-center gap-2 px-2.5 py-1.5 text-left cursor-pointer transition-colors',
|
|
155
|
+
a.id === agent.id ? 'bg-accent/10' : 'hover:bg-surface-3',
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
<span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', SWITCH_STATUS_DOT[a.status] || 'bg-text-4')} />
|
|
159
|
+
<span className={cn('text-xs font-sans truncate', a.id === agent.id ? 'text-accent font-medium' : 'text-text-1')}>{a.name}</span>
|
|
160
|
+
<span className="text-2xs text-text-4 font-sans ml-auto flex-shrink-0 capitalize">{a.role}</span>
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
76
168
|
</div>
|
|
77
169
|
);
|
|
78
170
|
}
|
|
@@ -81,6 +173,7 @@ export function AgentPanel() {
|
|
|
81
173
|
const detailPanel = useGrooveStore((s) => s.detailPanel);
|
|
82
174
|
const agents = useGrooveStore((s) => s.agents);
|
|
83
175
|
const activeTeamId = useGrooveStore((s) => s.activeTeamId);
|
|
176
|
+
const activeView = useGrooveStore((s) => s.activeView);
|
|
84
177
|
const addToast = useGrooveStore((s) => s.addToast);
|
|
85
178
|
const [activeTab, setActiveTab] = useState('command');
|
|
86
179
|
const cachedAgentRef = useRef(null);
|
|
@@ -93,7 +186,10 @@ export function AgentPanel() {
|
|
|
93
186
|
const isAlive = liveAgent?.status === 'running' || liveAgent?.status === 'starting';
|
|
94
187
|
|
|
95
188
|
if (!agent) return null;
|
|
96
|
-
|
|
189
|
+
// The team guard hides a stale panel when switching teams in the Agents view.
|
|
190
|
+
// Fleet is cross-team by design — an agent selected there legitimately belongs
|
|
191
|
+
// to another team, so applying the guard would blank a valid panel.
|
|
192
|
+
if (activeView !== 'fleet' && activeTeamId && agent.teamId && agent.teamId !== activeTeamId) return null;
|
|
97
193
|
|
|
98
194
|
const ctxPct = Math.round((agent.contextUsage || 0) * 100);
|
|
99
195
|
const spawned = agent.spawnedAt || agent.createdAt;
|
|
@@ -109,7 +205,7 @@ export function AgentPanel() {
|
|
|
109
205
|
<div className="pl-4 pr-10 pt-3 pb-2">
|
|
110
206
|
{/* Name + status row — pr-10 gives room for the detail panel X button */}
|
|
111
207
|
<div className="flex items-center gap-2">
|
|
112
|
-
<
|
|
208
|
+
<AgentSwitcher agent={agent} />
|
|
113
209
|
<Badge variant={STATUS_VARIANT[agent.status] || 'default'} dot={isAlive ? 'pulse' : undefined} className="text-2xs flex-shrink-0">
|
|
114
210
|
{STATUS_LABEL[agent.status] || agent.status}
|
|
115
211
|
</Badge>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// match, and results are de-duped by full path.
|
|
11
11
|
|
|
12
12
|
const TAIL_RE = /tail\s+(?:-[a-zA-Z]+\s+)*([~/]?[\w./-]+)/g;
|
|
13
|
-
const DOTLOG_RE = /(?:^|[\s'"`(=])(
|
|
13
|
+
const DOTLOG_RE = /(?:^|[\s'"`(=])((?:~\/|\/)?(?:[\w.-]+\/)*[\w.-]+\.log)\b/g;
|
|
14
14
|
|
|
15
15
|
function clean(p) {
|
|
16
16
|
return p.replace(/^['"`]+/, '').replace(/['"`.,;:)]+$/, '');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { create } from 'zustand';
|
|
5
5
|
import { api } from '../lib/api';
|
|
6
|
-
import { persistJSON } from './helpers.js';
|
|
6
|
+
import { persistJSON, persistChatHistory } from './helpers.js';
|
|
7
7
|
import { createUiSlice } from './slices/ui-slice.js';
|
|
8
8
|
import { createAgentsSlice } from './slices/agents-slice.js';
|
|
9
9
|
import { createTeamsSlice } from './slices/teams-slice.js';
|
|
@@ -20,11 +20,11 @@ const WS_URL = `ws://${window.location.hostname}:${window.location.port || 31415
|
|
|
20
20
|
|
|
21
21
|
let plannerPollInterval = null;
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Record the store version for potential future migrations. Chat history and
|
|
24
|
+
// activity log are USER DATA and must survive version changes — never wipe them
|
|
25
|
+
// here. If a schema ever needs migrating, transform in place, don't delete.
|
|
24
26
|
const STORE_VERSION = '0.22.28';
|
|
25
27
|
if (localStorage.getItem('groove:storeVersion') !== JSON.stringify(STORE_VERSION)) {
|
|
26
|
-
localStorage.removeItem('groove:chatHistory');
|
|
27
|
-
localStorage.removeItem('groove:activityLog');
|
|
28
28
|
persistJSON('groove:storeVersion', STORE_VERSION);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -264,7 +264,7 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
264
264
|
|
|
265
265
|
history[agentId] = arr.slice(-100);
|
|
266
266
|
set({ chatHistory: history });
|
|
267
|
-
|
|
267
|
+
persistChatHistory(history);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
const conv = get().conversations.find((c) => c.agentId === agentId);
|
|
@@ -402,7 +402,7 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
402
402
|
push(turn.to.id, entry(turn.from, 'in'));
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
|
|
405
|
+
persistChatHistory(history);
|
|
406
406
|
|
|
407
407
|
const answers = { ...s.innerchatAnswers };
|
|
408
408
|
if (turn.kind === 'answer') {
|
|
@@ -1146,3 +1146,16 @@ export const useGrooveStore = create((set, get) => ({
|
|
|
1146
1146
|
ws.onerror = () => ws.close();
|
|
1147
1147
|
},
|
|
1148
1148
|
}));
|
|
1149
|
+
|
|
1150
|
+
// Reconnect promptly when connectivity returns — laptop wake, network back, or
|
|
1151
|
+
// the window regaining focus — instead of waiting out the periodic 2s retry.
|
|
1152
|
+
// connect() no-ops if a socket already exists, so these are safe to fire often.
|
|
1153
|
+
if (typeof window !== 'undefined') {
|
|
1154
|
+
const kick = () => {
|
|
1155
|
+
const s = useGrooveStore.getState();
|
|
1156
|
+
if (!s.ws) s.connect();
|
|
1157
|
+
};
|
|
1158
|
+
window.addEventListener('online', kick);
|
|
1159
|
+
window.addEventListener('focus', kick);
|
|
1160
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); });
|
|
1161
|
+
}
|
|
@@ -8,3 +8,31 @@ export function loadJSON(key, fallback = {}) {
|
|
|
8
8
|
export function persistJSON(key, value) {
|
|
9
9
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* quota */ }
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
// Base64 image data must never reach localStorage — it blows the ~5MB quota,
|
|
13
|
+
// after which every write silently fails and history stops persisting. Keep the
|
|
14
|
+
// attachment metadata, drop the payload.
|
|
15
|
+
export function stripAttachments(history) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [id, msgs] of Object.entries(history || {})) {
|
|
18
|
+
out[id] = (msgs || []).map((m) =>
|
|
19
|
+
m.attachments?.length
|
|
20
|
+
? { ...m, attachments: m.attachments.map(({ dataUrl, ...rest }) => rest) }
|
|
21
|
+
: m);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The single writer for chat history. Strips attachments and refuses to
|
|
27
|
+
// overwrite a populated store with an empty object — a blank write is almost
|
|
28
|
+
// always a startup race or partial state, and losing chat is never acceptable.
|
|
29
|
+
export function persistChatHistory(history) {
|
|
30
|
+
const clean = stripAttachments(history);
|
|
31
|
+
try {
|
|
32
|
+
if (Object.keys(clean).length === 0) {
|
|
33
|
+
const existing = localStorage.getItem('groove:chatHistory');
|
|
34
|
+
if (existing && existing !== '{}' && existing !== 'null') return;
|
|
35
|
+
}
|
|
36
|
+
localStorage.setItem('groove:chatHistory', JSON.stringify(clean));
|
|
37
|
+
} catch { /* quota — keep the last good value rather than clobbering it */ }
|
|
38
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { api } from '../../lib/api';
|
|
4
|
-
import { loadJSON, persistJSON } from '../helpers.js';
|
|
4
|
+
import { loadJSON, persistJSON, persistChatHistory } from '../helpers.js';
|
|
5
5
|
|
|
6
6
|
export const createAgentsSlice = (set, get) => ({
|
|
7
7
|
// ── Agent data ────────────────────────────────────────────
|
|
@@ -85,7 +85,7 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
85
85
|
delete chatHistory[id];
|
|
86
86
|
delete activityLog[id];
|
|
87
87
|
delete tokenTimeline[id];
|
|
88
|
-
|
|
88
|
+
persistChatHistory(chatHistory);
|
|
89
89
|
persistJSON('groove:activityLog', activityLog);
|
|
90
90
|
return { chatHistory, activityLog, tokenTimeline };
|
|
91
91
|
});
|
|
@@ -95,6 +95,22 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
|
|
98
|
+
// Switch the detail panel to another agent without disturbing the rest of
|
|
99
|
+
// the layout (terminal stays open). Aligns activeTeamId to the target so the
|
|
100
|
+
// panel's team guard passes even when switching across teams.
|
|
101
|
+
switchAgentPanel(agentId) {
|
|
102
|
+
const agent = get().agents.find((a) => a.id === agentId);
|
|
103
|
+
if (!agent) return;
|
|
104
|
+
const tid = agent.teamId || get().activeTeamId;
|
|
105
|
+
const panel = { type: 'agent', agentId };
|
|
106
|
+
set((s) => ({
|
|
107
|
+
activeTeamId: tid,
|
|
108
|
+
detailPanel: panel,
|
|
109
|
+
teamDetailPanels: { ...s.teamDetailPanels, [tid]: panel },
|
|
110
|
+
}));
|
|
111
|
+
if (tid) localStorage.setItem('groove:activeTeamId', tid);
|
|
112
|
+
},
|
|
113
|
+
|
|
98
114
|
async rotateAgent(id) {
|
|
99
115
|
try {
|
|
100
116
|
return await api.post(`/agents/${encodeURIComponent(id)}/rotate`);
|
|
@@ -156,12 +172,7 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
156
172
|
const msg = { from, text, timestamp: Date.now(), isQuery };
|
|
157
173
|
if (attachments?.length) msg.attachments = attachments;
|
|
158
174
|
history[agentId] = [...history[agentId].slice(-100), msg];
|
|
159
|
-
|
|
160
|
-
forStorage[agentId] = forStorage[agentId].map((m) => {
|
|
161
|
-
if (!m.attachments?.length) return m;
|
|
162
|
-
return { ...m, attachments: m.attachments.map(({ dataUrl, ...rest }) => rest) };
|
|
163
|
-
});
|
|
164
|
-
persistJSON('groove:chatHistory', forStorage);
|
|
175
|
+
persistChatHistory(history);
|
|
165
176
|
return { chatHistory: history };
|
|
166
177
|
});
|
|
167
178
|
},
|
|
@@ -237,7 +248,7 @@ export const createAgentsSlice = (set, get) => ({
|
|
|
237
248
|
next.add(newAgent.id);
|
|
238
249
|
return { thinkingAgents: next };
|
|
239
250
|
});
|
|
240
|
-
if (get().chatHistory[newAgent.id]?.length)
|
|
251
|
+
if (get().chatHistory[newAgent.id]?.length) persistChatHistory(get().chatHistory);
|
|
241
252
|
if (get().activityLog[newAgent.id]?.length) persistJSON('groove:activityLog', get().activityLog);
|
|
242
253
|
if (get().labAssistantAgentId === id) {
|
|
243
254
|
localStorage.setItem('groove:labAssistantAgentId', newAgent.id);
|