groove-dev 0.27.141 → 0.27.143

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 (74) hide show
  1. package/CLAUDE.md +7 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +18 -7
  5. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +3 -2
  7. package/node_modules/@groove-dev/daemon/src/keeper.js +2 -2
  8. package/node_modules/@groove-dev/daemon/src/memory.js +8 -5
  9. package/node_modules/@groove-dev/daemon/src/process.js +5 -16
  10. package/node_modules/@groove-dev/daemon/src/rotator.js +25 -8
  11. package/node_modules/@groove-dev/gui/dist/assets/{codemirror-BQqYnZfL.js → codemirror-BYKpdS2W.js} +10 -10
  12. package/node_modules/@groove-dev/gui/dist/assets/index-CCVvAoQn.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-DGIv_TRm.js +984 -0
  14. package/node_modules/@groove-dev/gui/dist/index.html +3 -3
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/app.jsx +0 -2
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +73 -17
  18. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -2
  19. package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +5 -6
  20. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +79 -5
  21. package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +2 -53
  22. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +111 -0
  23. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +70 -33
  24. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +2 -68
  25. package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
  26. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +1 -2
  27. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +0 -1
  28. package/node_modules/@groove-dev/gui/src/stores/groove.js +3 -3
  29. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +2 -0
  30. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +3 -71
  31. package/node_modules/@groove-dev/gui/src/views/models.jsx +5 -6
  32. package/package.json +1 -1
  33. package/packages/cli/package.json +1 -1
  34. package/packages/daemon/package.json +1 -1
  35. package/packages/daemon/src/api.js +18 -7
  36. package/packages/daemon/src/introducer.js +1 -1
  37. package/packages/daemon/src/journalist.js +3 -2
  38. package/packages/daemon/src/keeper.js +2 -2
  39. package/packages/daemon/src/memory.js +8 -5
  40. package/packages/daemon/src/process.js +5 -16
  41. package/packages/daemon/src/rotator.js +25 -8
  42. package/packages/gui/dist/assets/{codemirror-BQqYnZfL.js → codemirror-BYKpdS2W.js} +10 -10
  43. package/packages/gui/dist/assets/index-CCVvAoQn.css +1 -0
  44. package/packages/gui/dist/assets/index-DGIv_TRm.js +984 -0
  45. package/packages/gui/dist/index.html +3 -3
  46. package/packages/gui/package.json +1 -1
  47. package/packages/gui/src/app.jsx +0 -2
  48. package/packages/gui/src/components/agents/agent-chat.jsx +73 -17
  49. package/packages/gui/src/components/agents/agent-feed.jsx +8 -2
  50. package/packages/gui/src/components/agents/agent-file-tree.jsx +5 -6
  51. package/packages/gui/src/components/agents/agent-panel.jsx +79 -5
  52. package/packages/gui/src/components/agents/workspace-mode.jsx +2 -53
  53. package/packages/gui/src/components/dashboard/context-gauges.jsx +111 -0
  54. package/packages/gui/src/components/dashboard/routing-chart.jsx +70 -33
  55. package/packages/gui/src/components/editor/code-editor.jsx +2 -68
  56. package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
  57. package/packages/gui/src/components/layout/activity-bar.jsx +1 -2
  58. package/packages/gui/src/components/layout/terminal-panel.jsx +0 -1
  59. package/packages/gui/src/stores/groove.js +3 -3
  60. package/packages/gui/src/views/dashboard.jsx +2 -0
  61. package/packages/gui/src/views/marketplace.jsx +3 -71
  62. package/packages/gui/src/views/models.jsx +5 -6
  63. package/node_modules/@groove-dev/gui/dist/assets/index-A4e1gIDh.css +0 -1
  64. package/node_modules/@groove-dev/gui/dist/assets/index-P1hsM27-.js +0 -8696
  65. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +0 -78
  66. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +0 -144
  67. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +0 -187
  68. package/node_modules/@groove-dev/gui/src/views/toys.jsx +0 -162
  69. package/packages/gui/dist/assets/index-A4e1gIDh.css +0 -1
  70. package/packages/gui/dist/assets/index-P1hsM27-.js +0 -8696
  71. package/packages/gui/src/components/toys/toy-card.jsx +0 -78
  72. package/packages/gui/src/components/toys/toy-creator.jsx +0 -144
  73. package/packages/gui/src/components/toys/toy-launcher.jsx +0 -187
  74. package/packages/gui/src/views/toys.jsx +0 -162
