groove-dev 0.27.161 → 0.27.164

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 (55) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +1 -0
  4. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  5. package/node_modules/@groove-dev/daemon/src/model-lab.js +15 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +227 -105
  7. package/node_modules/@groove-dev/daemon/src/routes/teams.js +2 -0
  8. package/node_modules/@groove-dev/daemon/src/scheduler.js +1 -0
  9. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +4 -8
  10. package/node_modules/@groove-dev/gui/dist/assets/index-BJVNpGIp.css +1 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-CkCFf4Fl.js +1025 -0
  12. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  13. package/node_modules/@groove-dev/gui/package.json +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  15. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +20 -12
  16. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +5 -2
  17. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +2 -19
  18. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  19. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-content.jsx +135 -0
  20. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +105 -0
  21. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  22. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  23. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +4 -0
  24. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +71 -1
  25. package/node_modules/@groove-dev/gui/src/views/fleet.jsx +15 -0
  26. package/package.json +1 -1
  27. package/packages/cli/package.json +1 -1
  28. package/packages/daemon/package.json +1 -1
  29. package/packages/daemon/src/gateways/manager.js +1 -0
  30. package/packages/daemon/src/index.js +3 -0
  31. package/packages/daemon/src/model-lab.js +15 -0
  32. package/packages/daemon/src/process.js +227 -105
  33. package/packages/daemon/src/routes/teams.js +2 -0
  34. package/packages/daemon/src/scheduler.js +1 -0
  35. package/packages/daemon/src/tunnel-manager.js +4 -8
  36. package/packages/gui/dist/assets/index-BJVNpGIp.css +1 -0
  37. package/packages/gui/dist/assets/index-CkCFf4Fl.js +1025 -0
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/packages/gui/src/App.jsx +2 -0
  41. package/packages/gui/src/components/agents/diff-viewer.jsx +20 -12
  42. package/packages/gui/src/components/agents/spawn-wizard.jsx +5 -2
  43. package/packages/gui/src/components/agents/workspace-mode.jsx +2 -19
  44. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +176 -0
  45. package/packages/gui/src/components/fleet/fleet-content.jsx +135 -0
  46. package/packages/gui/src/components/fleet/fleet-pane.jsx +105 -0
  47. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +216 -0
  48. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  49. package/packages/gui/src/stores/slices/agents-slice.js +4 -0
  50. package/packages/gui/src/stores/slices/ui-slice.js +71 -1
  51. package/packages/gui/src/views/fleet.jsx +15 -0
  52. package/node_modules/@groove-dev/gui/dist/assets/index-DpRdb7o1.js +0 -1020
  53. package/node_modules/@groove-dev/gui/dist/assets/index-Dzofq3wS.css +0 -1
  54. package/packages/gui/dist/assets/index-DpRdb7o1.js +0 -1020
  55. package/packages/gui/dist/assets/index-Dzofq3wS.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-DpRdb7o1.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-CkCFf4Fl.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-Dzofq3wS.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BJVNpGIp.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.161",
3
+ "version": "0.27.164",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -24,6 +24,7 @@ import ModelLabView from './views/model-lab';
24
24
  import NetworkView from './views/network';
25
25
  import ChatView from './views/chat';
26
26
  import MemoryView from './views/memory';
27
+ import FleetView from './views/fleet';
27
28
 
28
29
  // Agent components
29
30
  import { AgentPanel } from './components/agents/agent-panel';
