groove-dev 0.27.168 → 0.27.171

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 (62) hide show
  1. package/default/Screenshot_2026-05-29_at_11.16.28_PM.png +0 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/index.js +15 -2
  5. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  6. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +7 -1
  7. package/node_modules/@groove-dev/daemon/src/registry.js +3 -0
  8. package/node_modules/@groove-dev/daemon/src/routes/files.js +18 -5
  9. package/node_modules/@groove-dev/daemon/src/state.js +7 -2
  10. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +16 -6
  11. package/node_modules/@groove-dev/daemon/src/validate.js +1 -0
  12. package/node_modules/@groove-dev/gui/dist/assets/index-BrMU-6gi.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BsCp-oqa.js +1030 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +6 -0
  17. package/node_modules/@groove-dev/gui/src/components/agents/folder-browser.jsx +39 -11
  18. package/node_modules/@groove-dev/gui/src/components/agents/recommended-team-card.jsx +300 -0
  19. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +30 -1
  20. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +64 -42
  21. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-content.jsx +18 -4
  22. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +125 -44
  23. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +4 -4
  24. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +64 -33
  25. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +74 -72
  26. package/node_modules/@groove-dev/gui/src/views/agents.jsx +2 -11
  27. package/node_modules/@groove-dev/gui/src/views/editor.jsx +63 -2
  28. package/node_modules/@groove-dev/gui/src/views/settings.jsx +2 -1
  29. package/package.json +1 -1
  30. package/packages/cli/package.json +1 -1
  31. package/packages/daemon/package.json +1 -1
  32. package/packages/daemon/src/index.js +15 -2
  33. package/packages/daemon/src/process.js +1 -1
  34. package/packages/daemon/src/providers/claude-code.js +7 -1
  35. package/packages/daemon/src/registry.js +3 -0
  36. package/packages/daemon/src/routes/files.js +18 -5
  37. package/packages/daemon/src/state.js +7 -2
  38. package/packages/daemon/src/tunnel-manager.js +16 -6
  39. package/packages/daemon/src/validate.js +1 -0
  40. package/packages/gui/dist/assets/index-BrMU-6gi.css +1 -0
  41. package/packages/gui/dist/assets/index-BsCp-oqa.js +1030 -0
  42. package/packages/gui/dist/index.html +2 -2
  43. package/packages/gui/package.json +1 -1
  44. package/packages/gui/src/components/agents/agent-feed.jsx +6 -0
  45. package/packages/gui/src/components/agents/folder-browser.jsx +39 -11
  46. package/packages/gui/src/components/agents/recommended-team-card.jsx +300 -0
  47. package/packages/gui/src/components/agents/spawn-wizard.jsx +30 -1
  48. package/packages/gui/src/components/editor/terminal.jsx +64 -42
  49. package/packages/gui/src/components/fleet/fleet-content.jsx +18 -4
  50. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +125 -44
  51. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +4 -4
  52. package/packages/gui/src/components/settings/quick-connect.jsx +64 -33
  53. package/packages/gui/src/components/settings/ssh-wizard.jsx +74 -72
  54. package/packages/gui/src/views/agents.jsx +2 -11
  55. package/packages/gui/src/views/editor.jsx +63 -2
  56. package/packages/gui/src/views/settings.jsx +2 -1
  57. package/node_modules/@groove-dev/gui/dist/assets/index-BJVNpGIp.css +0 -1
  58. package/node_modules/@groove-dev/gui/dist/assets/index-CSMIQsrG.js +0 -1025
  59. package/packages/gui/dist/assets/index-BJVNpGIp.css +0 -1
  60. package/packages/gui/dist/assets/index-CSMIQsrG.js +0 -1025
  61. package/ssh/error.png +0 -0
  62. package/terminal/Screenshot_2026-05-19_at_12.20.15_PM.png +0 -0
@@ -75,7 +75,10 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
75
75
  onSelectionChange?.(text || '');
76
76
  });
77
77
 
78
+ // ── Spawn logic ──────────────────────────────────────────────
78
79
  let spawnAttempts = 0;
80
+ let outputReady = false;
81
+
79
82
  function trySpawn() {
80
83
  spawnAttempts++;
81
84
  const ws = useGrooveStore.getState().ws;
@@ -85,7 +88,6 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
85
88
  }
86
89
 
87
90
  const requestId = `spawn-${++spawnSeq}`;
88
- const initialCols = term.cols;
89
91
  ws.send(JSON.stringify({ type: 'terminal:spawn', cols: term.cols, rows: term.rows, requestId }));
90
92
 