@@ -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-P1hsM27-.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DGIv_TRm.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
- <link rel="modulepreload" crossorigin href="/assets/codemirror-BQqYnZfL.js">
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-A4e1gIDh.css">
14
+ <link rel="stylesheet" crossorigin href="/assets/index-CCVvAoQn.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.141",
3
+ "version": "0.27.143",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -20,7 +20,6 @@ import TeamsView from './views/teams';
20
20
  import SettingsView from './views/settings';
21
21
  import ModelsView from './views/models';
22
22
  import FederationView from './views/federation';
23
- import ToysView from './views/toys';
24
23
  import ModelLabView from './views/model-lab';
25
24
  import NetworkView from './views/network';
26
25
  import ChatView from './views/chat';
@@ -73,7 +72,6 @@ function ViewRouter() {
73
72
  case 'editor': content = <EditorView />; break;
74
73
  case 'dashboard': content = <DashboardView />; break;
75
74
  case 'marketplace': content = <MarketplaceView />; break;
76
- case 'toys': content = <ToysView />; break;
77
75
  case 'teams': content = <TeamsView />; break;
78
76
  case 'models': content = <ModelsView />; break;
79
77
  case 'model-lab': content = <ModelLabView />; break;
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useRef, useEffect } from 'react';
3
- import { Send, Loader2, MessageSquare, HelpCircle, ArrowRight, Paperclip, Square } from 'lucide-react';
3
+ import { Send, Loader2, MessageSquare, HelpCircle, ArrowRight, Paperclip, Square, FileCode, Terminal as TerminalIcon, X } from 'lucide-react';
4
4
  import { useGrooveStore } from '../../stores/groove';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { ThinkingIndicator } from '../ui/thinking-indicator';
@@ -39,9 +39,9 @@ function parseSegments(text) {
39
39
  }
40
40
 
