groove-dev 0.27.157 → 0.27.161

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 (81) hide show
  1. package/CLAUDE.md +7 -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/process.js +130 -2
  5. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +68 -60
  6. package/node_modules/@groove-dev/gui/dist/assets/index-DpRdb7o1.js +1020 -0
  7. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +1 -0
  8. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  9. package/node_modules/@groove-dev/gui/package.json +1 -2
  10. package/node_modules/@groove-dev/gui/src/app.css +2 -2
  11. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -8
  12. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +2 -2
  13. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +2 -2
  14. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +1 -1
  15. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +17 -2
  16. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +4 -4
  17. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +1 -1
  18. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +19 -7
  19. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +122 -17
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +9 -1
  21. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +69 -38
  22. package/node_modules/@groove-dev/gui/src/views/memory.jsx +75 -30
  23. package/package.json +1 -1
  24. package/packages/cli/package.json +1 -1
  25. package/packages/daemon/package.json +1 -1
  26. package/packages/daemon/src/process.js +130 -2
  27. package/packages/daemon/src/tunnel-manager.js +68 -60
  28. package/packages/gui/dist/assets/index-DpRdb7o1.js +1020 -0
  29. package/packages/gui/dist/assets/index-Dzofq3wS.css +1 -0
  30. package/packages/gui/dist/index.html +2 -2
  31. package/packages/gui/package.json +1 -2
  32. package/packages/gui/src/app.css +2 -2
  33. package/packages/gui/src/components/agents/agent-feed.jsx +8 -8
  34. package/packages/gui/src/components/dashboard/cache-ring.jsx +2 -2
  35. package/packages/gui/src/components/dashboard/token-chart.jsx +2 -2
  36. package/packages/gui/src/components/editor/terminal.jsx +1 -1
  37. package/packages/gui/src/components/layout/welcome-splash.jsx +17 -2
  38. package/packages/gui/src/components/network/activity-chart.jsx +4 -4
  39. package/packages/gui/src/components/network/performance-dashboard.jsx +1 -1
  40. package/packages/gui/src/components/settings/quick-connect.jsx +19 -7
  41. package/packages/gui/src/components/settings/ssh-wizard.jsx +122 -17
  42. package/packages/gui/src/stores/groove.js +9 -1
  43. package/packages/gui/src/stores/slices/agents-slice.js +69 -38
  44. package/packages/gui/src/views/memory.jsx +75 -30
  45. package/node_modules/@fontsource-variable/jetbrains-mono/CHANGELOG.md +0 -2
  46. package/node_modules/@fontsource-variable/jetbrains-mono/LICENSE +0 -93
  47. package/node_modules/@fontsource-variable/jetbrains-mono/README.md +0 -48
  48. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-italic.woff2 +0 -0
  49. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  50. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-italic.woff2 +0 -0
  51. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-cyrillic-wght-normal.woff2 +0 -0
  52. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-italic.woff2 +0 -0
  53. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-greek-wght-normal.woff2 +0 -0
  54. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2 +0 -0
  55. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
  56. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  57. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  58. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-italic.woff2 +0 -0
  59. package/node_modules/@fontsource-variable/jetbrains-mono/files/jetbrains-mono-vietnamese-wght-normal.woff2 +0 -0
  60. package/node_modules/@fontsource-variable/jetbrains-mono/index.css +0 -59
  61. package/node_modules/@fontsource-variable/jetbrains-mono/metadata.json +0 -29
  62. package/node_modules/@fontsource-variable/jetbrains-mono/package.json +0 -47
  63. package/node_modules/@fontsource-variable/jetbrains-mono/scss/metadata.scss +0 -46
  64. package/node_modules/@fontsource-variable/jetbrains-mono/scss/mixins.scss +0 -193
  65. package/node_modules/@fontsource-variable/jetbrains-mono/unicode.json +0 -8
  66. package/node_modules/@fontsource-variable/jetbrains-mono/wght-italic.css +0 -59
  67. package/node_modules/@fontsource-variable/jetbrains-mono/wght.css +0 -59
  68. package/node_modules/@groove-dev/gui/dist/assets/index-B6taUF7J.js +0 -1015
  69. package/node_modules/@groove-dev/gui/dist/assets/index-BAM0QzR0.css +0 -1
  70. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  71. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  72. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  73. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  74. package/node_modules/@groove-dev/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  75. package/packages/gui/dist/assets/index-B6taUF7J.js +0 -1015
  76. package/packages/gui/dist/assets/index-BAM0QzR0.css +0 -1
  77. package/packages/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  78. package/packages/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  79. package/packages/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  80. package/packages/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  81. package/packages/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
@@ -7,7 +7,7 @@ import { useGrooveStore } from '../../stores/groove';
7
7
  import { cn } from '../../lib/cn';
