groove-dev 0.27.183 → 0.27.185

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 (65) hide show
  1. package/.watch-test-flag-1784767927904 +1 -0
  2. package/.watch-test-flag-1784768055729 +1 -0
  3. package/.watch-test-flag-1784768066364 +1 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  7. package/node_modules/@groove-dev/daemon/src/index.js +17 -0
  8. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
  11. package/node_modules/@groove-dev/daemon/src/process.js +36 -6
  12. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
  13. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  20. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  21. package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/package.json +1 -1
  24. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  25. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
  27. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  28. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
  29. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  30. package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
  31. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  32. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  33. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  34. package/package.json +1 -1
  35. package/packages/cli/package.json +1 -1
  36. package/packages/daemon/package.json +1 -1
  37. package/packages/daemon/src/api.js +2 -0
  38. package/packages/daemon/src/index.js +17 -0
  39. package/packages/daemon/src/innerchat-docs.js +96 -0
  40. package/packages/daemon/src/innerchat.js +265 -96
  41. package/packages/daemon/src/introducer.js +31 -1
  42. package/packages/daemon/src/process.js +36 -6
  43. package/packages/daemon/src/routes/innerchat.js +109 -13
  44. package/packages/daemon/src/routes/teams.js +11 -0
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/teams.js +28 -0
  47. package/packages/daemon/src/watcher.js +258 -0
  48. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  49. package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
  50. package/packages/gui/dist/index.html +2 -2
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/app.css +1 -0
  53. package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
  54. package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
  55. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  56. package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
  57. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
  58. package/packages/gui/src/stores/groove.js +36 -16
  59. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  60. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  61. package/packages/gui/src/views/memory.jsx +56 -31
  62. package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
  63. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
  64. package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
  65. package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
@@ -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-DPjGBQ5X.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DEZwM4Hb.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-CiOy7wVS.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-CU8L_r5f.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.183",
3
+ "version": "0.27.185",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -40,6 +40,7 @@
40
40
  --color-info: #7ab0df;
41
41
  --color-purple: #b07fd5;
42
42
  --color-orange: #c4956a;
43
+ --color-indigo: #7c86e0; /* InnerChat — agent-to-agent traffic */
43
44
 
44
45
  /* Fonts */
45
46
  --font-sans: 'Inter Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -4,7 +4,7 @@ import {
4
4
  Loader2, MessageSquare, SendHorizontal, Pause,
5
5
  FileEdit, Search, Terminal, CheckCircle2, AlertCircle,
6
6
  RotateCw, Zap, Wrench, Eye, Code2, Bug,
7
- ChevronDown, Paperclip, GripHorizontal,
7
+ ChevronDown, Paperclip, GripHorizontal, ArrowLeftRight,
8
8
  FileCode, X, File, Image as ImageIcon, Film, Upload,
9
9
  } from 'lucide-react';
10
10
  import { AnimatePresence, motion } from 'framer-motion';
@@ -16,6 +16,7 @@ import { ThinkingIndicator } from '../ui/thinking-indicator';
16
16
  import { TableTree } from '../ui/table-tree';
17
17
 
18
18
  const EMPTY = [];
19
+ const EMPTY_MAP = {};
19
20
  const KEEPER_RE = /(\[(?:save|append|update|delete|view|doc|link|read|instruct)\]|#[\w/.-]+)/gi;
20
21
  const KEEPER_CMD_RE = /^\[(?:save|append|update|delete|view|doc|link|read|instruct)\]$/i;
21
22
  const KEEPER_TAG_RE = /^#[\w/.-]+$/;
@@ -339,7 +340,45 @@ function UserMessage({ msg }) {
339
340
  );
340
341
  }
341
342
 