91
93
  function onMessage(event) {
@@ -94,28 +96,31 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
94
96
 
95
97
  if (msg.type === 'terminal:spawned' && msg.requestId === requestId && !termIdRef.current) {
96
98
  termIdRef.current = msg.id;
99
+ // Give the shell time to start and the layout to fully settle,
100
+ // then correct dimensions, wipe any garbled output, and redraw.
97
101
  setTimeout(() => {
98
- try { fitRef.current?.fit(); } catch {}
102
+ try { fitAddon.fit(); } catch {}
99
103
  const c = term.cols, r = term.rows;
100
104
  if (c > 1 && r > 1 && termIdRef.current) {
101
105
  const w = useGrooveStore.getState().ws;
102
106
  if (w?.readyState === WebSocket.OPEN) {
103
107
  w.send(JSON.stringify({ type: 'terminal:resize', id: termIdRef.current, rows: r, cols: c }));
104
108
  lastSizeRef.current = { cols: c, rows: r };
105
- if (c !== initialCols) {
106
- setTimeout(() => {
107
- term.clear();
108
- if (w.readyState === WebSocket.OPEN && termIdRef.current) {
109
- w.send(JSON.stringify({ type: 'terminal:input', id: termIdRef.current, data: '\x0c' }));
110
- }
111
- }, 80);
112
- }
113
109
  }
114
110
  }
115
- }, 50);
111
+ // Wipe xterm so garbled output from wrong-sized PTY is never visible
112
+ term.reset();
113
+ outputReady = true;
114
+ // Ask the shell to clear screen and redraw its prompt
115
+ const w2 = useGrooveStore.getState().ws;
116
+ if (w2?.readyState === WebSocket.OPEN && termIdRef.current) {
117
+ w2.send(JSON.stringify({ type: 'terminal:input', id: termIdRef.current, data: '\x0c' }));
118
+ }
119
+ }, 300);
116
120
  } else if (msg.type === 'terminal:output' && msg.id === termIdRef.current) {
117
- term.write(msg.data);
121
+ if (outputReady) term.write(msg.data);
118
122
  } else if (msg.type === 'terminal:exit' && msg.id === termIdRef.current) {
123
+ outputReady = true;
119
124
  term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n');
120
125
  termIdRef.current = null;
121
126
  }
@@ -131,59 +136,86 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
131
136
  }
132
137
  });
133
138
 
139
+ // Debounce resize messages so rapid drag doesn't flood
140
+ // the PTY with SIGWINCHs that cause staircase redraws
141
+ let resizeTimer = null;
134
142
  term.onResize(({ cols, rows }) => {
135
143
  if (cols === lastSizeRef.current.cols && rows === lastSizeRef.current.rows) return;
136
144
  if (cols < 2 || rows < 2) return;
137
- const ws = useGrooveStore.getState().ws;
138
- if (ws?.readyState === WebSocket.OPEN && termIdRef.current) {
139
- lastSizeRef.current = { cols, rows };
140
- ws.send(JSON.stringify({ type: 'terminal:resize', id: termIdRef.current, rows, cols }));
141
- }
145
+ lastSizeRef.current = { cols, rows };
146
+ clearTimeout(resizeTimer);
147
+ resizeTimer = setTimeout(() => {
148
+ const { cols: c, rows: r } = lastSizeRef.current;
149
+ const ws = useGrooveStore.getState().ws;
150
+ if (ws?.readyState === WebSocket.OPEN && termIdRef.current) {
151
+ ws.send(JSON.stringify({ type: 'terminal:resize', id: termIdRef.current, rows: r, cols: c }));
152
+ }
153
+ }, 150);
142
154
  });
143
155
  }
144
156
 
157
+ // ── Deferred spawn — wait for container to be visible & stable ──
145
158
  let hasSpawned = false;
159
+ let settleTimer = null;
160
+
146
161
  function tryInitSpawn() {
147
162
  if (hasSpawned) return;
148
- if (term.cols >= 10 && term.rows >= 2) {
163
+ const el = containerRef.current;
164
+ // Container must have meaningful pixel dimensions (not hidden/mid-layout)
165
+ if (!el || el.offsetWidth < 200 || el.offsetHeight < 50) {
166
+ clearTimeout(settleTimer);
167
+ settleTimer = null;
168
+ return;
169
+ }
170
+ // Already waiting for settle
171
+ if (settleTimer) return;
172
+ // Wait 200ms for layout to fully stabilize before spawning
173
+ settleTimer = setTimeout(() => {
174
+ if (hasSpawned) return;
175
+ const el2 = containerRef.current;
176
+ if (!el2 || el2.offsetWidth < 200 || el2.offsetHeight < 50) {
177
+ settleTimer = null;
178
+ return;
179
+ }
149
180
  hasSpawned = true;
181
+ try { fitAddon.fit(); } catch {}
150
182
  trySpawn();
151
- }
183
+ }, 200);
152
184
  }