8
8
  import {
9
9
  FolderSearch, Check, X, AlertTriangle, Loader2,
10
- ExternalLink, Server, KeyRound, Settings, Plug,
10
+ ExternalLink, Server, KeyRound, Settings, Plug, Terminal, Copy, RefreshCw,
11
11
  } from 'lucide-react';
12
12
 
13
13
  const STEPS = [
@@ -110,6 +110,111 @@ function InfoCard({ icon: Icon, title, iconColor, children }) {
110
110
  );
111
111
  }
112
112
 
113
+ function CopyableCommand({ command }) {
114
+ const [copied, setCopied] = useState(false);
115
+ return (
116
+ <div className="flex items-center gap-1.5 group">
117
+ <code className="flex-1 text-2xs font-mono text-text-1 bg-surface-0 px-2.5 py-1.5 rounded-md border border-border-subtle truncate">
118
+ {command}
119
+ </code>
120
+ <button
121
+ onClick={() => { navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
122
+ className="p-1.5 text-text-4 hover:text-text-1 cursor-pointer transition-colors flex-shrink-0"
123
+ title="Copy"
124
+ >
125
+ {copied ? <Check size={11} className="text-success" /> : <Copy size={11} />}
126
+ </button>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ function SetupStatus({ testResult, user, host, sshPort, testLoading, onRetest }) {
132
+ const allGood = testResult.nodeInstalled && testResult.grooveInstalled && testResult.daemonRunning;
133
+ const needsNode = !testResult.nodeInstalled;
134
+ const needsGroove = testResult.nodeInstalled && !testResult.grooveInstalled;
135
+ const needsDaemon = testResult.grooveInstalled && !testResult.daemonRunning;
136
+ const sshTarget = `${user}@${host}${sshPort !== 22 ? ` -p ${sshPort}` : ''}`;
137
+
138
+ if (allGood) {
139
+ return (
140
+ <InfoCard icon={Check} title="Ready to Connect" iconColor="bg-success/10 text-success">
141
+ <div className="space-y-2.5">
142
+ <div className="flex items-center gap-2 text-2xs font-sans">
143
+ <StatusDot status="running" size="sm" />
144
+ <span className="text-text-1">Node.js {testResult.nodeVersion}</span>
145
+ </div>
146
+ <div className="flex items-center gap-2 text-2xs font-sans">
147
+ <StatusDot status="running" size="sm" />
148
+ <span className="text-text-1">Groove Installed{testResult.remoteVersion ? ` (v${testResult.remoteVersion})` : ''}</span>
149
+ </div>
150
+ <div className="flex items-center gap-2 text-2xs font-sans">
151
+ <StatusDot status="running" size="sm" />
152
+ <span className="text-text-1">Daemon Running</span>
153
+ </div>
154
+ </div>
155
+ </InfoCard>
156
+ );
157
+ }
158
+
159
+ return (
160
+ <div className="rounded-xl border border-warning/25 bg-gradient-to-br from-warning/[0.04] to-transparent px-5 py-4">
161
+ <div className="flex items-center justify-between mb-3">
162
+ <div className="flex items-center gap-2.5">
163
+ <div className="w-7 h-7 rounded-lg bg-warning/10 flex items-center justify-center flex-shrink-0">
164
+ <Terminal size={13} className="text-warning" />
165
+ </div>
166
+ <span className="text-sm font-semibold text-text-0 font-sans">Remote Setup Required</span>
167
+ </div>
168
+ <button
169
+ onClick={onRetest}
170
+ disabled={testLoading}
171
+ className="flex items-center gap-1 text-2xs text-text-3 hover:text-text-1 font-sans cursor-pointer transition-colors disabled:opacity-50"
172
+ >
173
+ <RefreshCw size={10} className={testLoading ? 'animate-spin' : ''} />
174
+ Re-test
175
+ </button>
176
+ </div>
177
+
178
+ <div className="space-y-2.5 mb-3">
179
+ <div className="flex items-center gap-2 text-2xs font-sans">
180
+ <StatusDot status={testResult.reachable ? 'running' : 'crashed'} size="sm" />
181
+ <span className="text-text-1">Reachable</span>
182
+ </div>
183
+ <div className="flex items-center gap-2 text-2xs font-sans">
184
+ <StatusDot status={testResult.nodeInstalled ? 'running' : 'stopped'} size="sm" />
185
+ <span className="text-text-1">Node.js{testResult.nodeVersion ? ` ${testResult.nodeVersion}` : ''}</span>
186
+ </div>
187
+ <div className="flex items-center gap-2 text-2xs font-sans">
188
+ <StatusDot status={testResult.grooveInstalled ? 'running' : 'stopped'} size="sm" />
189
+ <span className="text-text-1">Groove{testResult.remoteVersion ? ` v${testResult.remoteVersion}` : ''}</span>
190
+ </div>
191
+ <div className="flex items-center gap-2 text-2xs font-sans">
192
+ <StatusDot status={testResult.daemonRunning ? 'running' : 'stopped'} size="sm" />
193
+ <span className="text-text-1">Daemon</span>
194
+ </div>
195
+ </div>
196
+
197
+ <div className="border-t border-border-subtle pt-3 space-y-2">
198
+ <p className="text-2xs text-text-3 font-sans">SSH in and run{needsNode ? '' : needsGroove ? '' : ''}:</p>
199
+ <CopyableCommand command={`ssh ${sshTarget}`} />
200
+ {needsNode && (
201
+ <>
202
+ <CopyableCommand command='curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash' />
203
+ <CopyableCommand command="source ~/.bashrc && nvm install 20" />
204
+ </>
205
+ )}
206
+ {(needsNode || needsGroove) && (
207
+ <CopyableCommand command="npm i -g groove-dev" />
208
+ )}
209
+ {(needsNode || needsGroove || needsDaemon) && (
210
+ <CopyableCommand command="groove start" />
211
+ )}
212
+ <p className="text-2xs text-text-4 font-sans mt-1">Then click Re-test above.</p>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
113
218
  export function SSHWizard({ server, onSave, onTest, onConnect, onCancel }) {
114
219
  const remoteHomedir = useGrooveStore((s) => s.remoteHomedir);
115
220
  const [step, setStep] = useState(0);
@@ -140,6 +245,13 @@ export function SSHWizard({ server, onSave, onTest, onConnect, onCancel }) {
140
245
  setAutoConnect(server.autoConnect || false);
141
246
  setCompletedSteps([0, 1]);
142
247
  setStep(2);
248
+ setTestResult(null);
249
+ setTestLoading(true);
250
+ onTest().then((result) => {
251
+ setTestResult(result);
252
+ }).catch((err) => {
253
+ setTestResult({ error: err.message || 'Test failed' });
254
+ }).finally(() => setTestLoading(false));
143
255
  } else {
144
256
  setName('');
145
257
  setHost('');
@@ -150,6 +262,7 @@ export function SSHWizard({ server, onSave, onTest, onConnect, onCancel }) {
150
262
  setAutoConnect(false);
151
263
  setCompletedSteps([]);
152
264
  setStep(0);
265
+ setTestResult(null);
153
266
  }
154
267
  }, [server]);
155
268
 
@@ -402,22 +515,14 @@ export function SSHWizard({ server, onSave, onTest, onConnect, onCancel }) {
402
515
  </FieldCard>
403
516
 
404
517
  {testResult && !testResult.error ? (
405
- <InfoCard icon={Check} title="Test Results" iconColor="bg-success/10 text-success">
406
- <div className="space-y-2.5">
407
- <div className="flex items-center gap-2 text-2xs font-sans">
408
- <StatusDot status={testResult.reachable ? 'running' : 'crashed'} size="sm" />
409
- <span className="text-text-1">Reachable</span>
410
- </div>
411
- <div className="flex items-center gap-2 text-2xs font-sans">
412
- <StatusDot status={testResult.grooveInstalled ? 'running' : 'stopped'} size="sm" />
413
- <span className="text-text-1">Groove Installed</span>
414
- </div>
415
- <div className="flex items-center gap-2 text-2xs font-sans">
416
- <StatusDot status={testResult.daemonRunning ? 'running' : 'stopped'} size="sm" />
417
- <span className="text-text-1">Daemon Running</span>
418
- </div>
419
- </div>
420
- </InfoCard>
518
+ <SetupStatus
519
+ testResult={testResult}
520
+ user={user}
521
+ host={host}
522
+ sshPort={sshPort}
523
+ testLoading={testLoading}
524
+ onRetest={handleTest}
525
+ />
421
526
  ) : (
422
527
  <InfoCard icon={Server} title={name || 'Server'}>
423
528
  <div className="space-y-2 text-2xs font-sans">
@@ -198,7 +198,11 @@ export const useGrooveStore = create((set, get) => ({
198
198
  }
199
199
  }
200
200
  for (const id of removed) delete timeline[id];
201
- set({ agents, tokenTimeline: timeline, hydrated: true });
201
+ const updates = { agents, tokenTimeline: timeline, hydrated: true };
202
+ if (removed.length > 0 && st.detailPanel?.type === 'agent' && removed.includes(st.detailPanel.agentId)) {
203
+ updates.detailPanel = null;
204
+ }
205
+ set(updates);
202
206
  break;
203
207
  }
204
208
 
@@ -366,6 +370,10 @@ export const useGrooveStore = create((set, get) => ({
366
370
  break;
367
371
  }
368
372
 
373
+ case 'recommended-team:ready':
374
+ if (!get().recommendedTeam) get().checkRecommendedTeam();
375
+ break;
376
+
369
377
  case 'phase2:spawned':
370
378
  get().addToast('info', `QC agent ${msg.name} auto-spawned`, 'Auditing phase 1 work');
371
379
  break;
@@ -169,10 +169,11 @@ export const createAgentsSlice = (set, get) => ({
169
169
  return data;
170
170
  }
171
171
 
172
- // CLI agent: was stopped + resumed/rotated — transfer state to new agent ID
172
+ // CLI agent: was stopped + resumed/rotated — transfer state to new agent ID.
173
+ // Only transfer if the rotation:complete WebSocket handler hasn't already done it.
173
174
  const newAgent = data;
174
175
  for (const key of ['chatHistory', 'activityLog', 'tokenTimeline']) {
175
- if (snapshot[key]?.length) {
176
+ if (snapshot[key]?.length && !get()[key]?.[newAgent.id]?.length) {
176
177
  set((s) => ({ [key]: { ...s[key], [newAgent.id]: [...snapshot[key]] } }));
177
178
  }
178
179
  }
@@ -380,6 +381,13 @@ export const createAgentsSlice = (set, get) => ({
380
381
  get().addChatMessage(agentId, 'system', text);
381
382
  };
382
383
 
384
+ const startThinking = () => {
385
+ set((s) => ({ thinkingAgents: new Set([...s.thinkingAgents, agentId]) }));
386
+ };
387
+ const stopThinking = () => {
388
+ set((s) => { const next = new Set(s.thinkingAgents); next.delete(agentId); return { thinkingAgents: next }; });
389
+ };
390
+
383
391
  try {
384
392
  switch (command) {
385
393
  case 'instruct': {
@@ -391,8 +399,11 @@ export const createAgentsSlice = (set, get) => ({
391
399
  if (tags.length === 0) { addSystemMsg('Usage: save #tag your message here'); return true; }
392
400
  const content = rest.replace(/#[\w/.-]+/g, '').trim();
393
401
  if (!content) { addSystemMsg('Usage: save #tag your message here'); return true; }
394
- await get().saveKeeperItem(tags[0], content);
395
- addSystemMsg(`Saved to #${tags[0]}`);
402
+ startThinking();
403
+ try {
404
+ await get().saveKeeperItem(tags[0], content);
405
+ addSystemMsg(`Saved to #${tags[0]}`);
406
+ } finally { stopThinking(); }
396
407
  return { passthrough: content };
397
408
  }
398
409
 
@@ -400,36 +411,48 @@ export const createAgentsSlice = (set, get) => ({
400
411
  if (tags.length === 0) { addSystemMsg('Usage: append #tag content to add'); return true; }
401
412
  const content = rest.replace(/#[\w/.-]+/g, '').trim();
402
413
  if (!content) { addSystemMsg('Usage: append #tag content to add'); return true; }
403
- await get().appendKeeperItem(tags[0], content);
404
- addSystemMsg(`Appended to #${tags[0]}`);
414
+ startThinking();
415
+ try {
416
+ await get().appendKeeperItem(tags[0], content);
417
+ addSystemMsg(`Appended to #${tags[0]}`);
418
+ } finally { stopThinking(); }
405
419
  return { passthrough: content };
406
420
  }
407
421
 
408
422
  case 'update': {
409
423
  if (tags.length === 0) { addSystemMsg('Usage: [update] #tag'); return true; }
410
424
  get().addChatMessage(agentId, 'user', message, false);
411
- const existing = await get().getKeeperItem(tags[0]);
412
- set({ keeperEditing: { tag: tags[0], content: existing?.content || '', isNew: !existing } });
425
+ startThinking();
426
+ try {
427
+ const existing = await get().getKeeperItem(tags[0]);
428
+ set({ keeperEditing: { tag: tags[0], content: existing?.content || '', isNew: !existing } });
429
+ } finally { stopThinking(); }
413
430
  return true;
414
431
  }
415
432
 
416
433
  case 'delete': {
417
434
  if (tags.length === 0) { addSystemMsg('Usage: [delete] #tag'); return true; }
418
435
  get().addChatMessage(agentId, 'user', message, false);
419
- await get().deleteKeeperItem(tags[0]);
420
- addSystemMsg(`Deleted #${tags[0]}`);
436
+ startThinking();
437
+ try {
438
+ await get().deleteKeeperItem(tags[0]);
439
+ addSystemMsg(`Deleted #${tags[0]}`);
440
+ } finally { stopThinking(); }
421
441
  return true;
422
442
  }
423
443
 
424
444
  case 'view': {
425
445
  if (tags.length === 0) { addSystemMsg('Usage: [view] #tag'); return true; }
426
446
  get().addChatMessage(agentId, 'user', message, false);
427
- const item = await get().getKeeperItem(tags[0]);
428
- if (item) {
429
- set({ keeperEditing: { tag: tags[0], content: item.content, isNew: false, readOnly: true } });
430
- } else {
431
- addSystemMsg(`#${tags[0]} not found`);
432
- }
447
+ startThinking();
448
+ try {
449
+ const item = await get().getKeeperItem(tags[0]);
450
+ if (item) {
451
+ set({ keeperEditing: { tag: tags[0], content: item.content, isNew: false, readOnly: true } });
452
+ } else {
453
+ addSystemMsg(`#${tags[0]} not found`);
454
+ }
455
+ } finally { stopThinking(); }
433
456
  return true;
434
457
  }
435
458
 
@@ -437,21 +460,22 @@ export const createAgentsSlice = (set, get) => ({
437
460
  if (tags.length === 0) { addSystemMsg('Usage: [read] #tag1 #tag2 ...'); return true; }
438
461
  const userText = rest.replace(/#[\w/.-]+/g, '').trim();
439
462
  get().addChatMessage(agentId, 'user', message, false);
440
- const readBrief = await api.post('/keeper/pull', { tags });
441
- if (readBrief?.brief) {
442
- const memoryBlock = `\n\n---\nContext from memories (${tags.map(t => '#' + t).join(', ')}):\n\n${readBrief.brief}`;
443
- set((s) => ({ thinkingAgents: new Set([...s.thinkingAgents, agentId]) }));
444
- await api.post(`/agents/${encodeURIComponent(agentId)}/instruct`, {
445
- message: userText ? `${userText}${memoryBlock}` : `Here is context from my tagged memories:\n\n${readBrief.brief}`,
446
- });
447
- addSystemMsg(`Sent ${tags.map(t => '#' + t).join(', ')} to agent`);
448
- } else {
449
- addSystemMsg(`No memories found for ${tags.map(t => '#' + t).join(', ')}`);
450
- if (userText) {
451
- set((s) => ({ thinkingAgents: new Set([...s.thinkingAgents, agentId]) }));
452
- await api.post(`/agents/${encodeURIComponent(agentId)}/instruct`, { message: userText });
463
+ startThinking();
464
+ try {
465
+ const readBrief = await api.post('/keeper/pull', { tags });
466
+ if (readBrief?.brief) {
467
+ const memoryBlock = `\n\n---\nContext from memories (${tags.map(t => '#' + t).join(', ')}):\n\n${readBrief.brief}`;
468
+ await api.post(`/agents/${encodeURIComponent(agentId)}/instruct`, {
469
+ message: userText ? `${userText}${memoryBlock}` : `Here is context from my tagged memories:\n\n${readBrief.brief}`,
470
+ });
471
+ addSystemMsg(`Sent ${tags.map(t => '#' + t).join(', ')} to agent`);
472
+ } else {
473
+ addSystemMsg(`No memories found for ${tags.map(t => '#' + t).join(', ')}`);
474
+ if (userText) {
475
+ await api.post(`/agents/${encodeURIComponent(agentId)}/instruct`, { message: userText });
476
+ }
453
477
  }
454
- }
478
+ } finally { stopThinking(); }
455
479
  return true;
456
480
  }
457
481
 
@@ -459,12 +483,15 @@ export const createAgentsSlice = (set, get) => ({
459
483
  if (tags.length === 0) { addSystemMsg('Usage: [doc] #tag'); return true; }
460
484
  get().addChatMessage(agentId, 'user', message, false);
461
485
  addSystemMsg(`Generating doc for #${tags[0]}...`);
462
- const history = get().chatHistory[agentId] || [];
463
- const result = await api.post('/keeper/doc', { tag: tags[0], chatHistory: history, agentId });
464
- if (result?.content) {
465
- addSystemMsg(`Doc #${tags[0]} generated (${result.size}B)`);
466
- set({ keeperEditing: { tag: tags[0], content: result.content, isNew: false } });
467
- }
486
+ startThinking();
487
+ try {
488
+ const history = get().chatHistory[agentId] || [];
489
+ const result = await api.post('/keeper/doc', { tag: tags[0], chatHistory: history, agentId });
490
+ if (result?.content) {
491
+ addSystemMsg(`Doc #${tags[0]} generated (${result.size}B)`);
492
+ set({ keeperEditing: { tag: tags[0], content: result.content, isNew: false } });
493
+ }
494
+ } finally { stopThinking(); }
468
495
  return true;
469
496
  }
470
497
 
@@ -473,12 +500,16 @@ export const createAgentsSlice = (set, get) => ({
473
500
  if (!linkMatch || tags.length === 0) { addSystemMsg('Usage: [link] #tag path/to/doc'); return true; }
474
501
  const docPath = linkMatch[2].trim();
475
502
  get().addChatMessage(agentId, 'user', message, false);
476
- await api.post('/keeper/link', { tag: tags[0], docPath });
477
- addSystemMsg(`Linked #${tags[0]} → ${docPath}`);
503
+ startThinking();
504
+ try {
505
+ await api.post('/keeper/link', { tag: tags[0], docPath });
506
+ addSystemMsg(`Linked #${tags[0]} → ${docPath}`);
507
+ } finally { stopThinking(); }
478
508
  return true;
479
509
  }
480
510
  }
481
511
  } catch (err) {
512
+ stopThinking();
482
513
  addSystemMsg(`Keeper error: ${err.message}`);
483
514
  return true;
484
515
  }
@@ -4,7 +4,7 @@ import { useGrooveStore } from '../stores/groove';
4
4
  import { Button } from '../components/ui/button';
5
5
  import { ScrollArea } from '../components/ui/scroll-area';
6
6
  import { Dialog, DialogContent } from '../components/ui/dialog';
7
- import { BookOpen, Plus, Search, Trash2, Pencil, ChevronRight, Hash, FolderOpen, Clock, Save, Link2, FileText, Sparkles, HelpCircle, GripVertical } from 'lucide-react';
7
+ import { BookOpen, Plus, Search, Trash2, Pencil, ChevronRight, Hash, FolderOpen, Clock, Save, Link2, FileText, Sparkles, HelpCircle, GripVertical, CornerLeftUp } from 'lucide-react';
8
8
 
9
9
  const COMMANDS = [
10
10
  { cmd: 'save', args: '#tag', desc: 'Save the message and send it to the agent' },
@@ -114,7 +114,7 @@ function MemoryCard({ item, onEdit, onDelete }) {
114
114
  );
115
115
  }
116
116
 
117
- function EditorModal({ open, onOpenChange, editing, onSave }) {
117
+ function EditorModal({ open, onOpenChange, editing, onSave, onRename }) {
118
118
  const [tag, setTag] = useState('');
119
119
  const [content, setContent] = useState('');
120
120
  const textareaRef = useRef(null);
@@ -132,9 +132,15 @@ function EditorModal({ open, onOpenChange, editing, onSave }) {
132
132
  }
133
133
  }, [open]);
134
134
 
135
- const handleSave = () => {
135
+ const handleSave = async () => {
136
136
  if (!tag.trim() || editing?.readOnly) return;
137
- onSave(tag.trim(), content);
137
+ const originalTag = editing?.tag || '';
138
+ const newTag = tag.trim();
139
+ if (!editing?.isNew && newTag !== originalTag) {
140
+ await onRename(originalTag, newTag, content);
141
+ } else {
142
+ onSave(newTag, content);
143
+ }
138
144
  onOpenChange(false);
139
145
  };
140
146
 
@@ -148,27 +154,30 @@ function EditorModal({ open, onOpenChange, editing, onSave }) {
148
154
  const isNew = editing?.isNew;
149
155
  const readOnly = editing?.readOnly;
150
156
  const title = readOnly ? `#${editing?.tag || ''}` : isNew ? 'New Memory' : `Edit #${editing?.tag || ''}`;
157
+ const tagChanged = !isNew && tag.trim() !== (editing?.tag || '');
151
158
 
152
159
  return (
153
160
  <Dialog open={open} onOpenChange={onOpenChange}>
154
161
  <DialogContent title={title} description="Memory content" className="max-w-2xl">
155
162
  <div className="p-5 space-y-4" onKeyDown={handleKeyDown}>
156
- {isNew && (
157
- <div>
158
- <label className="block text-xs font-medium text-text-2 mb-1.5">Tag</label>
159
- <div className="flex items-center gap-1">
160
- <span className="text-sm text-text-3">#</span>
161
- <input
162
- type="text"
163
- value={tag}
164
- onChange={(e) => setTag(e.target.value.replace(/[^a-zA-Z0-9/_-]/g, '').toLowerCase())}
165
- placeholder="project/feature-name"
166
- className="flex-1 px-2 py-1.5 text-sm font-mono rounded-md bg-surface-0 border border-border text-text-0 placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
167
- />
168
- </div>
169
- <p className="text-2xs text-text-4 mt-1">Use / for hierarchy: groove/memory-system</p>
163
+ <div>
164
+ <label className="block text-xs font-medium text-text-2 mb-1.5">
165
+ {isNew ? 'Tag' : 'Title'}
166
+ {tagChanged && <span className="ml-2 text-2xs text-warning">(will rename)</span>}
167
+ </label>
168
+ <div className="flex items-center gap-1">
169
+ <span className="text-sm text-text-3">#</span>
170
+ <input
171
+ type="text"
172
+ value={tag}
173
+ onChange={(e) => !readOnly && setTag(e.target.value.replace(/[^a-zA-Z0-9/_-]/g, '').toLowerCase())}
174
+ readOnly={readOnly}
175
+ placeholder="project/feature-name"
176
+ className="flex-1 px-2 py-1.5 text-sm font-mono rounded-md bg-surface-0 border border-border text-text-0 placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
177
+ />
170
178
  </div>
171
- )}
179
+ {isNew && <p className="text-2xs text-text-4 mt-1">Use / for hierarchy: groove/memory-system</p>}
180
+ </div>
172
181
  <div>
173
182
  {!readOnly && <label className="block text-xs font-medium text-text-2 mb-1.5">Content</label>}
174
183
  <textarea
@@ -196,7 +205,7 @@ function EditorModal({ open, onOpenChange, editing, onSave }) {
196
205
  {!readOnly && (
197
206
  <Button variant="primary" size="sm" onClick={handleSave} disabled={!tag.trim()}>
198
207
  <Save size={14} />
199
- Save
208
+ {tagChanged ? 'Rename & Save' : 'Save'}
200
209
  </Button>
201
210
  )}
202
211
  </div>
@@ -249,7 +258,7 @@ function InstructModal({ open, onOpenChange }) {
249
258
  );
250
259
  }
251
260
 
252
- function TreeItem({ tag, label, isDoc, indent, isDragOver, onSelect, onDragStart, onDragOver, onDragLeave, onDrop }) {
261
+ function TreeItem({ tag, label, isDoc, indent, isDragOver, onSelect, onEdit, onDelete, onDragStart, onDragOver, onDragLeave, onDrop }) {
253
262
  return (
254
263
  <div
255
264
  draggable
@@ -257,19 +266,34 @@ function TreeItem({ tag, label, isDoc, indent, isDragOver, onSelect, onDragStart
257
266
  onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; onDragOver?.(tag); }}
258
267
  onDragLeave={() => onDragLeave?.()}
259
268
  onDrop={(e) => { e.preventDefault(); onDrop?.(e.dataTransfer.getData('text/plain'), tag); }}
260
- onClick={() => onSelect({ tag })}
261
269
  className={`flex items-center gap-1.5 w-full px-2 py-1.5 rounded-md text-xs transition-colors cursor-pointer group ${isDragOver ? 'bg-accent/15 border border-accent/30 border-dashed' : 'hover:bg-surface-2'}`}
262
270
  style={indent ? { paddingLeft: `${8 + indent * 16}px` } : undefined}
263
271
  >
264
272
  <GripVertical size={10} className="text-text-4 opacity-0 group-hover:opacity-50 flex-shrink-0 cursor-grab" />
265
273
  <Hash size={11} className="text-text-4 flex-shrink-0" />
266
- <span className="font-medium text-text-2 truncate">{label}</span>
274
+ <span className="font-medium text-text-2 truncate flex-1" onClick={() => onSelect({ tag })}>{label}</span>
267
275
  {isDoc && <Sparkles size={9} className="text-purple flex-shrink-0" />}
276
+ <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
277
+ <button
278
+ onClick={(e) => { e.stopPropagation(); onEdit?.({ tag }); }}
279
+ className="p-1 rounded text-text-4 hover:text-accent hover:bg-accent/10 transition-colors cursor-pointer"
280
+ title="Edit"
281
+ >
282
+ <Pencil size={11} />
283
+ </button>
284
+ <button
285
+ onClick={(e) => { e.stopPropagation(); onDelete?.(tag); }}
286
+ className="p-1 rounded text-text-4 hover:text-danger hover:bg-danger/10 transition-colors cursor-pointer"
287
+ title="Delete"
288
+ >
289
+ <Trash2 size={11} />
290
+ </button>
291
+ </div>
268
292
  </div>
269
293
  );
270
294
  }
271
295
 
272
- function TreeGroup({ node, onSelect, dragOverTag, onDragStart, onDragOver, onDragLeave, onDrop }) {
296
+ function TreeGroup({ node, onSelect, onEdit, onDelete, dragOverTag, onDragStart, onDragOver, onDragLeave, onDrop }) {
273
297
  const [expanded, setExpanded] = useState(true);
274
298
  const hasChildren = node.children && node.children.length > 0;
275
299
 
@@ -278,7 +302,8 @@ function TreeGroup({ node, onSelect, dragOverTag, onDragStart, onDragOver, onDra
278
302
  <TreeItem
279
303
  tag={node.tag} label={node.tag} isDoc={node.type === 'doc'}
280
304
  isDragOver={dragOverTag === node.tag}
281
- onSelect={onSelect} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
305
+ onSelect={onSelect} onEdit={onEdit} onDelete={onDelete}
306
+ onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
282
307
  />
283
308
  );
284
309
  }
@@ -305,14 +330,16 @@ function TreeGroup({ node, onSelect, dragOverTag, onDragStart, onDragOver, onDra
305
330
  <TreeItem
306
331
  tag={node.tag} label={node.tag} isDoc={node.type === 'doc'} indent={1}
307
332
  isDragOver={false}
308
- onSelect={onSelect} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
333
+ onSelect={onSelect} onEdit={onEdit} onDelete={onDelete}
334
+ onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
309
335
  />
310
336
  )}
311
337
  {node.children.map((child) => (
312
338
  <TreeItem
313
339
  key={child.tag} tag={child.tag} label={child.tag.split('/').pop()} isDoc={child.type === 'doc'} indent={1}
314
340
  isDragOver={dragOverTag === child.tag}
315
- onSelect={onSelect} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
341
+ onSelect={onSelect} onEdit={onEdit} onDelete={onDelete}
342
+ onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}
316
343
  />
317
344
  ))}
318
345
  </div>
@@ -335,7 +362,7 @@ export default function MemoryView() {
335
362
  const setKeeperEditing = useGrooveStore((s) => s.setKeeperEditing);
336
363
 
337
364
  const [search, setSearch] = useState('');
338
- const [viewMode, setViewMode] = useState('list');
365
+ const [viewMode, setViewMode] = useState('tree');
339
366
  const [editorOpen, setEditorOpen] = useState(false);
340
367
  const [dragOverTag, setDragOverTag] = useState(null);
341
368
  const [draggingTag, setDraggingTag] = useState(null);
@@ -383,6 +410,12 @@ export default function MemoryView() {
383
410
  setKeeperEditing(null);
384
411
  };
385
412
 
413
+ const handleRename = async (oldTag, newTag, content) => {
414
+ await moveKeeperItem(oldTag, newTag);
415
+ await updateKeeperItem(newTag, content);
416
+ setKeeperEditing(null);
417
+ };
418
+
386
419
  const handleEditorClose = (open) => {
387
420
  setEditorOpen(open);
388
421
  if (!open) setKeeperEditing(null);
@@ -397,10 +430,9 @@ export default function MemoryView() {
397
430
  setDragOverTag(null);
398
431
  setDraggingTag(null);
399
432
  if (!sourceTag || !targetTag || sourceTag === targetTag) return;
400
- // Don't drop onto self or own children
401
433
  if (targetTag.startsWith(sourceTag + '/')) return;
402
434
  const sourceName = sourceTag.split('/').pop();
403
- const newTag = targetTag + '/' + sourceName;
435
+ const newTag = targetTag === '__root__' ? sourceName : targetTag + '/' + sourceName;
404
436
  if (sourceTag === newTag) return;
405
437
  try {
406
438
  await moveKeeperItem(sourceTag, newTag);
@@ -487,6 +519,7 @@ export default function MemoryView() {
487
519
  {filteredTree.map((node) => (
488
520
  <TreeGroup
489
521
  key={node.tag} node={node} onSelect={handleTreeSelect}
522
+ onEdit={handleEdit} onDelete={(tag) => deleteKeeperItem(tag)}
490
523
  dragOverTag={dragOverTag}
491
524
  onDragStart={(tag) => setDraggingTag(tag)}
492
525
  onDragOver={(tag) => { if (tag !== draggingTag) setDragOverTag(tag); }}
@@ -494,6 +527,17 @@ export default function MemoryView() {
494
527
  onDrop={handleDrop}
495
528
  />
496
529
  ))}
530
+ {draggingTag && draggingTag.includes('/') && (
531
+ <div
532
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverTag('__root__'); }}
533
+ onDragLeave={() => setDragOverTag(null)}
534
+ onDrop={(e) => { e.preventDefault(); handleDrop(e.dataTransfer.getData('text/plain'), '__root__'); }}
535
+ className={`flex items-center gap-2 px-3 py-2 mt-2 rounded-md border border-dashed text-xs transition-colors ${dragOverTag === '__root__' ? 'border-accent/50 bg-accent/10 text-accent' : 'border-border text-text-4'}`}
536
+ >
537
+ <CornerLeftUp size={12} />
538
+ <span>Drop here to move to root</span>
539
+ </div>
540
+ )}
497
541
  </div>
498
542
  )
499
543
  ) : (
@@ -518,6 +562,7 @@ export default function MemoryView() {
518
562
  onOpenChange={handleEditorClose}
519
563
  editing={keeperEditing}
520
564
  onSave={handleSave}
565
+ onRename={handleRename}
521
566
  />
522
567
 
523
568
  {/* Instruct Modal (command reference) */}
@@ -1,2 +0,0 @@
1
- # Changelog
2
- See the [Fontsource CHANGELOG.md](https://github.com/fontsource/fontsource/blob/main/CHANGELOG.md).