41
41
  function highlightKeeper(text) {
42
- const parts = text.split(/(\[(?:save|append|update|delete|view|doc|link|read|instruct)\]|\b(?:save|append|update|delete|view|doc|link|read|instruct)\b(?=\s+#)|#[\w/.-]+)/gi);
42
+ const parts = text.split(/(\[(?:save|append|update|delete|view|doc|link|read|instruct)\]|#[\w/.-]+)/gi);
43
43
  return parts.map((part, i) => {
44
- if (/^\[?(?:save|append|update|delete|view|doc|link|read|instruct)\]?$/i.test(part)) {
44
+ if (/^\[(?:save|append|update|delete|view|doc|link|read|instruct)\]$/i.test(part)) {
45
45
  return <span key={i} className="px-1 py-0.5 rounded bg-accent/15 text-accent font-semibold font-mono text-2xs">{part}</span>;
46
46
  }
47
47
  if (/^#[\w/.-]+$/.test(part)) {
@@ -145,12 +145,40 @@ function TypingIndicator() {
145
145
  );
146
146
  }
147
147
 
148
+ function SnippetTag({ snippet, onRemove }) {
149
+ const isCode = snippet.type === 'code';
150
+ const Icon = isCode ? FileCode : TerminalIcon;
151
+ const lines = snippet.code.split('\n').length;
152
+ let label;
153
+ if (isCode && snippet.filePath) {
154
+ const fileName = snippet.filePath.split('/').pop();
155
+ label = `${fileName}:${snippet.lineStart}-${snippet.lineEnd}`;
156
+ } else {
157
+ label = `${isCode ? '' : 'Terminal · '}${lines} line${lines !== 1 ? 's' : ''}`;
158
+ }
159
+ return (
160
+ <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-accent/10 border border-accent/20 text-accent">
161
+ <Icon size={11} className="flex-shrink-0" />
162
+ <span className="text-2xs font-sans font-medium truncate max-w-[160px]">{label}</span>
163
+ {snippet.instruction && (
164
+ <span className="text-2xs text-accent/60 truncate max-w-[100px]">· {snippet.instruction}</span>
165
+ )}
166
+ <button onClick={onRemove} className="p-0.5 rounded hover:bg-accent/20 cursor-pointer flex-shrink-0">
167
+ <X size={9} />
168
+ </button>
169
+ </div>
170
+ );
171
+ }
172
+
148
173
  export function AgentChat({ agent }) {
149
174
  const chatHistory = useGrooveStore((s) => s.chatHistory[agent.id]) || EMPTY;
150
175
  const activityLog = useGrooveStore((s) => s.activityLog[agent.id]) || EMPTY;
151
176
  const instructAgent = useGrooveStore((s) => s.instructAgent);
152
177
  const isThinking = useGrooveStore((s) => s.thinkingAgents?.has(agent.id));
153
178
 
179
+ const pendingSnippet = useGrooveStore((s) => s.editorPendingSnippet);
180
+ const clearSnippet = useGrooveStore((s) => s.clearSnippet);
181
+
154
182
  const storeInput = useGrooveStore((s) => s.chatInputs[agent.id] || '');
155
183
  const setStoreInput = (val) => useGrooveStore.setState((s) => ({ chatInputs: { ...s.chatInputs, [agent.id]: val } }));
156
184
  const input = storeInput;
@@ -162,6 +190,10 @@ export function AgentChat({ agent }) {
162
190
  const fileInputRef = useRef(null);
163
191
  const isAtBottomRef = useRef(true);
164
192
 
193
+ useEffect(() => {
194
+ if (pendingSnippet) inputRef.current?.focus();
195
+ }, [pendingSnippet]);
196
+
165
197
  useEffect(() => {
166
198
  const el = scrollRef.current;
167
199
  if (!el) return;
@@ -192,16 +224,30 @@ export function AgentChat({ agent }) {
192
224
 
193
225
  async function handleSend() {
194
226
  const text = input.trim();
195
- if (!text || sending) return;
227
+ if ((!text && !pendingSnippet) || sending) return;
228
+ const parts = [];
229
+ if (text) parts.push(text);
230
+ if (pendingSnippet) {
231
+ const s = pendingSnippet;
232
+ if (s.type === 'code' && s.filePath) {
233
+ if (s.instruction && !text) parts.push(s.instruction);
234
+ parts.push(`File: ${s.filePath} (lines ${s.lineStart}-${s.lineEnd})`);
235
+ parts.push('```\n' + s.code + '\n```');
236
+ } else if (s.code) {
237
+ parts.push('```\n' + s.code + '\n```');
238
+ }
239
+ }
240
+ const message = parts.join('\n\n');
196
241
  setInput('');
197
242
  setAttachedFiles([]);
243
+ clearSnippet();
198
244
  setSending(true);
199
245
  isAtBottomRef.current = true;
200
246
  requestAnimationFrame(() => {
201
247
  if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
202
248
  });
203
249
  try {
204
- await instructAgent(agent.id, text);
250
+ await instructAgent(agent.id, message);
205
251
  } catch { /* toast handles */ }
206
252
  setSending(false);
207
253
  inputRef.current?.focus();
@@ -251,6 +297,22 @@ export function AgentChat({ agent }) {
251
297
 
252
298
  {/* ── Input area ──────────────────────────────────── */}
253
299
  <div className="border-t border-border-subtle px-3 py-2 bg-surface-1">
300
+ {pendingSnippet && (
301
+ <div className="mb-1.5">
302
+ <SnippetTag snippet={pendingSnippet} onRemove={clearSnippet} />
303
+ </div>
304
+ )}
305
+ {input && /\[(?:save|append|update|delete|view|doc|link|read|instruct)\]/i.test(input) && (() => {
306
+ const cmdMatch = input.match(/\[(save|append|update|delete|view|doc|link|read|instruct)\]/i);
307
+ const tags = (input.match(/#[\w/.-]+/g) || []);
308
+ return (
309
+ <div className="flex items-center gap-1.5 px-3 py-1 mb-1.5 rounded-md bg-accent/5 border border-accent/10">
310
+ <span className="px-1.5 py-0.5 rounded bg-accent/15 text-accent font-semibold font-mono text-2xs">{cmdMatch[0]}</span>
311
+ {tags.map((tag, i) => <span key={i} className="text-accent font-medium text-2xs">{tag}</span>)}
312
+ <span className="text-2xs text-text-4 ml-auto">memory command</span>
313
+ </div>
314
+ );
315
+ })()}
254
316
  <div className="flex items-end gap-1.5">
255
317
  <input
256
318
  ref={fileInputRef}
@@ -267,19 +329,13 @@ export function AgentChat({ agent }) {
267
329
  >
268
330
  <Paperclip size={14} />
269
331
  </button>
270
- <div className="flex-1 relative">
271
- <div
272
- aria-hidden
273
- className="absolute inset-0 px-3 py-1.5 text-xs font-sans pointer-events-none whitespace-pre-wrap break-words overflow-hidden min-h-[32px] max-h-[120px] leading-[1.625]"
274
- >
275
- {input ? highlightKeeper(input) : null}
276
- </div>
332
+ <div className="flex-1">
277
333
  <textarea
278
334
  ref={inputRef}
279
335
  value={input}
280
336
  onChange={(e) => setInput(e.target.value)}
281
337
  onKeyDown={onKeyDown}
282
- placeholder={isAlive ? 'Instruct this agent...' : 'Continue conversation...'}
338
+ placeholder={pendingSnippet ? 'Add a message (optional)...' : isAlive ? 'Instruct this agent...' : 'Continue conversation...'}
283
339
  rows={1}
284
340
  className={cn(
285
341
  'w-full resize-none rounded-lg px-3 py-1.5 text-xs',
@@ -288,8 +344,8 @@ export function AgentChat({ agent }) {
288
344
  'focus:outline-none focus:ring-1',
289
345
  'min-h-[32px] max-h-[120px]',
290
346
  'border-border focus:ring-accent/40',
291
- input && /(\[(?:save|append|update|delete|view|doc|link|read|instruct)\]|\b(?:save|append|update|delete|view|doc|link|read|instruct)\b\s+#|#[\w/.-]+)/i.test(input)
292
- ? 'text-transparent caret-text-0'
347
+ input && /\[(?:save|append|update|delete|view|doc|link|read|instruct)\]/i.test(input)
348
+ ? 'text-accent'
293
349
  : 'text-text-0',
294
350
  )}
295
351
  />
@@ -305,11 +361,11 @@ export function AgentChat({ agent }) {
305
361
  )}
306
362
  <button
307
363
  onClick={handleSend}
308
- disabled={!input.trim() || sending}
364
+ disabled={(!input.trim() && !pendingSnippet) || sending}
309
365
  className={cn(
310
366
  'w-8 h-8 flex items-center justify-center rounded-lg transition-all cursor-pointer flex-shrink-0',
311
367
  'disabled:opacity-20 disabled:cursor-not-allowed',
312
- input.trim()
368
+ (input.trim() || pendingSnippet)
313
369
  ? 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/25'
314
370
  : 'bg-surface-4 text-text-4',
315
371
  )}
@@ -483,11 +483,17 @@ function BootSequence({ agent }) {
483
483
  // ── Main Feed ────────────────────────────────────────────────
484
484
 
485
485
  export function AgentFeed({ agent }) {
486
- const chatHistory = useGrooveStore((s) => s.chatHistory[agent.id]) || EMPTY;
487
- const activityLog = useGrooveStore((s) => s.activityLog[agent.id]) || EMPTY;
486
+ const rawChatHistory = useGrooveStore((s) => s.chatHistory[agent.id]) || EMPTY;
487
+ const rawActivityLog = useGrooveStore((s) => s.activityLog[agent.id]) || EMPTY;
488
488
  const instructAgent = useGrooveStore((s) => s.instructAgent);
489
489
  const queryAgent = useGrooveStore((s) => s.queryAgent);
490
490
  const isThinking = useGrooveStore((s) => s.thinkingAgents?.has(agent.id));
491
+ const cachedChatRef = useRef(EMPTY);
492
+ const cachedActivityRef = useRef(EMPTY);
493
+ if (rawChatHistory.length > 0) cachedChatRef.current = rawChatHistory;
494
+ if (rawActivityLog.length > 0) cachedActivityRef.current = rawActivityLog;
495
+ const chatHistory = rawChatHistory.length > 0 ? rawChatHistory : cachedChatRef.current;
496
+ const activityLog = rawActivityLog.length > 0 ? rawActivityLog : cachedActivityRef.current;
491
497
 
492
498
  const storeInput = useGrooveStore((s) => s.chatInputs[agent.id] || '');
493
499
  const setStoreInput = (val) => useGrooveStore.setState((s) => ({ chatInputs: { ...s.chatInputs, [agent.id]: val } }));
@@ -511,19 +511,18 @@ export function AgentFileTree({ agentId, onCollapse }) {
511
511
  Agent Files
512
512
  </div>
513
513
  {touchedFiles.slice(0, 15).map((f) => {
514
- const relPath = toRelativePath(f.path);
515
- const name = relPath.split('/').pop();
514
+ const name = f.path.split('/').pop();
516
515
  const hasWrites = f.writes > 0;
517
516
  return (
518
517
  <button
519
518
  key={f.path}
520
- onClick={() => openFile(relPath)}
521
- onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenu(e, { path: relPath, name, type: 'file' }); }}
522
- onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenu(e, { path: relPath, name, type: 'file' }); }}
519
+ onClick={() => openFile(f.path)}
520
+ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenu(e, { path: f.path, name, type: 'file' }); }}
521
+ onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenu(e, { path: f.path, name, type: 'file' }); }}
523
522
  className={cn(
524
523
  'w-full flex items-center gap-1.5 px-3 py-1 text-xs font-sans cursor-pointer',
525
524
  'hover:bg-surface-4/50 transition-colors text-left',
526
- (editorActiveFile === f.path || editorActiveFile === relPath) && 'bg-accent/8 text-accent',
525
+ editorActiveFile === f.path && 'bg-accent/8 text-accent',
527
526
  )}
528
527
  >
529
528
  {hasWrites
@@ -1,12 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useRef } from 'react';
2
+ import { useState, useRef, useEffect, useCallback } from 'react';
3
3
  import { useGrooveStore } from '../../stores/groove';
4
4
  import { Badge } from '../ui/badge';
5
5
  import { AgentFeed } from './agent-feed';
6
6
  import { AgentConfig } from './agent-config';
7
7
  import { AgentTelemetry } from './agent-telemetry';
8
8
  import { AgentMdFiles } from './agent-mdfiles';
9
- import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X } from 'lucide-react';
9
+ import { MessageSquare, Settings, Activity, FileText, Pencil, Check, X, TrendingDown } from 'lucide-react';
10
10
  import { fmtNum, fmtUptime } from '../../lib/format';
11
11
  import { cn } from '../../lib/cn';
12
12
  import { roleColor } from '../../lib/status';
@@ -77,23 +77,91 @@ function InlineName({ agent }) {
77
77
  );
78
78
  }
79
79
 
80
+ function useRoutingSuggestion(agentId, isAlive) {
81
+ const [suggestion, setSuggestion] = useState(null);
82
+ const [dismissed, setDismissed] = useState(false);
83
+
84
+ useEffect(() => {
85
+ if (!agentId || !isAlive || dismissed) { setSuggestion(null); return; }
86
+ let cancelled = false;
87
+ async function poll() {
88
+ try {
89
+ const res = await fetch(`/api/agents/${agentId}/routing/suggestion`);
90
+ if (cancelled) return;
91
+ if (res.status === 204 || !res.ok) { setSuggestion(null); return; }
92
+ const data = await res.json();
93
+ setSuggestion(data);
94
+ } catch { setSuggestion(null); }
95
+ }
96
+ poll();
97
+ const id = setInterval(poll, 30000);
98
+ return () => { cancelled = true; clearInterval(id); };
99
+ }, [agentId, isAlive, dismissed]);
100
+
101
+ const dismiss = useCallback(() => setDismissed(true), []);
102
+ const reset = useCallback(() => setDismissed(false), []);
103
+
104
+ return { suggestion: dismissed ? null : suggestion, dismiss, reset };
105
+ }
106
+
107
+ function DownshiftPill({ suggestion, onAccept, onDismiss }) {
108
+ if (!suggestion) return null;
109
+ const { suggestedModel } = suggestion;
110
+ return (
111
+ <div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-success/10 border border-success/20 text-2xs font-mono animate-in fade-in slide-in-from-left-1 duration-200">
112
+ <TrendingDown size={10} className="text-success flex-shrink-0" />
113
+ <span className="text-success/90 truncate max-w-[80px]">{suggestedModel.name}</span>
114
+ <button
115
+ onClick={onAccept}
116
+ className="px-1 py-px rounded bg-success/20 text-success font-semibold hover:bg-success/30 transition-colors cursor-pointer"
117
+ >
118
+ Switch
119
+ </button>
120
+ <button
121
+ onClick={onDismiss}
122
+ className="p-0.5 text-text-4 hover:text-text-1 cursor-pointer"
123
+ >
124
+ <X size={8} />
125
+ </button>
126
+ </div>
127
+ );
128
+ }
129
+
80
130
  export function AgentPanel() {
81
131
  const detailPanel = useGrooveStore((s) => s.detailPanel);
82
132
  const agents = useGrooveStore((s) => s.agents);
83
133
  const activeTeamId = useGrooveStore((s) => s.activeTeamId);
134
+ const addToast = useGrooveStore((s) => s.addToast);
84
135
  const [activeTab, setActiveTab] = useState('command');
136
+ const cachedAgentRef = useRef(null);
137
+
138
+ const agentId = detailPanel?.type === 'agent' ? detailPanel.agentId : null;
139
+ const liveAgent = agentId ? agents.find((a) => a.id === agentId) : null;
140
+ if (liveAgent) cachedAgentRef.current = liveAgent;
141
+ else if (cachedAgentRef.current && cachedAgentRef.current.id !== agentId) cachedAgentRef.current = null;
142
+ const agent = liveAgent || cachedAgentRef.current;
143
+ const isAlive = liveAgent?.status === 'running' || liveAgent?.status === 'starting';
144
+ const { suggestion, dismiss: dismissSuggestion } = useRoutingSuggestion(agentId, isAlive);
85
145
 
86
- if (detailPanel?.type !== 'agent') return null;
87
- const agent = agents.find((a) => a.id === detailPanel.agentId);
88
146
  if (!agent) return null;
89
147
  if (activeTeamId && agent.teamId && agent.teamId !== activeTeamId) return null;
90
148
 
91
- const isAlive = agent.status === 'running' || agent.status === 'starting';
92
149
  const ctxPct = Math.round((agent.contextUsage || 0) * 100);
93
150
  const spawned = agent.spawnedAt || agent.createdAt;
94
151
  const uptime = spawned ? Math.floor((Date.now() - new Date(spawned).getTime()) / 1000) : 0;
95
152
  const colors = roleColor(agent.role);
96
153
 
154
+ async function acceptSuggestion() {
155
+ if (!suggestion) return;
156
+ try {
157
+ await api.patch(`/agents/${agent.id}`, { model: suggestion.suggestedModel.id });
158
+ addToast('success', `Model → ${suggestion.suggestedModel.name}`);
159
+ dismissSuggestion();
160
+ } catch (err) {
161
+ addToast('error', 'Model switch failed', err.message);
162
+ }
163
+ }
164
+
97
165
  return (
98
166
  <div className="flex flex-col h-full">
99
167
  {/* ── Header ─────────────────────────────────────────── */}
@@ -126,6 +194,12 @@ export function AgentPanel() {
126
194
  )}
127
195
  <span className="text-text-4">·</span>
128
196
  <span>{fmtUptime(uptime)}</span>
197
+ {suggestion && (
198
+ <>
199
+ <span className="text-text-4">·</span>
200
+ <DownshiftPill suggestion={suggestion} onAccept={acceptSuggestion} onDismiss={dismissSuggestion} />
201
+ </>
202
+ )}
129
203
  </div>
130
204
  </div>
131
205
 
@@ -6,7 +6,6 @@ import { AgentFileTree } from './agent-file-tree';
6
6
  import { DiffViewer } from './diff-viewer';
7
7
  import { CodeReview } from './code-review';
8
8
  import { CodeEditor } from '../editor/code-editor';
9
- import { AiPanel } from '../editor/ai-panel';
10
9
  import { SelectionMenu } from '../editor/selection-menu';
11
10
  import { InlinePrompt } from '../editor/inline-prompt';
12
11
  import { QuickSearch } from '../editor/quick-search';
@@ -15,7 +14,7 @@ import { roleColor } from '../../lib/status';
15
14
  import { MediaViewer, isMediaFile } from '../editor/media-viewer';
16
15
  import {
17
16
  X, Code2, FileCode, GitCompareArrows,
18
- ClipboardCheck, Users, PanelLeftOpen, Sparkles, Search,
17
+ ClipboardCheck, Users, PanelLeftOpen, Search,
19
18
  } from 'lucide-react';
20
19
 
21
20
  const TREE_DEFAULT = 220;
@@ -61,7 +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, onToggleAi, aiOpen, onCmdP }) {
63
+ function TabBar({ tabs, activeFile, files, onSelect, onClose, diffMode, onToggleDiff, workspaceSnapshots, onBackToTeam, onToggleReview, reviewMode, onCmdP }) {
65
64
  const hasSnapshot = activeFile && workspaceSnapshots[activeFile];
66
65
 
67
66
  return (
@@ -142,19 +141,6 @@ function TabBar({ tabs, activeFile, files, onSelect, onClose, diffMode, onToggle
142
141
  <ClipboardCheck size={12} />
143
142
  </button>
144
143
  </Tooltip>
145
- <Tooltip content="AI Panel" side="bottom">
146
- <button
147
- onClick={onToggleAi}
148
- className={cn(
149
- 'flex items-center gap-1 px-2 py-1 text-xs font-sans rounded cursor-pointer transition-colors',
150
- aiOpen
151
- ? 'bg-accent/15 text-accent'
152
- : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
153
- )}
154
- >
155
- <Sparkles size={12} />
156
- </button>
157
- </Tooltip>
158
144
  <Tooltip content="Back to Team View" side="bottom">
159
145
  <button
160
146
  onClick={onBackToTeam}
@@ -177,10 +163,6 @@ export function WorkspaceMode() {
177
163
  const toggleReviewMode = useGrooveStore((s) => s.toggleReviewMode);
178
164
  const workspaceSnapshots = useGrooveStore((s) => s.workspaceSnapshots);
179
165
  const setWorkspaceMode = useGrooveStore((s) => s.setWorkspaceMode);
180
- const aiPanelOpen = useGrooveStore((s) => s.editorAiPanelOpen);
181
- const toggleAiPanel = useGrooveStore((s) => s.toggleAiPanel);
182
- const aiPanelWidth = useGrooveStore((s) => s.editorAiPanelWidth);
183
- const setAiPanelWidth = useGrooveStore((s) => s.setEditorAiPanelWidth);
184
166
  const setQuickSearchOpen = useGrooveStore((s) => s.setEditorQuickSearchOpen);
185
167
 
186
168
  const editorFiles = useGrooveStore((s) => s.editorFiles);
@@ -205,9 +187,6 @@ export function WorkspaceMode() {
205
187
  const treeDragging = useRef(false);
206
188
  const startX = useRef(0);
207
189
  const startW = useRef(0);
208
- const aiDragging = useRef(false);
209
- const aiStartX = useRef(0);
210
- const aiStartW = useRef(0);
211
190
 
212
191
  useEffect(() => {
213
192
  setDiffMode(false);
@@ -238,24 +217,6 @@ export function WorkspaceMode() {
238
217
  document.addEventListener('mouseup', onUp);
239
218
  }, [treeWidth]);
240
219
 
241
- const onAiPanelMouseDown = useCallback((e) => {
242
- e.preventDefault();
243
- aiDragging.current = true;
244
- aiStartX.current = e.clientX;
245
- aiStartW.current = aiPanelWidth;
246
- function onMove(e) {
247
- if (!aiDragging.current) return;
248
- const delta = aiStartX.current - e.clientX;
249
- setAiPanelWidth(Math.min(Math.max(aiStartW.current + delta, 280), 600));
250
- }
251
- function onUp() {
252
- aiDragging.current = false;
253
- document.removeEventListener('mousemove', onMove);
254
- document.removeEventListener('mouseup', onUp);
255
- }
256
- document.addEventListener('mousemove', onMove);
257
- document.addEventListener('mouseup', onUp);
258
- }, [aiPanelWidth, setAiPanelWidth]);
259
220
 
260
221
  const handleEditorMouseUp = useCallback(() => {
261
222
  const view = editorViewRef.current;
@@ -357,8 +318,6 @@ export function WorkspaceMode() {
357
318
  onBackToTeam={() => setWorkspaceMode(false)}
358
319
  onToggleReview={toggleReviewMode}
359
320
  reviewMode={workspaceReviewMode}
360
- onToggleAi={toggleAiPanel}
361
- aiOpen={aiPanelOpen}
362
321
  onCmdP={() => setQuickSearchOpen(true)}
363
322
  />
364
323
 
@@ -423,16 +382,6 @@ export function WorkspaceMode() {
423
382
  )}
424
383
  </div>
425
384
 
426
- {/* AI Panel */}
427
- {aiPanelOpen && !workspaceReviewMode && (
428
- <div className="relative flex-shrink-0" style={{ width: aiPanelWidth }}>
429
- <div
430
- className="absolute top-0 left-0 bottom-0 w-1 cursor-col-resize hover:bg-accent/30 transition-colors z-10"
431
- onMouseDown={onAiPanelMouseDown}
432
- />
433
- <AiPanel />
434
- </div>
435
- )}
436
385
  </div>
437
386
 
438
387
  {/* Quick Search modal */}
@@ -0,0 +1,111 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { memo } from 'react';
3
+ import { HEX } from '../../lib/theme-hex';
4
+
5
+ const SIZE = 36;
6
+ const STROKE = 3;
7
+ const RADIUS = (SIZE - STROKE) / 2;
8
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
9
+ const START_ANGLE = -90;
10
+
11
+ function gaugeColor(pct) {
12
+ if (pct > 80) return HEX.danger;
13
+ if (pct > 60) return HEX.warning;
14
+ return HEX.success;
15
+ }
16
+
17
+ function MiniGauge({ name, pct, threshold }) {
18
+ const color = gaugeColor(pct);
19
+ const dashLen = (pct / 100) * CIRCUMFERENCE;
20
+
21
+ return (
22
+ <div className="flex flex-col items-center gap-0.5" title={`${name}: ${pct}% context used`}>
23
+ <svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
24
+ <circle
25
+ cx={SIZE / 2} cy={SIZE / 2} r={RADIUS}
26
+ fill="none" strokeWidth={STROKE}
27
+ className="stroke-surface-4"
28
+ />
29
+ <circle
30
+ cx={SIZE / 2} cy={SIZE / 2} r={RADIUS}
31
+ fill="none" strokeWidth={STROKE}
32
+ strokeLinecap="round"
33
+ style={{
34
+ stroke: color,
35
+ strokeDasharray: `${dashLen} ${CIRCUMFERENCE - dashLen}`,
36
+ strokeDashoffset: 0,
37
+ transition: 'stroke-dasharray 0.5s ease',
38
+ }}
39
+ transform={`rotate(${START_ANGLE} ${SIZE / 2} ${SIZE / 2})`}
40
+ />
41
+ {threshold && (
42
+ <circle
43
+ cx={SIZE / 2} cy={SIZE / 2} r={RADIUS}
44
+ fill="none" strokeWidth={1}
45
+ strokeLinecap="butt"
46
+ style={{
47
+ stroke: HEX.purple,
48
+ strokeDasharray: `1 ${CIRCUMFERENCE - 1}`,
49
+ strokeDashoffset: -(threshold / 100) * CIRCUMFERENCE,
50
+ }}
51
+ transform={`rotate(${START_ANGLE} ${SIZE / 2} ${SIZE / 2})`}
52
+ />
53
+ )}
54
+ <text
55
+ x={SIZE / 2} y={SIZE / 2 + 1}
56
+ textAnchor="middle" dominantBaseline="central"
57
+ className="fill-text-1 font-mono font-semibold"
58
+ style={{ fontSize: 9 }}
59
+ >
60
+ {pct}
61
+ </text>
62
+ </svg>
63
+ <span className="text-2xs font-mono text-text-3 truncate max-w-[40px] leading-none">{name}</span>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function FleetSummary({ zones }) {
69
+ return (
70
+ <div className="flex items-center gap-2 text-2xs font-mono">
71
+ <span className="text-success">{zones.healthy}</span>
72
+ <span className="text-text-4">/</span>
73
+ <span className="text-warning">{zones.warning}</span>
74
+ <span className="text-text-4">/</span>
75
+ <span className="text-danger">{zones.critical}</span>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ const ContextGauges = memo(function ContextGauges({ agentBreakdown }) {
81
+ const alive = (agentBreakdown || []).filter(
82
+ (a) => a.status === 'running' || a.status === 'starting',
83
+ );
84
+ if (alive.length === 0) return null;
85
+
86
+ const zones = { healthy: 0, warning: 0, critical: 0 };
87
+ for (const a of alive) {
88
+ const pct = Math.round((a.contextUsage || 0) * 100);
89
+ if (pct > 80) zones.critical++;
90
+ else if (pct > 60) zones.warning++;
91
+ else zones.healthy++;
92
+ }
93
+
94
+ return (
95
+ <div className="px-3 py-2 flex-shrink-0 border-b border-border">
96
+ <div className="flex items-center justify-between mb-1.5">
97
+ <span className="text-2xs font-mono text-text-3 uppercase tracking-widest">Context Health</span>
98
+ <FleetSummary zones={zones} />
99
+ </div>
100
+ <div className="flex items-start gap-2 overflow-x-auto">
101
+ {alive.map((a) => {
102
+ const pct = Math.round((a.contextUsage || 0) * 100);
103
+ const threshold = a.rotationThreshold ? Math.round(a.rotationThreshold * 100) : null;
104
+ return <MiniGauge key={a.id} name={a.name} pct={pct} threshold={threshold} />;
105
+ })}
106
+ </div>
107
+ </div>
108
+ );
109
+ });
110
+
111
+ export { ContextGauges };