153
185
 
154
- requestAnimationFrame(() => {
155
- try { fitAddon.fit(); } catch {}
156
- requestAnimationFrame(() => {
157
- try { fitAddon.fit(); } catch {}
158
- tryInitSpawn();
159
- });
160
- });
186
+ requestAnimationFrame(tryInitSpawn);
161
187
 
162
- const spawnFallback = setTimeout(() => {
188
+ // Absolute fallback if observer never fires, spawn after 5s
189
+ const fallback = setTimeout(() => {
163
190
  if (!hasSpawned) {
164
- try { fitAddon.fit(); } catch {}
165
191
  hasSpawned = true;
192
+ try { fitAddon.fit(); } catch {}
166
193
  trySpawn();
167
194
  }
168
- }, 3000);
195
+ }, 5000);
169
196
 
170
197
  const observer = new ResizeObserver(() => {
171
198
  if (!visibleRef.current) return;
172
199
  requestAnimationFrame(() => {
173
- try { fitAddon.fit(); } catch {}
174
- tryInitSpawn();
200
+ if (!hasSpawned) {
201
+ tryInitSpawn();
202
+ } else {
203
+ try { fitAddon.fit(); } catch {}
204
+ }
175
205
  });
176
206
  });
177
207
  observer.observe(containerRef.current);
178
208
 
179
209
  return () => {
180
- clearTimeout(spawnFallback);
210
+ clearTimeout(settleTimer);
211
+ clearTimeout(fallback);
181
212
  observer.disconnect();
182
213
  if (handlerRef.current) {
183
214
  handlerRef.current.ws.removeEventListener('message', handlerRef.current.handler);
184
215
  }
185
216
  term.dispose();
186
217
  fitRef.current = null;
218
+ termRef.current = null;
187
219
  mountedRef.current = false;
188
220
  };
189
221
  }, []);
@@ -193,16 +225,6 @@ function TerminalInstance({ tabId, visible, registerKill, onSelectionChange }) {
193
225
  if (visible && fitRef.current) {
194
226
  requestAnimationFrame(() => {
195
227
  try { fitRef.current.fit(); } catch {}
196
- if (termRef.current && termIdRef.current) {
197
- const c = termRef.current.cols, r = termRef.current.rows;
198
- if (c > 1 && r > 1) {
199
- const ws = useGrooveStore.getState().ws;
200
- if (ws?.readyState === WebSocket.OPEN) {
201
- ws.send(JSON.stringify({ type: 'terminal:resize', id: termIdRef.current, rows: r, cols: c }));
202
- lastSizeRef.current = { cols: c, rows: r };
203
- }
204
- }
205
- }
206
228
  });
207
229
  }
208
230
  }, [visible]);
@@ -1,10 +1,21 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useRef, useCallback, useState } from 'react';
2
+ import { useRef, useCallback, useState, useEffect } from 'react';
3
3
  import { LayoutList } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { FleetPane } from './fleet-pane';
6
+ import { RecommendedTeamCard } from '../agents/recommended-team-card';
6
7
 
