groove-dev 0.27.186 → 0.27.188

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 (42) hide show
  1. package/innerchat/Screenshot_2026-07-23_at_1.30.16_PM.png +0 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/index.js +6 -0
  5. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +34 -13
  6. package/node_modules/@groove-dev/daemon/src/introducer.js +2 -2
  7. package/node_modules/@groove-dev/daemon/src/process.js +13 -1
  8. package/node_modules/@groove-dev/daemon/src/teams.js +36 -4
  9. package/node_modules/@groove-dev/daemon/src/watcher.js +230 -104
  10. package/node_modules/@groove-dev/daemon/test/teams.test.js +48 -1
  11. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -10
  12. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-DOOaCFRS.js → index-fyGUwOq4.js} +222 -222
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +31 -3
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +106 -10
  18. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +1 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +6 -6
  20. package/node_modules/@groove-dev/gui/src/stores/helpers.js +28 -0
  21. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +20 -9
  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/index.js +6 -0
  26. package/packages/daemon/src/innerchat-docs.js +34 -13
  27. package/packages/daemon/src/introducer.js +2 -2
  28. package/packages/daemon/src/process.js +13 -1
  29. package/packages/daemon/src/teams.js +36 -4
  30. package/packages/daemon/src/watcher.js +230 -104
  31. package/packages/gui/dist/assets/index-Cat5pJUx.css +1 -0
  32. package/packages/gui/dist/assets/{index-DOOaCFRS.js → index-fyGUwOq4.js} +222 -222
  33. package/packages/gui/dist/index.html +2 -2
  34. package/packages/gui/package.json +1 -1
  35. package/packages/gui/src/components/agents/agent-feed.jsx +31 -3
  36. package/packages/gui/src/components/agents/agent-panel.jsx +106 -10
  37. package/packages/gui/src/lib/logpaths.js +1 -1
  38. package/packages/gui/src/stores/groove.js +6 -6
  39. package/packages/gui/src/stores/helpers.js +28 -0
  40. package/packages/gui/src/stores/slices/agents-slice.js +20 -9
  41. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +0 -1
  42. 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-DOOaCFRS.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-fyGUwOq4.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-CU8L_r5f.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-Cat5pJUx.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.186",
3
+ "version": "0.27.188",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -385,9 +385,37 @@ function InnerChatMessage({ msg, agent }) {
385
385
  );
386
386
  }
387
387
 
388
+ // Single-quote a value for safe use inside the shell command we inject.
389
+ function shq(s) {
390
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
391
+ }
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
+
402
+ // Build a `tail -f` that actually resolves. Agents usually write a log path
403
+ // relative to wherever they ran it — often a bare filename several directories
404
+ // deep — but the terminal opens in a different cwd, so a naive `tail -f name`
405
+ // fails. Absolute paths are used as-is; anything else is resolved against the
406
+ // agent's working directory, falling back to a `find` by basename when the file
407
+ // sits below that directory.
408
+ function tailCommand(path, workdir) {
409
+ if (/^[/~]/.test(path) || !workdir) return `tail -f ${shqPath(path)}`;
410
+ const base = path.split('/').pop();
411
+ return `cd ${shqPath(workdir)} 2>/dev/null; `
412
+ + `if [ -f ${shq(path)} ]; then tail -f ${shq(path)}; `
413
+ + `else tail -f "$(find . -name ${shq(base)} -type f 2>/dev/null | head -1)"; fi`;
414
+ }
415
+
388
416
  // One-click "tail" chips for any log paths the agent mentioned — saves asking
389
417
  // "what's the log file?" and hand-copying it into a terminal.
390
- function LogChips({ text }) {
418
+ function LogChips({ text, workdir }) {
391
419
  const runInTerminal = useGrooveStore((s) => s.runInTerminal);
392
420
  const paths = useMemo(() => extractLogPaths(text), [text]);
393
421
  if (paths.length === 0) return null;
@@ -397,7 +425,7 @@ function LogChips({ text }) {
397
425
  {paths.map((path) => (
398
426
  <button
399
427
  key={path}
400
- onClick={() => runInTerminal(`tail -f ${path}`)}
428
+ onClick={() => runInTerminal(tailCommand(path, workdir))}
401
429
  title={`tail -f ${path}`}
402
430
  className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-accent/10 hover:bg-accent/20 text-accent text-[11px] font-medium font-sans cursor-pointer transition-colors max-w-full"
403
431
  >
@@ -430,7 +458,7 @@ function AgentMessage({ msg, agent, answeredTo }) {
430
458
  <div className={cn('pl-3.5 py-1 border-l', answeredTo ? 'border-indigo/50' : 'border-accent')}>
431
459
  <StructuredMessage text={collapsed ? msg.text.slice(0, 600) + '...' : msg.text} />
432
460
  </div>
433
- <LogChips text={msg.text} />
461
+ <LogChips text={msg.text} workdir={agent?.workingDir} />
434
462
  {collapsed && (
435
463
  <button
436
464
  onClick={() => setCollapsed(false)}
@@ -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
- function InlineName({ agent }) {
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 inputRef = useRef(null);
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
- <h2 className="text-sm font-bold text-text-0 font-sans truncate">{agent.name}</h2>
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
- if (activeTeamId && agent.teamId && agent.teamId !== activeTeamId) return null;
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
- <InlineName agent={agent} />
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'"`(=])([~/]?(?:[\w.-]+\/)*[\w.-]+\.log)\b/g;
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
- // Clear stale persisted data on version change
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
- persistJSON('groove:chatHistory', history);
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
- persistJSON('groove:chatHistory', history);
405
+ persistChatHistory(history);
406
406
 
407
407
  const answers = { ...s.innerchatAnswers };
408
408
  if (turn.kind === 'answer') {
@@ -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
- persistJSON('groove:chatHistory', chatHistory);
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
- const forStorage = { ...history };
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) persistJSON('groove:chatHistory', get().chatHistory);
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);