@@ -77,6 +78,7 @@ function ViewRouter() {
77
78
  case 'federation': content = <FederationView />; break;
78
79
  case 'settings': content = <SettingsView />; break;
79
80
  case 'chat': content = <ChatView />; break;
81
+ case 'fleet': content = <FleetView />; break;
80
82
  case 'memory': content = <MemoryView />; break;
81
83
  case 'network': content = networkUnlocked ? <NetworkView /> : <AgentsView />; break;
82
84
  default: content = <AgentsView />;
@@ -168,21 +168,21 @@ function SideBySideView({ pairs }) {
168
168
 
169
169
  export function DiffViewer({ filePath, gitDiffData, originalContent, modifiedContent }) {
170
170
  const file = useGrooveStore((s) => s.editorFiles[filePath]);
171
- const snapshot = useGrooveStore((s) => s.workspaceSnapshots[filePath]);
172
171
  const [viewMode, setViewMode] = useState('side-by-side');
173
- const [gitOriginal, setGitOriginal] = useState(null);
172
+ const [headContent, setHeadContent] = useState(undefined);
174
173
 
175
174
  useEffect(() => {
176
- if (gitDiffData?.original !== undefined) {
177
- setGitOriginal(gitDiffData.original);
178
- } else if (originalContent === undefined && !snapshot && !file?.originalContent) {
179
- api.get(`/files/git-show?path=${encodeURIComponent(filePath)}`).then((data) => {
180
- if (data?.content !== undefined) setGitOriginal(data.content);
181
- }).catch(() => {});
182
- }
183
- }, [filePath, gitDiffData, snapshot, file?.originalContent, originalContent]);
175
+ if (originalContent !== undefined || gitDiffData?.original !== undefined) return;
176
+ let cancelled = false;
177
+ setHeadContent(undefined);
178
+ api.get(`/files/git-show?path=${encodeURIComponent(filePath)}`).then((data) => {
179
+ if (!cancelled) setHeadContent(data?.content ?? '');
180
+ }).catch(() => { if (!cancelled) setHeadContent(''); });
181
+ return () => { cancelled = true; };
182
+ }, [filePath, gitDiffData, originalContent]);
184
183
 
185
- const original = originalContent ?? gitOriginal ?? snapshot ?? file?.originalContent ?? '';
184
+ const loading = originalContent === undefined && gitDiffData?.original === undefined && headContent === undefined;
185
+ const original = originalContent ?? gitDiffData?.original ?? headContent ?? '';
186
186
  const modified = modifiedContent ?? file?.content ?? '';
187
187
 
188
188
  const diffLines = useMemo(() => computeDiff(original, modified), [original, modified]);
@@ -197,6 +197,14 @@ export function DiffViewer({ filePath, gitDiffData, originalContent, modifiedCon
197
197
  return { adds, dels };
198
198
  }, [diffLines]);
199
199
 
200
+ if (loading) {
201
+ return (
202
+ <div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
203
+ Loading diff…
204
+ </div>
205
+ );
206
+ }
207
+
200
208
  if (!original && !modified) {
201
209
  return (
202
210
  <div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
@@ -205,7 +213,7 @@ export function DiffViewer({ filePath, gitDiffData, originalContent, modifiedCon
205
213
  );
206
214
  }
207
215
 
208
- if (original === modified && !gitDiffData) {
216
+ if (original === modified) {
209
217
  return (
210
218
  <div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
211
219
  No changes detected
@@ -94,7 +94,8 @@ export function SpawnWizard() {
94
94
  const availableModels = selectedProvider?.models || [];
95
95
  const installedProviders = providers.filter((p) => p.authType === 'api-key' ? (p.installed && p.hasKey) : p.installed);
96
96
  const isFromModelLab = !!(detailPanel?.presetProvider || detailPanel?.presetModel);
97
- const showTeamSelector = isFromModelLab || !activeTeamId;
97
+ const presetTeamId = detailPanel?.presetTeamId || null;
98
+ const showTeamSelector = !presetTeamId && (isFromModelLab || !activeTeamId);
98
99
 
99
100
  useEffect(() => {
100
101
  if (open) {
@@ -185,7 +186,9 @@ export function SpawnWizard() {
185
186
  setSpawning(true);
186
187
  try {
187
188
  let teamId;
188
- if (!showTeamSelector) {
189
+ if (presetTeamId) {
190
+ teamId = presetTeamId;
191
+ } else if (!showTeamSelector) {
189
192
  teamId = activeTeamId;
190
193
  } else if (teamMode === 'new') {
191
194
  const teamName = newTeamName.trim() || selectedRole || 'New Team';
@@ -2,7 +2,6 @@
2
2
  import { useState, useRef, useCallback, useEffect } from 'react';
3
3
  import { useGrooveStore } from '../../stores/groove';
4
4
  import { cn } from '../../lib/cn';
5
- import { api } from '../../lib/api';
6
5
  import { AgentFileTree } from './agent-file-tree';
7
6
  import { DiffViewer } from './diff-viewer';
8
7
  import { CodeReview } from './code-review';
@@ -61,9 +60,7 @@ function AgentRail({ agents, activeId, onSelect }) {
61
60
  );
62
61
  }
63
62
 
64
- function TabBar({ tabs, activeFile, files, onSelect, onClose, diffMode, onToggleDiff, workspaceSnapshots, onBackToTeam, onToggleReview, reviewMode, onCmdP }) {
65
- const hasSnapshot = activeFile && workspaceSnapshots[activeFile];
66
-
63
+ function TabBar({ tabs, activeFile, files, onSelect, onClose, diffMode, onToggleDiff, onBackToTeam, onToggleReview, reviewMode, onCmdP }) {
67
64
  return (
68
65
  <div className="flex items-stretch h-8 bg-surface-2 border-b border-border flex-shrink-0">
69
66
  <div className="flex items-stretch flex-1 min-w-0 overflow-x-auto scrollbar-none">
@@ -106,7 +103,7 @@ function TabBar({ tabs, activeFile, files, onSelect, onClose, diffMode, onToggle
106
103
  <Search size={12} />
107
104
  </button>
108
105
  </Tooltip>
109
- {hasSnapshot && (
106
+ {activeFile && (
110
107
  <>
111
108
  <button
112
109
  onClick={() => onToggleDiff(false)}
@@ -162,7 +159,6 @@ export function WorkspaceMode() {
162
159
  const setWorkspaceAgent = useGrooveStore((s) => s.setWorkspaceAgent);
163
160
  const workspaceReviewMode = useGrooveStore((s) => s.workspaceReviewMode);
164
161
  const toggleReviewMode = useGrooveStore((s) => s.toggleReviewMode);
165
- const workspaceSnapshots = useGrooveStore((s) => s.workspaceSnapshots);
166
162
  const setWorkspaceMode = useGrooveStore((s) => s.setWorkspaceMode);
167
163
  const setQuickSearchOpen = useGrooveStore((s) => s.setEditorQuickSearchOpen);
168
164
 
@@ -189,18 +185,6 @@ export function WorkspaceMode() {
189
185
  const startX = useRef(0);
190
186
  const startW = useRef(0);
191
187
 
192
- // Fetch git HEAD content as diff baseline when no snapshot exists
193
- useEffect(() => {
194
- if (!editorActiveFile || workspaceSnapshots[editorActiveFile]) return;
195
- let cancelled = false;
196
- api.get(`/files/git-show?path=${encodeURIComponent(editorActiveFile)}`).then((data) => {
197
- if (cancelled) return;
198
- const captureSnapshot = useGrooveStore.getState().captureSnapshot;
199
- captureSnapshot(editorActiveFile, data.content ?? '');
200
- }).catch(() => {});
201
- return () => { cancelled = true; };
202
- }, [editorActiveFile, workspaceSnapshots]);
203
-
204
188
  // Set the selected agent in the store so AI features use it
205
189
  useEffect(() => {
206
190
  if (agent?.id) {
@@ -323,7 +307,6 @@ export function WorkspaceMode() {
323
307
  onClose={closeFile}
324
308
  diffMode={diffMode}
325
309
  onToggleDiff={setDiffMode}
326
- workspaceSnapshots={workspaceSnapshots}
327
310
  onBackToTeam={() => setWorkspaceMode(false)}
328
311
  onToggleReview={toggleReviewMode}
329
312
  reviewMode={workspaceReviewMode}
@@ -0,0 +1,176 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useCallback, useRef, useEffect } from 'react';
3
+ import { X } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+ import { cn } from '../../lib/cn';
6
+ import { api } from '../../lib/api';
7
+
8
+ const STATUS_DOT = {
9
+ running: 'bg-accent',
10
+ starting: 'bg-text-3',
11
+ completed: 'bg-info',
12
+ crashed: 'bg-danger',
13
+ stopped: 'bg-text-4',
14
+ killed: 'bg-text-4',
15
+ rotating: 'bg-accent',
16
+ };
17
+
18
+ function ctxBarColor(pct) {
19
+ if (pct >= 75) return 'bg-danger';
20
+ if (pct >= 50) return 'bg-warning';
21
+ return 'bg-accent';
22
+ }
23
+
24
+ export function FleetAgentRow({ agent }) {
25
+ const selected = useGrooveStore((s) => s.fleetSelectedAgents);
26
+ const fleetSelectAgent = useGrooveStore((s) => s.fleetSelectAgent);
27
+ const fleetMarkRead = useGrooveStore((s) => s.fleetMarkRead);
28
+ const killAgent = useGrooveStore((s) => s.killAgent);
29
+ const unreadTs = useGrooveStore((s) => s.fleetUnreadMap[agent.id]);
30
+ const chatHistory = useGrooveStore((s) => s.chatHistory[agent.id]);
31
+
32
+ const [confirmKill, setConfirmKill] = useState(false);
33
+ const [editing, setEditing] = useState(false);
34
+ const [editName, setEditName] = useState('');
35
+ const inputRef = useRef(null);
36
+
37
+ const isSelected = selected[0] === agent.id || selected[1] === agent.id;
38
+ const ctxPct = Math.round((agent.contextUsage || 0) * 100);
39
+
40
+ const lastMsg = chatHistory?.[chatHistory.length - 1];
41
+ const hasUnread = lastMsg && (!unreadTs || lastMsg.timestamp > unreadTs) && lastMsg.from === 'agent';
42
+
43
+ useEffect(() => {
44
+ if (editing && inputRef.current) {
45
+ inputRef.current.focus();
46
+ inputRef.current.select();
47
+ }
48
+ }, [editing]);
49
+
50
+ function handleClick(e) {
51
+ if (editing) return;
52
+ if (e.metaKey || e.ctrlKey) {
53
+ fleetSelectAgent(agent.id, 1);
54
+ } else {
55
+ fleetSelectAgent(agent.id, 0);
56
+ }
57
+ fleetMarkRead(agent.id);
58
+ }
59
+
60
+ function handleDoubleClick(e) {
61
+ e.stopPropagation();
62
+ setEditing(true);
63
+ setEditName(agent.name);
64
+ }
65
+
66
+ async function commitRename() {
67
+ setEditing(false);
68
+ const trimmed = editName.trim().replace(/\s+/g, '-');
69
+ if (!trimmed || trimmed === agent.name) return;
70
+ try {
71
+ await api.patch(`/agents/${agent.id}`, { name: trimmed });
72
+ } catch { /* toast handles */ }
73
+ }
74
+
75
+ function handleKeyDown(e) {
76
+ if (e.key === 'Enter') {
77
+ e.preventDefault();
78
+ commitRename();
79
+ } else if (e.key === 'Escape') {
80
+ setEditing(false);
81
+ }
82
+ }
83
+
84
+ function handleRemove(e) {
85
+ e.stopPropagation();
86
+ const isAlive = agent.status === 'running' || agent.status === 'starting';
87
+ if (confirmKill) {
88
+ killAgent(agent.id, !isAlive);
89
+ setConfirmKill(false);
90
+ } else {
91
+ setConfirmKill(true);
92
+ setTimeout(() => setConfirmKill(false), 3000);
93
+ }
94
+ }
95
+
96
+ const handleDragStart = useCallback((e) => {
97
+ if (editing) { e.preventDefault(); return; }
98
+ e.dataTransfer.setData('application/x-fleet-agent', agent.id);
99
+ e.dataTransfer.effectAllowed = 'link';
100
+ }, [agent.id, editing]);
101
+
102
+ return (
103
+ <div
104
+ onClick={handleClick}
105
+ draggable={!editing}
106
+ onDragStart={handleDragStart}
107
+ className={cn(
108
+ 'w-full flex items-center gap-2 px-2 py-1.5 transition-colors cursor-pointer group rounded-md',
109
+ isSelected ? 'text-text-0' : 'hover:bg-surface-2',
110
+ confirmKill && 'bg-danger/10',
111
+ )}
112
+ >
113
+ {/* Status dot */}
114
+ <span className={cn('w-2 h-2 rounded-full flex-shrink-0', STATUS_DOT[agent.status] || 'bg-text-4')} />
115
+
116
+ {/* Name + role */}
117
+ <div className="flex-1 min-w-0 text-left">
118
+ {editing ? (
119
+ <input
120
+ ref={inputRef}
121
+ type="text"
122
+ value={editName}
123
+ onChange={(e) => setEditName(e.target.value)}
124
+ onBlur={commitRename}
125
+ onKeyDown={handleKeyDown}
126
+ className="w-full text-xs font-sans text-text-0 bg-surface-3 border border-border-subtle rounded px-1 py-0 leading-tight outline-none focus:border-accent"
127
+ />
128
+ ) : (
129
+ <>
130
+ <div
131
+ onDoubleClick={handleDoubleClick}
132
+ className={cn(
133
+ 'text-xs font-sans truncate leading-tight',
134
+ confirmKill ? 'text-danger' : isSelected ? 'text-accent' : 'text-text-0',
135
+ )}
136
+ >
137
+ {confirmKill ? 'Click again to remove' : agent.name}
138
+ </div>
139
+ {!confirmKill && (
140
+ <div className="text-xs text-text-3 font-sans truncate leading-tight">{agent.role}</div>
141
+ )}
142
+ </>
143
+ )}
144
+ </div>
145
+
146
+ {/* Context gauge (hidden on hover to make room for X) */}
147
+ {ctxPct > 0 && !confirmKill && !editing && (
148
+ <div className="group-hover:hidden w-8 h-1 rounded-sm bg-surface-4 overflow-hidden flex-shrink-0">
149
+ <div
150
+ className={cn('h-full rounded-sm transition-all', ctxBarColor(ctxPct))}
151
+ style={{ width: `${ctxPct}%` }}
152
+ />
153
+ </div>
154
+ )}
155
+
156
+ {/* Unread dot (hidden on hover) */}
157
+ {hasUnread && !confirmKill && !editing && (
158
+ <span className="group-hover:hidden w-1.5 h-1.5 rounded-full bg-accent flex-shrink-0" />
159
+ )}
160
+
161
+ {/* Remove button (visible on hover) */}
162
+ {!editing && (
163
+ <button
164
+ onClick={handleRemove}
165
+ className={cn(
166
+ 'hidden group-hover:flex items-center justify-center p-0.5 rounded transition-colors cursor-pointer flex-shrink-0',
167
+ confirmKill ? 'flex text-danger' : 'text-text-4 hover:text-danger',
168
+ )}
169
+ title={agent.status === 'running' || agent.status === 'starting' ? 'Kill agent' : 'Remove agent'}
170
+ >
171
+ <X size={14} />
172
+ </button>
173
+ )}
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,135 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useRef, useCallback, useState } from 'react';
3
+ import { LayoutList } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+ import { FleetPane } from './fleet-pane';
6
+
7
+ export function FleetContent() {
8
+ const selected = useGrooveStore((s) => s.fleetSelectedAgents);
9
+ const splitMode = useGrooveStore((s) => s.fleetSplitMode);
10
+ const fleetSelectAgent = useGrooveStore((s) => s.fleetSelectAgent);
11
+
12
+ const [dropTarget, setDropTarget] = useState(null);
13
+
14
+ const dragging = useRef(false);
15
+ const dividerRef = useRef(null);
16
+ const leftRef = useRef(null);
17
+ const rightRef = useRef(null);
18
+
19
+ const onDividerDown = useCallback((e) => {
20
+ e.preventDefault();
21
+ dragging.current = true;
22
+ const container = dividerRef.current?.parentElement;
23
+ if (!container) return;
24
+
25
+ function onMove(ev) {
26
+ if (!dragging.current || !container || !leftRef.current || !rightRef.current) return;
27
+ const rect = container.getBoundingClientRect();
28
+ const pct = ((ev.clientX - rect.left) / rect.width) * 100;
29
+ const clamped = Math.max(25, Math.min(75, pct));
30
+ leftRef.current.style.flex = `0 0 ${clamped}%`;
31
+ rightRef.current.style.flex = `0 0 ${100 - clamped}%`;
32
+ }
33
+
34
+ function onUp() {
35
+ dragging.current = false;
36
+ document.removeEventListener('mousemove', onMove);
37
+ document.removeEventListener('mouseup', onUp);
38
+ }
39
+
40
+ document.addEventListener('mousemove', onMove);
41
+ document.addEventListener('mouseup', onUp);
42
+ }, []);
43
+
44
+ function handleDragOver(e, side) {
45
+ const agentId = e.dataTransfer.types.includes('application/x-fleet-agent');
46
+ if (!agentId) return;
47
+ e.preventDefault();
48
+ e.dataTransfer.dropEffect = 'link';
49
+ setDropTarget(side);
50
+ }
51
+
52
+ function handleDrop(e, pane) {
53
+ e.preventDefault();
54
+ setDropTarget(null);
55
+ const agentId = e.dataTransfer.getData('application/x-fleet-agent');
56
+ if (!agentId) return;
57
+ fleetSelectAgent(agentId, pane);
58
+ }
59
+
60
+ function handleDragLeave() {
61
+ setDropTarget(null);
62
+ }
63
+
64
+ if (!selected[0] && !selected[1]) {
65
+ return (
66
+ <div
67
+ className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3"
68
+ onDragOver={(e) => handleDragOver(e, 'left')}
69
+ onDragLeave={handleDragLeave}
70
+ onDrop={(e) => handleDrop(e, 0)}
71
+ >
72
+ <LayoutList size={32} strokeWidth={1} className="text-text-4" />
73
+ <p className="text-sm font-sans">Select an agent or drag one here</p>
74
+ <p className="text-xs font-sans text-text-4">Cmd+Click or drag to open side-by-side</p>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ if (!splitMode || !selected[1]) {
80
+ return (
81
+ <div className="flex-1 flex min-w-0 min-h-0">
82
+ <div className="flex-1 min-w-0 min-h-0">
83
+ <FleetPane agentId={selected[0]} paneIndex={0} />
84
+ </div>
85
+ {/* Drop zone for second pane */}
86
+ <div
87
+ className={`w-1 flex-shrink-0 transition-all ${dropTarget === 'right' ? 'w-1 bg-accent/40' : ''}`}
88
+ onDragOver={(e) => handleDragOver(e, 'right')}
89
+ onDragLeave={handleDragLeave}
90
+ onDrop={(e) => handleDrop(e, 1)}
91
+ />
92
+ {dropTarget === 'right' && (
93
+ <div
94
+ className="w-48 flex-shrink-0 flex items-center justify-center border-2 border-dashed border-accent/30 bg-accent/5 rounded-lg m-1 transition-all"
95
+ onDragOver={(e) => handleDragOver(e, 'right')}
96
+ onDragLeave={handleDragLeave}
97
+ onDrop={(e) => handleDrop(e, 1)}
98
+ >
99
+ <p className="text-xs text-accent font-sans">Drop to open</p>
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div className="flex-1 flex min-w-0 min-h-0">
108
+ <div
109
+ ref={leftRef}
110
+ className={`min-w-0 min-h-0 ${dropTarget === 'left' ? 'ring-2 ring-inset ring-accent/30' : ''}`}
111
+ style={{ flex: '0 0 50%' }}
112
+ onDragOver={(e) => handleDragOver(e, 'left')}
113
+ onDragLeave={handleDragLeave}
114
+ onDrop={(e) => handleDrop(e, 0)}
115
+ >
116
+ <FleetPane agentId={selected[0]} paneIndex={0} />
117
+ </div>
118
+ <div
119
+ ref={dividerRef}
120
+ className="w-1 bg-border hover:bg-accent/30 cursor-col-resize transition-colors flex-shrink-0"
121
+ onMouseDown={onDividerDown}
122
+ />
123
+ <div
124
+ ref={rightRef}
125
+ className={`min-w-0 min-h-0 ${dropTarget === 'right' ? 'ring-2 ring-inset ring-accent/30' : ''}`}
126
+ style={{ flex: '0 0 calc(50% - 4px)' }}
127
+ onDragOver={(e) => handleDragOver(e, 'right')}
128
+ onDragLeave={handleDragLeave}
129
+ onDrop={(e) => handleDrop(e, 1)}
130
+ >
131
+ <FleetPane agentId={selected[1]} paneIndex={1} />
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,105 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { X } from 'lucide-react';
4
+ import { useGrooveStore } from '../../stores/groove';
5
+ import { cn } from '../../lib/cn';
6
+ import { Badge } from '../ui/badge';
7
+ import { AgentFeed } from '../agents/agent-feed';
8
+ import { fmtNum } from '../../lib/format';
9
+
10
+ const STATUS_VARIANT = {
11
+ running: 'success',
12
+ starting: 'warning',
13
+ stopped: 'default',
14
+ crashed: 'danger',
15
+ completed: 'accent',
16
+ killed: 'default',
17
+ rotating: 'purple',
18
+ };
19
+
20
+ const STATUS_LABEL = {
21
+ running: 'Running',
22
+ starting: 'Starting',
23
+ stopped: 'Stopped',
24
+ crashed: 'Crashed',
25
+ completed: 'Done',
26
+ killed: 'Killed',
27
+ rotating: 'Rotating',
28
+ };
29
+
30
+ export function FleetPane({ agentId, paneIndex }) {
31
+ const liveAgent = useGrooveStore((s) => s.agents.find((a) => a.id === agentId));
32
+ const fleetSelectAgent = useGrooveStore((s) => s.fleetSelectAgent);
33
+ const fleetMarkRead = useGrooveStore((s) => s.fleetMarkRead);
34
+
35
+ const lastAgentRef = useRef(liveAgent);
36
+ const [gone, setGone] = useState(false);
37
+ const goneTimer = useRef(null);
38
+
39
+ if (liveAgent) {
40
+ lastAgentRef.current = liveAgent;
41
+ if (gone) setGone(false);
42
+ if (goneTimer.current) { clearTimeout(goneTimer.current); goneTimer.current = null; }
43
+ }
44
+
45
+ useEffect(() => {
46
+ if (!liveAgent && agentId && !gone) {
47
+ goneTimer.current = setTimeout(() => setGone(true), 2000);
48
+ return () => { if (goneTimer.current) clearTimeout(goneTimer.current); };
49
+ }
50
+ }, [liveAgent, agentId, gone]);
51
+
52
+ useEffect(() => {
53
+ if (agentId) fleetMarkRead(agentId);
54
+ }, [agentId, fleetMarkRead]);
55
+
56
+ const agent = liveAgent || lastAgentRef.current;
57
+
58
+ if (!agent) {
59
+ return (
60
+ <div className="h-full flex items-center justify-center text-text-4">
61
+ <p className="text-xs font-sans">Select an agent</p>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ const ctxPct = Math.round((agent.contextUsage || 0) * 100);
67
+
68
+ return (
69
+ <div className="flex flex-col h-full min-h-0">
70
+ {/* Header */}
71
+ <div className="h-10 bg-surface-1 border-b border-border px-3 flex items-center gap-2 flex-shrink-0">
72
+ <span className="text-sm font-medium text-text-0 font-sans truncate">{agent.name}</span>
73
+ <Badge variant="default">{agent.role}</Badge>
74
+ {agent.provider && (
75
+ <span className="text-xs text-text-3 font-mono truncate">
76
+ {agent.provider}{agent.model ? `:${agent.model}` : ''}
77
+ </span>
78
+ )}
79
+ <Badge variant={STATUS_VARIANT[agent.status] || 'default'} dot={agent.status === 'running' ? 'pulse' : undefined}>
80
+ {STATUS_LABEL[agent.status] || agent.status}
81
+ </Badge>
82
+ <div className="flex-1" />
83
+ <span className="text-xs text-text-3 font-mono">{fmtNum(agent.tokensUsed)}</span>
84
+ <span className={cn(
85
+ 'text-xs font-mono',
86
+ ctxPct >= 75 ? 'text-danger' : ctxPct >= 50 ? 'text-warning' : 'text-text-3',
87
+ )}>
88
+ {ctxPct}%
89
+ </span>
90
+ <button
91
+ onClick={() => fleetSelectAgent(null, paneIndex)}
92
+ className="p-1 rounded-md text-text-3 hover:text-text-0 hover:bg-surface-3 transition-colors cursor-pointer"
93
+ title="Close pane"
94
+ >
95
+ <X size={14} />
96
+ </button>
97
+ </div>
98
+
99
+ {/* Agent feed */}
100
+ <div className="flex-1 min-h-0">
101
+ <AgentFeed agent={agent} />
102
+ </div>
103
+ </div>
104
+ );
105
+ }