7
8
  export function FleetContent() {
9
+ const allAgents = useGrooveStore((s) => s.agents);
10
+ const checkRecommendedTeam = useGrooveStore((s) => s.checkRecommendedTeam);
11
+ const recommendedTeam = useGrooveStore((s) => s.recommendedTeam);
12
+
13
+ useEffect(() => {
14
+ const hasPlanner = allAgents.some((a) => a.role === 'planner' && (a.status === 'running' || a.status === 'starting'));
15
+ if (!hasPlanner) return;
16
+ const interval = setInterval(() => checkRecommendedTeam(), 5000);
17
+ return () => clearInterval(interval);
18
+ }, [allAgents, checkRecommendedTeam]);
8
19
  const rawSelected = useGrooveStore((s) => s.fleetSelectedAgents);
9
20
  const lastSelectedRef = useRef(rawSelected);
10
21
  if (rawSelected[0] || rawSelected[1]) lastSelectedRef.current = rawSelected;
@@ -67,7 +78,7 @@ export function FleetContent() {
67
78
  if (!selected[0] && !selected[1]) {
68
79
  return (
69
80
  <div
70
- className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3"
81
+ className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3 relative"
71
82
  onDragOver={(e) => handleDragOver(e, 'left')}
72
83
  onDragLeave={handleDragLeave}
73
84
  onDrop={(e) => handleDrop(e, 0)}
@@ -75,13 +86,15 @@ export function FleetContent() {
75
86
  <LayoutList size={32} strokeWidth={1} className="text-text-4" />
76
87
  <p className="text-sm font-sans">Select an agent or drag one here</p>
77
88
  <p className="text-xs font-sans text-text-4">Cmd+Click or drag to open side-by-side</p>
89
+ <RecommendedTeamCard />
78
90
  </div>
79
91
  );
80
92
  }
81
93
 
82
94
  if (!splitMode || !selected[1]) {
83
95
  return (
84
- <div className="flex-1 flex min-w-0 min-h-0">
96
+ <div className="flex-1 flex min-w-0 min-h-0 relative">
97
+ <RecommendedTeamCard />
85
98
  <div className="flex-1 min-w-0 min-h-0">
86
99
  <FleetPane agentId={selected[0]} paneIndex={0} />
87
100
  </div>
@@ -107,7 +120,8 @@ export function FleetContent() {
107
120
  }
108
121
 
109
122
  return (
110
- <div className="flex-1 flex min-w-0 min-h-0">
123
+ <div className="flex-1 flex min-w-0 min-h-0 relative">
124
+ <RecommendedTeamCard />
111
125
  <div
112
126
  ref={leftRef}
113
127
  className={`min-w-0 min-h-0 ${dropTarget === 'left' ? 'ring-2 ring-inset ring-accent/30' : ''}`}
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useRef, useCallback, useMemo, useState } from 'react';
3
- import { Search, X, ChevronRight, Plus, Trash2 } from 'lucide-react';
3
+ import { Search, X, ChevronRight, Plus, Trash2, Pencil, Users, Check } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { FleetAgentRow } from './fleet-agent-row';
@@ -21,9 +21,16 @@ export function FleetSidebar({ width }) {
21
21
  const toggleCollapsed = useGrooveStore((s) => s.fleetToggleTeamCollapsed);
22
22
  const setSidebarWidth = useGrooveStore((s) => s.fleetSetSidebarWidth);
23
23
  const deleteTeam = useGrooveStore((s) => s.deleteTeam);
24
+ const createTeam = useGrooveStore((s) => s.createTeam);
25
+ const renameTeam = useGrooveStore((s) => s.renameTeam);
24
26
  const openDetail = useGrooveStore((s) => s.openDetail);
27
+ const addToast = useGrooveStore((s) => s.addToast);
25
28
 
26
29
  const [confirmDeleteTeam, setConfirmDeleteTeam] = useState(null);
30
+ const [renamingTeamId, setRenamingTeamId] = useState(null);
31
+ const [renameValue, setRenameValue] = useState('');
32
+ const [creatingTeam, setCreatingTeam] = useState(false);
33
+ const [newTeamName, setNewTeamName] = useState('');
27
34
 
28
35
  const dragging = useRef(false);
29
36
  const startX = useRef(0);
@@ -99,6 +106,31 @@ export function FleetSidebar({ width }) {
99
106
  openDetail({ type: 'spawn', presetTeamId: teamId });
100
107
  }
101
108
 
109
+ async function handleCreateTeam() {
110
+ const name = newTeamName.trim();
111
+ if (!name) return;
112
+ try {
113
+ await createTeam(name);
114
+ setNewTeamName('');
115
+ setCreatingTeam(false);
116
+ } catch { /* toast handles */ }
117
+ }
118
+
119
+ async function handleRename(teamId) {
120
+ const name = renameValue.trim();
121
+ if (!name) { setRenamingTeamId(null); return; }
122
+ try {
123
+ await renameTeam(teamId, name);
124
+ } catch { /* toast handles */ }
125
+ setRenamingTeamId(null);
126
+ }
127
+
128
+ function startRename(e, team) {
129
+ e.stopPropagation();
130
+ setRenamingTeamId(team.id);
131
+ setRenameValue(team.name);
132
+ }
133
+
102
134
  return (
103
135
  <div
104
136
  className="flex-shrink-0 flex flex-col bg-surface-1 border-r border-border relative h-full"
@@ -141,49 +173,73 @@ export function FleetSidebar({ width }) {
141
173
  'w-full flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-surface-2 transition-colors group',
142
174
  isConfirming && 'bg-danger/10 hover:bg-danger/20',
143
175
  )}>
144
- <button
145
- onClick={() => toggleCollapsed(team.id)}
146
- className="flex items-center gap-1.5 flex-1 min-w-0 cursor-pointer"
147
- >
148
- <ChevronRight
149
- size={14}
150
- className={cn(
151
- 'text-text-4 transition-transform flex-shrink-0',
152
- !isCollapsed && 'rotate-90',
153
- )}
154
- />
155
- <span className={cn(
156
- 'text-xs font-medium font-sans truncate text-left',
157
- isConfirming ? 'text-danger' : 'text-text-1',
158
- )}>
159
- {isConfirming ? 'Click again to delete' : team.name}
160
- </span>
161
- </button>
162
-
163
- {/* Hover actions + meta — stacked in same space */}
164
- <div className="flex items-center gap-0.5 flex-shrink-0">
165
- <button
166
- onClick={(e) => handleSpawnToTeam(e, team.id)}
167
- className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-text-4 hover:text-accent transition-opacity cursor-pointer"
168
- title="Spawn agent to team"
169
- >
170
- <Plus size={14} />
171
- </button>
172
- <button
173
- onClick={(e) => handleDeleteTeam(e, team.id)}
174
- className={cn(
175
- 'opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity cursor-pointer',
176
- isConfirming ? 'text-danger' : 'text-text-4 hover:text-danger',
177
- )}
178
- title="Delete team"
179
- >
180
- <Trash2 size={12} />
181
- </button>
182
- <span className="group-hover:opacity-0 text-2xs text-text-4 font-mono transition-opacity">
183
- {allTeamAgents.length}
184
- </span>
185
- <span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', teamStatusDot(allTeamAgents))} />
186
- </div>
176
+ {renamingTeamId === team.id ? (
177
+ <div className="flex items-center gap-1.5 flex-1 min-w-0 pl-1">
178
+ <input
179
+ value={renameValue}
180
+ onChange={(e) => setRenameValue(e.target.value)}
181
+ onKeyDown={(e) => { if (e.key === 'Enter') handleRename(team.id); if (e.key === 'Escape') setRenamingTeamId(null); }}
182
+ autoFocus
183
+ className="flex-1 min-w-0 h-6 px-1.5 text-xs bg-surface-3 border border-accent/40 rounded text-text-0 font-sans focus:outline-none"
184
+ />
185
+ <button onClick={() => handleRename(team.id)} className="p-0.5 text-accent cursor-pointer"><Check size={12} /></button>
186
+ <button onClick={() => setRenamingTeamId(null)} className="p-0.5 text-text-4 hover:text-text-1 cursor-pointer"><X size={12} /></button>
187
+ </div>
188
+ ) : (
189
+ <>
190
+ <button
191
+ onClick={() => toggleCollapsed(team.id)}
192
+ onDoubleClick={(e) => startRename(e, team)}
193
+ className="flex items-center gap-1.5 flex-1 min-w-0 cursor-pointer"
194
+ >
195
+ <ChevronRight
196
+ size={14}
197
+ className={cn(
198
+ 'text-text-4 transition-transform flex-shrink-0',
199
+ !isCollapsed && 'rotate-90',
200
+ )}
201
+ />
202
+ <span className={cn(
203
+ 'text-xs font-medium font-sans truncate text-left',
204
+ isConfirming ? 'text-danger' : 'text-text-1',
205
+ )}>
206
+ {isConfirming ? 'Click again to delete' : team.name}
207
+ </span>
208
+ </button>
209
+
210
+ {/* Hover actions + meta — stacked in same space */}
211
+ <div className="flex items-center gap-0.5 flex-shrink-0">
212
+ <button
213
+ onClick={(e) => startRename(e, team)}
214
+ className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-text-4 hover:text-accent transition-opacity cursor-pointer"
215
+ title="Rename team"
216
+ >
217
+ <Pencil size={11} />
218
+ </button>
219
+ <button
220
+ onClick={(e) => handleSpawnToTeam(e, team.id)}
221
+ className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-text-4 hover:text-accent transition-opacity cursor-pointer"
222
+ title="Spawn agent to team"
223
+ >
224
+ <Plus size={14} />
225
+ </button>
226
+ <button
227
+ onClick={(e) => handleDeleteTeam(e, team.id)}
228
+ className={cn(
229
+ 'opacity-0 group-hover:opacity-100 p-0.5 rounded transition-opacity cursor-pointer',
230
+ isConfirming ? 'text-danger' : 'text-text-4 hover:text-danger',
231
+ )}
232
+ title="Delete team"
233
+ >
234
+ <Trash2 size={12} />
235
+ </button>
236
+ <span className="group-hover:opacity-0 text-2xs text-text-4 font-mono transition-opacity">
237
+ {allTeamAgents.length}
238
+ </span>
239
+ <span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', teamStatusDot(allTeamAgents))} />
240
+ </div>
241
+ </>
242
+ )}
187
243
  </div>
188
244
 
189
245
  {/* Agent rows */}
@@ -206,6 +262,31 @@ export function FleetSidebar({ width }) {
206
262
  )}
207
263
  </div>
208
264
 
265
+ {/* Create team */}
266
+ <div className="px-2.5 py-2 border-t border-border-subtle flex-shrink-0">
267
+ {creatingTeam ? (
268
+ <div className="flex items-center gap-1.5">
269
+ <input
270
+ value={newTeamName}
271
+ onChange={(e) => setNewTeamName(e.target.value)}
272
+ onKeyDown={(e) => { if (e.key === 'Enter') handleCreateTeam(); if (e.key === 'Escape') { setCreatingTeam(false); setNewTeamName(''); } }}
273
+ placeholder="Team name..."
274
+ autoFocus
275
+ className="flex-1 min-w-0 h-7 px-2 text-xs bg-surface-3 border border-accent/40 rounded text-text-0 font-sans placeholder:text-text-4 focus:outline-none"
276
+ />
277
+ <button onClick={handleCreateTeam} disabled={!newTeamName.trim()} className="p-1 text-accent cursor-pointer disabled:opacity-30"><Check size={13} /></button>
278
+ <button onClick={() => { setCreatingTeam(false); setNewTeamName(''); }} className="p-1 text-text-4 hover:text-text-1 cursor-pointer"><X size={13} /></button>
279
+ </div>
280
+ ) : (
281
+ <button
282
+ onClick={() => setCreatingTeam(true)}
283
+ className="flex items-center gap-1.5 text-xs text-text-3 hover:text-accent font-sans font-medium cursor-pointer transition-colors"
284
+ >
285
+ <Users size={12} /> New Team
286
+ </button>
287
+ )}
288
+ </div>
289
+
209
290
  {/* Resize handle */}
210
291
  <div
211
292
  className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent/30 transition-colors z-10"
@@ -140,13 +140,13 @@ export function BreadcrumbBar({
140
140
  const [instanceName, setInstanceName] = useState(null);
141
141
 
142
142
  useEffect(() => {
143
- if (window.groove?.getInstanceInfo) {
143
+ const urlParam = new URLSearchParams(window.location.search).get('instance');
144
+ if (urlParam) {
145
+ setInstanceName(urlParam);
146
+ } else if (window.groove?.getInstanceInfo) {
144
147
  window.groove.getInstanceInfo().then(info => {
145
148
  if (info?.name) setInstanceName(info.name);
146
149
  });
147
- } else {
148
- const param = new URLSearchParams(window.location.search).get('instance');
149
- if (param) setInstanceName(param);
150
150
  }
151
151
  }, []);
152
152
 
@@ -17,12 +17,14 @@ export function QuickConnect() {
17
17
  const addToast = useGrooveStore((s) => s.addToast);
18
18
  const tunnelStep = useGrooveStore((s) => s.tunnelConnectStep);
19
19
  const [connectingId, setConnectingId] = useState(null);
20
+ const [openingServer, setOpeningServer] = useState(null);
20
21
  const [showWizard, setShowWizard] = useState(false);
21
22
  const wizardTunnelId = useRef(null);
22
23
 
23
24
  useEffect(() => {
24
25
  if (open) {
25
26
  setShowWizard(false);
27
+ setOpeningServer(null);
26
28
  useGrooveStore.getState().fetchTunnels();
27
29
  }
28
30
  }, [open]);
@@ -34,14 +36,15 @@ export function QuickConnect() {
34
36
  try {
35
37
  await useGrooveStore.getState().connectTunnel(id);
36
38
  const tunnel = savedTunnels.find((t) => t.id === id);
39
+ setConnectingId(null);
40
+ setOpeningServer({ name: tunnel?.name || 'Remote' });
37
41
  if (tunnel?.host) {
38
42
  addToast('info', `Add ${tunnel.host} to Federation Whitelist?`, '', {
39
43
  label: 'Add',
40
44
  onClick: () => useGrooveStore.getState().addToWhitelist(tunnel.host),
41
45
  });
42
46
  }
43
- setConnectingId(null);
44
- toggle();
47
+ setTimeout(() => { setOpeningServer(null); toggle(); }, 4000);
45
48
  return;
46
49
  } catch (err) {
47
50
  const detail = err?.message || 'Unknown error';
@@ -90,32 +93,55 @@ export function QuickConnect() {
90
93
  exit={{ opacity: 0, y: -10, scale: 0.98 }}
91
94
  transition={{ duration: 0.15 }}
92
95
  className={cn(
93
- 'fixed top-[15%] left-1/2 -translate-x-1/2 z-50 bg-surface-1 border border-border rounded-lg shadow-2xl overflow-hidden',
94
- showWizard ? 'w-[520px]' : 'w-[400px]',
96
+ 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 bg-[#24282f] border border-[#2c313a] rounded-lg shadow-2xl overflow-hidden',
97
+ showWizard ? 'w-[680px]' : 'w-[480px]',
95
98
  )}
96
99
  >
97
100
  {/* Header */}
98
- <div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
99
- <div className="flex items-center gap-2">
101
+ <div className="flex items-center justify-between px-5 py-4 border-b border-[#2c313a]">
102
+ <div className="flex items-center gap-3">
100
103
  {showWizard && (
101
104
  <button
102
105
  onClick={() => setShowWizard(false)}
103
- className="p-1 -ml-1 text-text-4 hover:text-text-1 cursor-pointer transition-colors"
106
+ className="p-1.5 -ml-1 text-[#6e7681] hover:text-[#e6e8ed] cursor-pointer transition-colors"
104
107
  >
105
- <ArrowLeft size={14} />
108
+ <ArrowLeft size={16} />
106
109
  </button>
107
110
  )}
108
- <Radio size={15} className="text-accent" />
109
- <span className="text-sm font-semibold text-text-0 font-sans">
111
+ <Radio size={17} className="text-[#33afbc]" />
112
+ <span className="text-base font-semibold text-[#e6e8ed]" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif", letterSpacing: '-0.2px' }}>
110
113
  {showWizard ? (wizardTunnelId.current ? 'Connection Setup' : 'Add Connection') : 'Quick Connect'}
111
114
  </span>
112
115
  </div>
113
- <button onClick={handleClose} className="p-1 text-text-4 hover:text-text-1 cursor-pointer transition-colors">
114
- <X size={14} />
116
+ <button onClick={handleClose} className="p-1.5 text-[#6e7681] hover:text-[#e6e8ed] cursor-pointer transition-colors">
117
+ <X size={16} />
115
118
  </button>
116
119
  </div>
117
120
 
118
- {showWizard ? (
121
+ {openingServer ? (
122
+ <div className="px-6 py-12 text-center">
123
+ <div className="relative w-14 h-14 mx-auto mb-5">
124
+ <span className="absolute inset-0 rounded-full border-2 border-[#33afbc]/20 animate-ping" style={{ animationDuration: '2s' }} />
125
+ <span className="absolute inset-0 rounded-full border-2 border-transparent border-t-[#33afbc] animate-spin" style={{ animationDuration: '1s' }} />
126
+ <span className="absolute inset-[6px] rounded-full bg-[#33afbc]/8 flex items-center justify-center">
127
+ <Server size={18} className="text-[#33afbc]" />
128
+ </span>
129
+ </div>
130
+ <p className="text-base font-semibold text-[#e6e8ed] mb-1.5" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}>
131
+ Opening {openingServer.name}
132
+ </p>
133
+ <p className="text-sm text-[#6e7681]" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}>
134
+ Loading remote dashboard...
135
+ </p>
136
+ <button
137
+ onClick={() => { setOpeningServer(null); toggle(); }}
138
+ className="mt-6 text-xs text-[#6e7681] hover:text-[#e6e8ed] cursor-pointer transition-colors"
139
+ style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}
140
+ >
141
+ Dismiss
142
+ </button>
143
+ </div>
144
+ ) : showWizard ? (
119
145
  <SSHWizard
120
146
  server={wizardTunnelId.current ? savedTunnels.find((t) => t.id === wizardTunnelId.current) || null : null}
121
147
  onSave={async (data) => {
@@ -145,45 +171,49 @@ export function QuickConnect() {
145
171
  ) : (
146
172
  <>
147
173
  {/* Server list */}
148
- <div className="overflow-y-auto max-h-[320px] py-1">
174
+ <div className="overflow-y-auto max-h-[400px] py-2">
149
175
  {savedTunnels.length === 0 ? (
150
- <div className="px-4 py-8 text-center">
151
- <Server size={24} className="text-text-4 mx-auto mb-2" />
152
- <p className="text-sm text-text-3 font-sans">No saved servers</p>
153
- <p className="text-2xs text-text-4 font-sans mt-1">Add a connection to get started.</p>
154
- <Button
155
- variant="primary"
156
- size="sm"
176
+ <div className="px-6 py-10 text-center">
177
+ <Server size={32} className="text-[#6e7681] mx-auto mb-3" />
178
+ <p className="text-base font-semibold text-[#e6e8ed]" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}>No saved servers</p>
179
+ <p className="text-xs text-[#6e7681] mt-1.5" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}>Add a connection to get started.</p>
180
+ <button
157
181
  onClick={() => { wizardTunnelId.current = null; setShowWizard(true); }}
158
- className="h-8 text-xs gap-1.5 mt-3"
182
+ className="inline-flex items-center gap-1.5 h-9 px-5 mt-4 rounded bg-[#33afbc] text-[#0a0c10] text-sm font-semibold cursor-pointer transition-opacity hover:opacity-90"
183
+ style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}
159
184
  >
160
- <Plus size={12} /> Add Connection
161
- </Button>
185
+ <Plus size={14} /> Add Connection
186
+ </button>
162
187
  </div>
163
188
  ) : (
164
189
  savedTunnels.map((server) => (
165
190
  <div
166
191
  key={server.id}
167
192
  className={cn(
168
- 'w-full flex items-center gap-3 px-4 py-2.5 transition-colors',
169
- 'hover:bg-surface-5',
193
+ 'w-full flex items-center gap-4 px-5 py-3.5 transition-colors',
194
+ 'hover:bg-[#2c313a]',
170
195
  connectingId === server.id && 'opacity-60 pointer-events-none',
171
196
  )}
172
197
  >
173
- <Server size={15} className={server.active ? 'text-success' : 'text-text-4'} />
198
+ <div className={cn(
199
+ 'w-10 h-10 rounded flex items-center justify-center flex-shrink-0',
200
+ server.active ? 'bg-[#33afbc]/10' : 'bg-[rgba(255,255,255,0.04)]',
201
+ )}>
202
+ <Server size={18} className={server.active ? 'text-[#33afbc]' : 'text-[#8b95a5]'} />
203
+ </div>
174
204
  <button
175
205
  onClick={() => server.active ? handleOpenRemote(server) : handleConnect(server.id)}
176
206
  disabled={connectingId === server.id}
177
207
  className="flex-1 min-w-0 text-left cursor-pointer"
178
208
  >
179
209
  <div className="flex items-center gap-2">
180
- <span className="text-sm font-medium text-text-0 font-sans truncate">{server.name}</span>
210
+ <span className="text-sm font-semibold text-[#e6e8ed] truncate" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif", letterSpacing: '-0.2px' }}>{server.name}</span>
181
211
  {server.active && <StatusDot status="running" size="sm" />}
182
212
  {server.remoteVersion && (
183
- <span className="text-2xs font-mono text-text-4 ml-1">v{server.remoteVersion}</span>
213
+ <span className="text-xs text-[#6e7681] ml-1" style={{ fontFamily: "ui-monospace, 'SF Mono', Monaco, monospace" }}>v{server.remoteVersion}</span>
184
214
  )}
185
215
  </div>
186
- <span className="text-2xs text-text-4 font-mono">{server.user}@{server.host}</span>
216
+ <span className="text-xs text-[#6e7681]" style={{ fontFamily: "ui-monospace, 'SF Mono', Monaco, monospace" }}>{server.user}@{server.host}</span>
187
217
  </button>
188
218
  <div className="flex items-center gap-1.5 flex-shrink-0">
189
219
  {connectingId === server.id ? (
@@ -267,12 +297,13 @@ export function QuickConnect() {
267
297
  </div>
268
298
 
269
299
  {/* Footer with Add button */}
270
- <div className="px-4 py-2.5 border-t border-border-subtle">
300
+ <div className="px-5 py-3.5 border-t border-[#2c313a]">
271
301
  <button
272
302
  onClick={() => { wizardTunnelId.current = null; setShowWizard(true); }}
273
- className="flex items-center gap-1.5 text-2xs text-accent hover:text-accent/80 font-sans font-medium cursor-pointer transition-colors"
303
+ className="flex items-center gap-2 text-sm text-[#33afbc] hover:opacity-80 font-semibold cursor-pointer transition-opacity"
304
+ style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif" }}
274
305
  >
275
- <Plus size={10} /> Add new connection
306
+ <Plus size={14} /> Add new connection
276
307
  </button>
277
308
  </div>
278
309
  </>