342
- function AgentMessage({ msg, agent }) {
343
+ // An agent-to-agent turn. Deliberately styled apart from the normal feed —
344
+ // amber rail and an explicit "X → Y" header — so it reads as traffic between
345
+ // agents rather than something addressed to the user.
346
+ function InnerChatMessage({ msg, agent }) {
347
+ const [collapsed, setCollapsed] = useState(msg.text?.length > 400);
348
+ const isLong = msg.text?.length > 400;
349
+ const { peer, direction, kind } = msg.innerchat || {};
350
+ const outbound = direction === 'out';
351
+ const me = agent?.name || 'this agent';
352
+
353
+ return (
354
+ <div className="rounded-md bg-indigo/[0.04] border border-indigo/15 px-2.5 py-2">
355
+ <div className="flex items-center gap-1.5 mb-1">
356
+ <ArrowLeftRight size={10} className="text-indigo flex-shrink-0" />
357
+ <span className="text-2xs font-semibold text-indigo font-sans">InnerChat</span>
358
+ <span className="text-2xs text-text-3 font-sans truncate">
359
+ {outbound ? `${me} → ${peer?.name}` : `${peer?.name} → ${me}`}
360
+ </span>
361
+ <span className="text-2xs text-text-4 font-sans">
362
+ {kind === 'answer' ? 'answer' : outbound ? 'asked' : 'asks'}
363
+ </span>
364
+ <span className="text-[10px] text-text-4 font-sans ml-auto flex-shrink-0">{timeAgo(msg.timestamp)}</span>
365
+ </div>
366
+ <div className="border-l-2 border-indigo/40 pl-3">
367
+ <StructuredMessage text={collapsed ? msg.text.slice(0, 400) + '...' : msg.text} />
368
+ </div>
369
+ {isLong && (
370
+ <button
371
+ onClick={() => setCollapsed((c) => !c)}
372
+ className="mt-1 text-2xs text-text-4 hover:text-indigo font-sans cursor-pointer"
373
+ >
374
+ {collapsed ? 'Show more' : 'Show less'}
375
+ </button>
376
+ )}
377
+ </div>
378
+ );
379
+ }
380
+
381
+ function AgentMessage({ msg, agent, answeredTo }) {
343
382
  const [collapsed, setCollapsed] = useState(msg.text?.length > 600);
344
383
  const isLong = msg.text?.length > 600;
345
384
 
@@ -348,9 +387,16 @@ function AgentMessage({ msg, agent }) {
348
387
  <div className="flex items-center gap-2 mb-1">
349
388
  <span className="text-2xs font-semibold text-text-1 font-sans">{agent?.name || 'Agent'}</span>
350
389
  <span className="text-2xs text-text-4 font-sans">{agent?.role}</span>
390
+ {/* This reply went to another agent, not to the user */}
391
+ {answeredTo && (
392
+ <span className="flex items-center gap-1 px-1.5 py-px rounded bg-indigo/10 text-2xs font-medium text-indigo font-sans">
393
+ <ArrowLeftRight size={9} />
394
+ to {answeredTo.name}
395
+ </span>
396
+ )}
351
397
  <span className="text-[10px] text-text-4 font-sans ml-auto">{timeAgo(msg.timestamp)}</span>
352
398
  </div>
353
- <div className="border-l border-accent pl-3.5 py-1">
399
+ <div className={cn('pl-3.5 py-1 border-l', answeredTo ? 'border-indigo/50' : 'border-accent')}>
354
400
  <StructuredMessage text={collapsed ? msg.text.slice(0, 600) + '...' : msg.text} />
355
401
  </div>
356
402
  {collapsed && (
@@ -606,6 +652,8 @@ export function AgentFeed({ agent, readOnly = false }) {
606
652
  const rawActivityLog = useGrooveStore((s) => s.activityLog[agent.id]) || EMPTY;
607
653
  const instructAgent = useGrooveStore((s) => s.instructAgent);
608
654
  const isThinking = useGrooveStore((s) => s.thinkingAgents?.has(agent.id));
655
+ // Replies this agent sent to other agents, keyed by text — see innerchatAnswers.
656
+ const answerPeers = useGrooveStore((s) => s.innerchatAnswers?.[agent.id]) || EMPTY_MAP;
609
657
  const cachedChatRef = useRef(EMPTY);
610
658
  const cachedActivityRef = useRef(EMPTY);
611
659
  const cachedAgentIdRef = useRef(agent.id);
@@ -886,7 +934,15 @@ export function AgentFeed({ agent, readOnly = false }) {
886
934
  }
887
935
  if (item.from === 'user') return <UserMessage key={`user-${item._chatIdx}`} msg={item} />;
888
936
  if (item.from === 'system') return <SystemMessage key={`sys-${item._chatIdx}`} msg={item} />;
889
- return <AgentMessage key={`agent-${item._chatIdx}`} msg={item} agent={agent} />;
937
+ if (item.from === 'innerchat') return <InnerChatMessage key={`ic-${item._chatIdx}`} msg={item} agent={agent} />;
938
+ return (
939
+ <AgentMessage
940
+ key={`agent-${item._chatIdx}`}
941
+ msg={item}
942
+ agent={agent}
943
+ answeredTo={answerPeers[item.text?.trim()]?.peer}
944
+ />
945
+ );
890
946
  })}
891
947
  <AnimatePresence>
892
948
  {(sending || isThinking) && (
@@ -6,7 +6,6 @@ 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 { InnerChatRelay } from './innerchat-relay';
10
9
  import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X } from 'lucide-react';
11
10
  import { fmtNum, fmtUptime } from '../../lib/format';
12
11
  import { cn } from '../../lib/cn';
@@ -86,8 +85,6 @@ export function AgentPanel() {
86
85
  const [activeTab, setActiveTab] = useState('command');
87
86
  const cachedAgentRef = useRef(null);
88
87
 
89
- const [innerChatOpen, setInnerChatOpen] = useState(false);
90
-
91
88
  const agentId = detailPanel?.type === 'agent' ? detailPanel.agentId : null;
92
89
  const liveAgent = agentId ? agents.find((a) => a.id === agentId) : null;
93
90
  if (liveAgent) cachedAgentRef.current = liveAgent;
@@ -159,37 +156,12 @@ export function AgentPanel() {
159
156
  </button>
160
157
  );
161
158
  })}
162
- <div className="flex-1" />
163
- {activeTab === 'command' && (
164
- <button
165
- onClick={() => setInnerChatOpen((v) => !v)}
166
- className={cn(
167
- 'flex items-center gap-1 px-2 py-1.5 text-2xs font-semibold font-sans rounded transition-colors cursor-pointer border-b-2 -mb-px',
168
- innerChatOpen
169
- ? 'text-warning border-warning/60'
170
- : 'text-text-4 border-transparent hover:text-warning',
171
- )}
172
- title="InnerChat — relay message to another agent"
173
- >
174
- <MessageSquare size={11} />
175
- Relay
176
- </button>
177
- )}
178
159
  </div>
179
160
  </div>
180
161
 
181
162
  {/* ── Tab Content ────────────────────────────────────── */}
182
163
  <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
183
- {activeTab === 'command' && (
184
- <>
185
- <div className="flex-1 min-h-0 overflow-hidden">
186
- <AgentFeed agent={agent} />
187
- </div>
188
- {innerChatOpen && (
189
- <InnerChatRelay fromAgent={agent} onClose={() => setInnerChatOpen(false)} />
190
- )}
191
- </>
192
- )}
164
+ {activeTab === 'command' && <AgentFeed agent={agent} />}
193
165
  {activeTab === 'config' && <AgentConfig agent={agent} />}
194
166
  {activeTab === 'telemetry' && <AgentTelemetry agent={agent} />}
195
167
  {activeTab === 'mdfiles' && <AgentMdFiles agent={agent} />}
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useCallback, useRef, useEffect } from 'react';
3
- import { X } from 'lucide-react';
3
+ import { X, Pencil } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { api } from '../../lib/api';
@@ -26,6 +26,7 @@ export function FleetAgentRow({ agent }) {
26
26
  const fleetSelectAgent = useGrooveStore((s) => s.fleetSelectAgent);
27
27
  const fleetMarkRead = useGrooveStore((s) => s.fleetMarkRead);
28
28
  const killAgent = useGrooveStore((s) => s.killAgent);
29
+ const addToast = useGrooveStore((s) => s.addToast);
29
30
  const unreadTs = useGrooveStore((s) => s.fleetUnreadMap[agent.id]);
30
31
  const chatHistory = useGrooveStore((s) => s.chatHistory[agent.id]);
31
32
 
@@ -69,7 +70,10 @@ export function FleetAgentRow({ agent }) {
69
70
  if (!trimmed || trimmed === agent.name) return;
70
71
  try {
71
72
  await api.patch(`/agents/${agent.id}`, { name: trimmed });
72
- } catch { /* toast handles */ }
73
+ } catch (err) {
74
+ // Renames can legitimately fail — duplicate name, illegal characters.
75
+ addToast('error', 'Rename failed', err.message);
76
+ }
73
77
  }
74
78
 
75
79
  function handleKeyDown(e) {
@@ -158,6 +162,18 @@ export function FleetAgentRow({ agent }) {
158
162
  <span className="group-hover:hidden w-1.5 h-1.5 rounded-full bg-accent flex-shrink-0" />
159
163
  )}
160
164
 
165
+ {/* Rename — an explicit affordance, since double-click competes with
166
+ the row's drag handler and click-to-select */}
167
+ {!editing && !confirmKill && (
168
+ <button
169
+ onClick={(e) => { e.stopPropagation(); setEditing(true); setEditName(agent.name); }}
170
+ className="hidden group-hover:flex items-center justify-center p-0.5 rounded text-text-4 hover:text-accent transition-colors cursor-pointer flex-shrink-0"
171
+ title="Rename agent"
172
+ >
173
+ <Pencil size={11} />
174
+ </button>
175
+ )}
176
+
161
177
  {/* Remove button (visible on hover) */}
162
178
  {!editing && (
163
179
  <button
@@ -1,11 +1,10 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { X, PanelRight, MessageSquare } from 'lucide-react';
3
+ import { X, PanelRight } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { Badge } from '../ui/badge';
7
7
  import { AgentFeed } from '../agents/agent-feed';
8
- import { InnerChatRelay } from '../agents/innerchat-relay';
9
8
  import { fmtNum } from '../../lib/format';
10
9
 
11
10
  const STATUS_VARIANT = {
@@ -40,7 +39,6 @@ export function FleetPane({ agentId, paneIndex, readOnly = false }) {
40
39
  const openDetail = useGrooveStore((s) => s.openDetail);
41
40
  const detailPanel = useGrooveStore((s) => s.detailPanel);
42
41
  const isPanelOpen = detailPanel?.type === 'agent' && detailPanel?.agentId === resolvedId;
43
- const [relayOpen, setRelayOpen] = useState(false);
44
42
 
45
43
  const lastAgentRef = useRef(liveAgent);
46
44
  const [gone, setGone] = useState(false);
@@ -97,16 +95,6 @@ export function FleetPane({ agentId, paneIndex, readOnly = false }) {
97
95
  )}>
98
96
  {ctxPct}%
99
97
  </span>
100
- <button
101
- onClick={() => setRelayOpen((v) => !v)}
102
- className={cn(
103
- 'p-1 rounded-md transition-colors cursor-pointer',
104
- relayOpen ? 'text-warning' : 'text-text-3 hover:text-warning hover:bg-surface-3',
105
- )}
106
- title="Relay a message to another agent"
107
- >
108
- <MessageSquare size={14} />
109
- </button>
110
98
  <button
111
99
  onClick={() => openDetail({ type: 'agent', agentId: resolvedId })}
112
100
  className={cn(
@@ -130,8 +118,6 @@ export function FleetPane({ agentId, paneIndex, readOnly = false }) {
130
118
  <div className="flex-1 min-h-0">
131
119
  <AgentFeed agent={agent} readOnly={readOnly} />
132
120
  </div>
133
-
134
- {relayOpen && <InnerChatRelay fromAgent={agent} onClose={() => setRelayOpen(false)} />}
135
121
  </div>
136
122
  );
137
123
  }
@@ -5,6 +5,9 @@ import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { FleetAgentRow } from './fleet-agent-row';
7
7
 
8
+ const AGENT_DRAG = 'application/x-fleet-agent';
9
+ const TEAM_DRAG = 'application/x-fleet-team';
10
+
8
11
  function teamStatusDot(agents) {
9
12
  if (agents.some((a) => a.status === 'crashed')) return 'bg-danger';
10
13
  if (agents.some((a) => a.status === 'running' || a.status === 'starting')) return 'bg-accent';
@@ -27,6 +30,7 @@ export function FleetSidebar({ width }) {
27
30
  const addToast = useGrooveStore((s) => s.addToast);
28
31
 
29
32
  const moveAgentToTeam = useGrooveStore((s) => s.moveAgentToTeam);
33
+ const reorderTeams = useGrooveStore((s) => s.reorderTeams);
30
34
 
31
35
  const [dropTeamId, setDropTeamId] = useState(null);
32
36
  const [confirmDeleteTeam, setConfirmDeleteTeam] = useState(null);
@@ -128,20 +132,27 @@ export function FleetSidebar({ width }) {
128
132
  setRenamingTeamId(null);
129
133
  }
130
134
 
131
- // Team headers accept agent rows dragged from any other team.
135
+ // A team block accepts two kinds of drop: an agent row (reassign its team)
136
+ // or another team header (reorder the list).
132
137
  function handleDragOver(e, teamId) {
133
- if (!e.dataTransfer.types.includes('application/x-fleet-agent')) return;
138
+ const { types } = e.dataTransfer;
139
+ const isAgent = types.includes(AGENT_DRAG);
140
+ const isTeam = types.includes(TEAM_DRAG);
141
+ if (!isAgent && !isTeam) return;
134
142
  e.preventDefault();
135
- e.dataTransfer.dropEffect = 'link';
143
+ e.dataTransfer.dropEffect = isAgent ? 'link' : 'move';
136
144
  if (dropTeamId !== teamId) setDropTeamId(teamId);
137
145
  }
138
146
 
139
147
  function handleDrop(e, teamId) {
140
- const agentId = e.dataTransfer.getData('application/x-fleet-agent');
141
148
  setDropTeamId(null);
142
- if (!agentId) return;
149
+ const agentId = e.dataTransfer.getData(AGENT_DRAG);
150
+ const draggedTeamId = e.dataTransfer.getData(TEAM_DRAG);
151
+ if (!agentId && !draggedTeamId) return;
143
152
  e.preventDefault();
144
- moveAgentToTeam(agentId, teamId);
153
+
154
+ if (agentId) moveAgentToTeam(agentId, teamId);
155
+ else if (draggedTeamId !== teamId) reorderTeams(draggedTeamId, teamId);
145
156
  }
146
157
 
147
158
  function startRename(e, team) {
@@ -200,11 +211,18 @@ export function FleetSidebar({ width }) {
200
211
  }}
201
212
  onDrop={(e) => handleDrop(e, team.id)}
202
213
  >
203
- {/* Team header */}
204
- <div className={cn(
205
- 'w-full flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-surface-2 transition-colors group',
206
- isConfirming && 'bg-danger/10 hover:bg-danger/20',
207
- )}>
214
+ {/* Team header — draggable to reorder the sidebar list */}
215
+ <div
216
+ draggable={renamingTeamId !== team.id}
217
+ onDragStart={(e) => {
218
+ e.dataTransfer.setData(TEAM_DRAG, team.id);
219
+ e.dataTransfer.effectAllowed = 'move';
220
+ }}
221
+ className={cn(
222
+ 'w-full flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-surface-2 transition-colors group',
223
+ isConfirming && 'bg-danger/10 hover:bg-danger/20',
224
+ )}
225
+ >
208
226
  {renamingTeamId === team.id ? (
209
227
  <div className="flex items-center gap-1.5 flex-1 min-w-0 pl-1">
210
228
  <input
@@ -372,36 +372,50 @@ export const useGrooveStore = create((set, get) => ({
372
372
  case 'innerchat:turn': {
373
373
  const { thread, turn } = msg.data;
374
374
  if (turn.status === 'failed') {
375
- get().addToast('error', `Relay to ${turn.to.name} failed`, turn.error);
375
+ get().addToast('error', `InnerChat to ${turn.to.name} failed`, turn.error);
376
+ break;
376
377
  }
377
378
 
378
- // The turn lands in the recipient's chat, badged with who sent it.
379
379
  set((s) => {
380
380
  const history = { ...s.chatHistory };
381
- const arr = [...(history[turn.to.id] || [])];
382
- arr.push({
381
+ const entry = (peer, direction) => ({
383
382
  from: 'innerchat',
384
383
  text: turn.text,
385
384
  timestamp: turn.timestamp,
386
385
  isQuery: false,
387
- innerchat: {
388
- turnId: turn.id,
389
- threadId: thread.id,
390
- kind: turn.kind,
391
- fromAgent: turn.from,
392
- },
386
+ innerchat: { turnId: turn.id, threadId: thread.id, kind: turn.kind, peer, direction },
393
387
  });
394
- history[turn.to.id] = arr.slice(-100);
388
+ const push = (agentId, item) => {
389
+ history[agentId] = [...(history[agentId] || []), item].slice(-100);
390
+ };
391
+
392
+ if (turn.kind === 'ask') {
393
+ // Both sides see the question — the asker as outgoing, the
394
+ // target as incoming — so neither feed has an unexplained gap.
395
+ push(turn.from.id, entry(turn.to, 'out'));
396
+ push(turn.to.id, entry(turn.from, 'in'));
397
+ } else {
398
+ // The answer reaches the asker over HTTP, so only it needs a
399
+ // feed entry. The responder already shows this text as its own
400
+ // agent message; that one gets badged via innerchatAnswers
401
+ // rather than duplicated here.
402
+ push(turn.to.id, entry(turn.from, 'in'));
403
+ }
404
+
395
405
  persistJSON('groove:chatHistory', history);
396
406
 
407
+ const answers = { ...s.innerchatAnswers };
408
+ if (turn.kind === 'answer') {
409
+ answers[turn.from.id] = {
410
+ ...(answers[turn.from.id] || {}),
411
+ [turn.text.trim()]: { peer: turn.to, threadId: thread.id },
412
+ };
413
+ }
414
+
397
415
  const threads = { ...s.innerchatThreads, [thread.id]: thread };
398
416
  persistJSON('groove:innerchatThreads', threads);
399
- return { chatHistory: history, innerchatThreads: threads };
417
+ return { chatHistory: history, innerchatThreads: threads, innerchatAnswers: answers };
400
418
  });
401
-
402
- if (turn.kind === 'reply' && turn.status !== 'failed') {
403
- get().addToast('info', `${turn.from.name} replied`, `Forwarded to ${turn.to.name}`);
404
- }
405
419
  break;
406
420
  }
407
421
 
@@ -605,6 +619,12 @@ export const useGrooveStore = create((set, get) => ({
605
619
  get().fetchTeams();
606
620
  break;
607
621
 
622
+ // Reordering already applied optimistically on the client that did it;
623
+ // this keeps every other window in sync.
624
+ case 'teams:reordered':
625
+ if (msg.teams) set({ teams: msg.teams });
626
+ break;
627
+
608
628
  case 'approval:request':
609
629
  set((s) => ({ pendingApprovals: [...s.pendingApprovals, msg.data] }));
610
630
  break;
@@ -123,6 +123,12 @@ export const createAgentsSlice = (set, get) => ({
123
123
 
124
124
  innerchatThreads: loadJSON('groove:innerchatThreads') || {},
125
125
 
126
+ // agentId -> { <answer text>: { peer, threadId } }. An agent's answer to
127
+ // another agent arrives as an ordinary result, indistinguishable in its own
128
+ // feed from a message meant for the user — this is what lets the feed badge
129
+ // it. Keyed by text so it works regardless of which event lands first.
130
+ innerchatAnswers: {},
131
+
126
132
  // Relay a message from one agent to another. Pass threadId to continue an
127
133
  // existing exchange rather than opening a new one.
128
134
  async sendInnerChat(fromId, toId, message, threadId = null) {
@@ -173,6 +173,28 @@ export const createTeamsSlice = (set, get) => ({
173
173
  }
174
174
  },
175
175
 
176
+ // Move a team to a new index in the sidebar. Applied optimistically so the
177
+ // drop lands instantly, then reconciled with whatever the daemon persisted.
178
+ async reorderTeams(draggedId, targetId) {
179
+ const current = get().teams;
180
+ const from = current.findIndex((t) => t.id === draggedId);
181
+ const to = current.findIndex((t) => t.id === targetId);
182
+ if (from === -1 || to === -1 || from === to) return;
183
+
184
+ const next = [...current];
185
+ const [moved] = next.splice(from, 1);
186
+ next.splice(to, 0, moved);
187
+ set({ teams: next });
188
+
189
+ try {
190
+ const { teams } = await api.post('/teams/reorder', { orderedIds: next.map((t) => t.id) });
191
+ if (teams) set({ teams });
192
+ } catch (err) {
193
+ set({ teams: current });
194
+ get().addToast('error', 'Failed to reorder teams', err.message);
195
+ }
196
+ },
197
+
176
198
  async promoteTeam(id) {
177
199
  try {
178
200
  const team = await api.post(`/teams/${encodeURIComponent(id)}/promote`);
@@ -6,16 +6,19 @@ import { ScrollArea } from '../components/ui/scroll-area';
6
6
  import { Dialog, DialogContent } from '../components/ui/dialog';
7
7
  import { BookOpen, Plus, Search, Trash2, Pencil, ChevronRight, Hash, FolderOpen, Clock, Save, Link2, FileText, Sparkles, HelpCircle, GripVertical, CornerLeftUp } from 'lucide-react';
8
8
 
9
+ // `cmd` is the bare word; the UI always renders it bracketed so the real
10
+ // syntax — [cmd] #tag — is never ambiguous. `example` shows a full, copyable
11
+ // line so a new user can see exactly what to type.
9
12
  const COMMANDS = [
10
- { cmd: 'save', args: '#tag', desc: 'Save the message and send it to the agent' },
11
- { cmd: 'append', args: '#tag', desc: 'Add to an existing memory and send to agent' },
12
- { cmd: 'update', args: '#tag', desc: 'Open the editor to modify a memory in place' },
13
- { cmd: 'delete', args: '#tag', desc: 'Remove a memory permanently' },
14
- { cmd: 'view', args: '#tag', desc: 'Read a memory in the viewer' },
15
- { cmd: 'read', args: '#tag1 #tag2 ...', desc: 'Send memory content to the agent chat stays clean' },
16
- { cmd: 'doc', args: '#tag', desc: 'AI synthesizes the full conversation into a document' },
17
- { cmd: 'link', args: '#tag path/to/doc', desc: 'Link a memory to a NORTHSTAR or external document' },
18
- { cmd: '[instruct]', args: '', desc: 'Show this command reference' },
13
+ { cmd: 'save', example: '[save] #api-auth', desc: 'Save the current message as a new memory under #api-auth' },
14
+ { cmd: 'append', example: '[append] #api-auth', desc: 'Add the current message to the existing #api-auth memory' },
15
+ { cmd: 'update', example: '[update] #api-auth', desc: 'Open the editor to change the #api-auth memory' },
16
+ { cmd: 'view', example: '[view] #api-auth', desc: 'Open a memory to read it, without sending it to the agent' },
17
+ { cmd: 'read', example: '[read] #api-auth #db-schema', desc: 'Send one or more memories to the agent as context (chat stays clean)' },
18
+ { cmd: 'doc', example: '[doc] #api-auth', desc: 'Let the AI synthesize the conversation into a memory' },
19
+ { cmd: 'link', example: '[link] #api-auth docs/spec.md', desc: 'Link a memory to a NORTHSTAR or external document' },
20
+ { cmd: 'delete', example: '[delete] #api-auth', desc: 'Delete a memory permanently' },
21
+ { cmd: 'instruct', example: '[instruct]', desc: 'Show this command reference' },
19
22
  ];
20
23
 
21
24
  function formatRelative(iso) {
@@ -219,36 +222,58 @@ function EditorModal({ open, onOpenChange, editing, onSave, onRename }) {
219
222
  function InstructModal({ open, onOpenChange }) {
220
223
  return (
221
224
  <Dialog open={open} onOpenChange={onOpenChange}>
222
- <DialogContent title="Keeper Commands" description="Memory system command reference" className="max-w-lg">
225
+ <DialogContent title="Memory Commands" description="How to save and recall tagged memories" className="max-w-lg">
223
226
  <div className="p-5 space-y-4">
224
- <p className="text-xs text-text-2 leading-relaxed">
225
- Type these commands in any agent chat to manage your tagged memories. Commands are intercepted by the Keeper sidecar — the agent never sees them.
226
- </p>
227
- <div className="space-y-1.5">
227
+ {/* Format primer — the part new users miss */}
228
+ <div className="rounded-lg bg-surface-2 border border-border-subtle p-3 space-y-2">
229
+ <p className="text-xs text-text-2 leading-relaxed">
230
+ Type a command in any agent chat. The format is always the command in
231
+ <span className="font-mono text-accent"> [square brackets]</span>, then a
232
+ <span className="font-mono text-accent"> #tag</span>:
233
+ </p>
234
+ <div className="flex items-center gap-2 font-mono text-sm">
235
+ <span className="px-1.5 py-0.5 rounded bg-accent/15 text-accent font-semibold">[save]</span>
236
+ <span className="px-1.5 py-0.5 rounded bg-info/10 text-info font-semibold">#api-auth</span>
237
+ <span className="text-text-3 text-xs font-sans">your message here</span>
238
+ </div>
239
+ <p className="text-xs text-text-4 leading-relaxed">
240
+ These commands are handled by GROOVE — the agent never sees them.
241
+ </p>
242
+ </div>
243
+
244
+ <div className="space-y-1">
228
245
  {COMMANDS.map((c) => (
229
246
  <div key={c.cmd} className="flex items-start gap-3 py-1.5 border-b border-border-subtle last:border-0">
230
- <div className="flex-shrink-0 flex items-center gap-1">
231
- <span className="px-1.5 py-0.5 rounded bg-accent/15 text-accent font-mono text-xs font-semibold">{c.cmd}</span>
232
- {c.args && <span className="text-xs text-text-3 font-mono">{c.args}</span>}
233
- </div>
247
+ <code className="flex-shrink-0 w-44 text-xs font-mono text-text-1 pt-0.5">
248
+ {c.example.split(/(\[[^\]]+\]|#[\w/-]+)/).map((part, i) => {
249
+ if (/^\[.+\]$/.test(part)) return <span key={i} className="text-accent font-semibold">{part}</span>;
250
+ if (/^#/.test(part)) return <span key={i} className="text-info">{part}</span>;
251
+ return <span key={i} className="text-text-4">{part}</span>;
252
+ })}
253
+ </code>
234
254
  <span className="text-xs text-text-3 pt-0.5">{c.desc}</span>
235
255
  </div>
236
256
  ))}
237
257
  </div>
238
- <div className="pt-2 space-y-2">
239
- <h3 className="text-xs font-semibold text-text-1">Tag Hierarchy</h3>
240
- <p className="text-xs text-text-3 leading-relaxed">
241
- Use <span className="font-mono text-accent">/</span> to create nested tags: <span className="font-mono text-accent">#groove/memory-system</span> lives under <span className="font-mono text-accent">#groove</span>. Pull a parent tag to get all children.
242
- </p>
243
- <h3 className="text-xs font-semibold text-text-1">Memory Types</h3>
244
- <p className="text-xs text-text-3 leading-relaxed">
245
- <span className="font-semibold">Manual</span> — you write it via [save], [append], [update]. <span className="font-semibold">Doc</span> AI writes it via [doc], synthesizing your conversation. Both are fully editable.
246
- </p>
247
- <h3 className="text-xs font-semibold text-text-1">Spawning with Context</h3>
248
- <p className="text-xs text-text-3 leading-relaxed">
249
- When spawning a new agent, use [pull] #tag to inject memories into their context. You can also link memories to NORTHSTAR docs with [link] for cross-referencing.
250
- </p>
258
+
259
+ <div className="pt-1 space-y-2.5">
260
+ <div>
261
+ <h3 className="text-xs font-semibold text-text-1 mb-1">Nested tags</h3>
262
+ <p className="text-xs text-text-3 leading-relaxed">
263
+ Use <span className="font-mono text-accent">/</span> to nest:
264
+ <span className="font-mono text-info"> #groove/memory</span> lives under
265
+ <span className="font-mono text-info"> #groove</span>. Reading the parent tag pulls every memory beneath it.
266
+ </p>
267
+ </div>
268
+ <div>
269
+ <h3 className="text-xs font-semibold text-text-1 mb-1">Give a new agent context</h3>
270
+ <p className="text-xs text-text-3 leading-relaxed">
271
+ Use <span className="font-mono"><span className="text-accent">[read]</span> <span className="text-info">#tag</span></span> to
272
+ drop saved memories into an agent's chat as background — useful right after spawning one.
273
+ </p>
274
+ </div>
251
275
  </div>
276
+
252
277
  <div className="flex justify-end pt-1">
253
278
  <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
254
279
  </div>