groove-dev 0.26.20 → 0.26.22

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.
@@ -1,89 +1,51 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
  import { useState, useEffect } from 'react';
3
3
 
4
- const PHASES = [
5
- 'Analyzing codebase',
6
- 'Reading context',
7
- 'Planning approach',
8
- 'Reasoning through solution',
9
- 'Evaluating options',
10
- ];
11
-
12
- const DETAILS = [
13
- ['Scanning project structure...', 'Checking dependencies...', 'Reviewing recent changes...'],
14
- ['Parsing file tree...', 'Loading imports...', 'Indexing symbols...'],
15
- ['Mapping task scope...', 'Identifying constraints...', 'Outlining steps...'],
16
- ['Tracing logic flow...', 'Considering edge cases...', 'Weighing approaches...'],
17
- ['Comparing alternatives...', 'Checking tradeoffs...', 'Selecting best path...'],
4
+ const MESSAGES = [
5
+ 'Reading through the codebase...',
6
+ 'Thinking through your request...',
7
+ 'Planning the approach...',
8
+ 'Running tool calls...',
9
+ 'Working through the problem...',
10
+ 'Reasoning step by step...',
11
+ 'Reviewing context...',
12
+ 'Considering options...',
13
+ 'Analyzing the code...',
14
+ 'Making progress...',
18
15
  ];
19
16
 
20
17
  export function ThinkingIndicator({ agent, className }) {
21
- const [phase, setPhase] = useState(0);
22
- const [elapsed, setElapsed] = useState(0);
23
-
24
- // Cycle phases every 3.5s
25
- useEffect(() => {
26
- const t = setInterval(() => setPhase((p) => (p + 1) % PHASES.length), 3500);
27
- return () => clearInterval(t);
28
- }, []);
18
+ const [idx, setIdx] = useState(0);
19
+ const [fade, setFade] = useState(true);
29
20
 
30
- // Elapsed timer
31
21
  useEffect(() => {
32
- const t = setInterval(() => setElapsed((e) => e + 1), 1000);
22
+ const t = setInterval(() => {
23
+ setFade(false);
24
+ setTimeout(() => {
25
+ setIdx((i) => (i + 1) % MESSAGES.length);
26
+ setFade(true);
27
+ }, 250);
28
+ }, 2800);
33
29
  return () => clearInterval(t);
34
30
  }, []);
35
31
 
36
- const secs = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`;
37
-
38
32
  return (
39
- <div className={`relative rounded-xl bg-surface-2 border border-border-subtle overflow-hidden my-2 ${className || ''}`}>
40
- {/* Shimmer sweep */}
41
- <div className="absolute inset-0 bg-gradient-to-r from-transparent via-accent/[0.04] to-transparent animate-shimmer pointer-events-none" />
42
-
43
- <div className="relative px-4 py-4 min-h-[90px]">
44
- {/* Header: spinning ring + identity + timer */}
45
- <div className="flex items-center gap-3 mb-4">
46
- {/* Spinning ring same pattern as BootSequence */}
47
- <div className="relative w-8 h-8 flex-shrink-0">
48
- <span className="absolute inset-0 rounded-full border-2 border-accent/15 animate-ping" style={{ animationDuration: '2.5s' }} />
49
- <span className="absolute inset-0 rounded-full border-2 border-transparent border-t-accent animate-spin" style={{ animationDuration: '1.2s' }} />
50
- <span className="absolute inset-[6px] rounded-full bg-accent/10" />
51
- </div>
52
-
53
- <div className="flex-1 min-w-0">
54
- {agent ? (
55
- <>
56
- <p className="text-xs font-sans font-semibold text-text-1 truncate leading-tight">{agent.name}</p>
57
- <p className="text-[10px] font-mono text-accent leading-tight mt-0.5">thinking</p>
58
- </>
59
- ) : (
60
- <p className="text-xs font-sans font-semibold text-text-1 leading-tight">Thinking</p>
61
- )}
62
- </div>
63
-
64
- <span className="text-xs font-mono text-text-2 tabular-nums flex-shrink-0">{secs}</span>
65
- </div>
66
-
67
- {/* Phase label — re-keyed to replay fade-in on phase change */}
68
- <div className="pl-3 border-l-2 border-accent/25 mb-3">
69
- <span key={phase} className="block text-[13px] font-sans font-medium text-text-1 animate-phase-in">
70
- {PHASES[phase]}
71
- </span>
72
- </div>
73
-
74
- {/* Staggered detail lines — re-keyed to replay cascade on phase change */}
75
- <div className="space-y-1.5 pl-3 border-l border-border-subtle">
76
- {DETAILS[phase].map((line, i) => (
77
- <div
78
- key={`${phase}-${i}`}
79
- className="flex items-center gap-2 animate-cascade-in"
80
- style={{ animationDelay: `${150 + i * 200}ms` }}
81
- >
82
- <span className="w-1 h-1 rounded-full bg-accent/35 flex-shrink-0" />
83
- <span className="text-[11px] font-mono text-text-3">{line}</span>
84
- </div>
85
- ))}
33
+ <div className={`${className || ''}`}>
34
+ <div className="flex items-center gap-2 mb-1">
35
+ <span className="text-2xs font-semibold text-text-1 font-sans">{agent?.name || 'Agent'}</span>
36
+ <span className="text-2xs text-accent font-mono">thinking</span>
37
+ </div>
38
+ <div className="border-l border-accent/40 pl-3.5 py-1 flex items-center gap-2.5">
39
+ {/* Spinning ring */}
40
+ <div className="relative w-3.5 h-3.5 flex-shrink-0">
41
+ <span className="absolute inset-0 rounded-full border border-transparent border-t-accent animate-spin" style={{ animationDuration: '0.9s' }} />
86
42
  </div>
43
+ <span
44
+ className="text-[12px] font-sans text-text-3 transition-opacity duration-[250ms]"
45
+ style={{ opacity: fade ? 1 : 0 }}
46
+ >
47
+ {MESSAGES[idx]}
48
+ </span>
87
49
  </div>
88
50
  </div>
89
51
  );
@@ -136,15 +136,6 @@ export const useGrooveStore = create((set, get) => ({
136
136
  case 'agent:output': {
137
137
  const { agentId, data } = msg;
138
138
 
139
- // Clear thinking indicator when agent responds
140
- if (get().thinkingAgents.has(agentId)) {
141
- set((s) => {
142
- const next = new Set(s.thinkingAgents);
143
- next.delete(agentId);
144
- return { thinkingAgents: next };
145
- });
146
- }
147
-
148
139
  // Separate text content from tool calls
149
140
  let chatText = '';
150
141
  let activityText = '';
@@ -177,6 +168,15 @@ export const useGrooveStore = create((set, get) => ({
177
168
  (data.type === 'activity' && typeof data.data === 'string')
178
169
  );
179
170
  if (showAsChat) {
171
+ // Clear thinking indicator only when actual text renders as a chat bubble
172
+ if (get().thinkingAgents.has(agentId)) {
173
+ set((s) => {
174
+ const next = new Set(s.thinkingAgents);
175
+ next.delete(agentId);
176
+ return { thinkingAgents: next };
177
+ });
178
+ }
179
+
180
180
  const trimmed = chatText.trim();
181
181
  const history = { ...get().chatHistory };
182
182
  if (!history[agentId]) history[agentId] = [];
@@ -516,21 +516,46 @@ export const useGrooveStore = create((set, get) => ({
516
516
  async checkRecommendedTeam() {
517
517
  try {
518
518
  const data = await api.get('/recommended-team');
519
- if (data && data.agents?.length) {
520
- set({ recommendedTeam: data });
521
- } else {
519
+ if (!data || !data.agents?.length) {
522
520
  set({ recommendedTeam: null });
521
+ return;
523
522
  }
523
+
524
+ // Check if all recommended roles already exist in the planner's team.
525
+ // If so, auto-delegate instead of showing the "Launch Team" modal.
526
+ const planners = get().agents.filter((a) => a.role === 'planner');
527
+ const planner = planners.sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''))[0];
528
+ const teamId = planner?.teamId;
529
+
530
+ if (teamId) {
531
+ const teamAgents = get().agents.filter((a) => a.teamId === teamId && a.role !== 'planner');
532
+ const phase1Roles = data.agents.filter((a) => !a.phase || a.phase === 1).map((a) => a.role);
533
+ const allExist = phase1Roles.every((role) => teamAgents.some((a) => a.role === role));
534
+
535
+ if (allExist && phase1Roles.length > 0) {
536
+ // Auto-delegate — all agents already exist in the team
537
+ set({ recommendedTeam: null });
538
+ const result = await api.post('/recommended-team/launch');
539
+ const names = result.agents?.map((a) => a.name).join(', ') || '';
540
+ get().addToast('success', 'Planner delegated work', names ? `→ ${names}` : undefined);
541
+ api.post('/cleanup').catch(() => {});
542
+ return;
543
+ }
544
+ }
545
+
546
+ // New agents needed — show the modal for approval
547
+ set({ recommendedTeam: data });
524
548
  } catch {
525
549
  set({ recommendedTeam: null });
526
550
  }
527
551
  },
528
552
 
529
- async launchRecommendedTeam() {
553
+ async launchRecommendedTeam(modifiedAgents) {
530
554
  try {
531
555
  set({ recommendedTeam: null }); // Dismiss modal immediately
532
556
  get().addToast('info', 'Launching team...');
533
- const result = await api.post('/recommended-team/launch');
557
+ const body = modifiedAgents ? { agents: modifiedAgents } : undefined;
558
+ const result = await api.post('/recommended-team/launch', body);
534
559
  const sub = [
535
560
  result.phase2Pending ? `${result.phase2Pending} QC queued` : '',
536
561
  result.projectDir ? `→ ${result.projectDir}/` : '',
@@ -474,11 +474,17 @@ function EmptyState({ onPlanner, onSpawn }) {
474
474
 
475
475
  const ROLE_ICONS = { backend: Server, frontend: Monitor, fullstack: Code2, testing: TestTube, security: Shield };
476
476
 
477
+ const NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
478
+
479
+ function sanitizeName(raw) {
480
+ return raw.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64);
481
+ }
482
+
477
483
  function RecommendedTeamCard() {
478
484
  const recommendedTeam = useGrooveStore((s) => s.recommendedTeam);
479
485
  const launchRecommendedTeam = useGrooveStore((s) => s.launchRecommendedTeam);
480
- const checkRecommendedTeam = useGrooveStore((s) => s.checkRecommendedTeam);
481
486
  const [launching, setLaunching] = useState(false);
487
+ const [editedAgents, setEditedAgents] = useState(null);
482
488
 
483
489
  if (!recommendedTeam?.agents?.length) return null;
484
490
 
@@ -486,10 +492,23 @@ function RecommendedTeamCard() {
486
492
  const phase1 = agents.filter((a) => !a.phase || a.phase === 1);
487
493
  const phase2 = agents.filter((a) => a.phase === 2);
488
494
 
495
+ // Initialize edits lazily so we get fresh data if recommendedTeam changes
496
+ const agentEdits = editedAgents ?? phase1.map((a) => ({ ...a, name: a.name || '' }));
497
+
498
+ function handleNameChange(i, raw) {
499
+ const next = agentEdits.map((a, idx) => idx === i ? { ...a, name: sanitizeName(raw) } : a);
500
+ setEditedAgents(next);
501
+ }
502
+
489
503
  async function handleLaunch() {
490
504
  setLaunching(true);
491
505
  try {
492
- await launchRecommendedTeam();
506
+ // Merge edited phase1 names back with phase2 agents
507
+ const modified = [
508
+ ...agentEdits,
509
+ ...phase2,
510
+ ];
511
+ await launchRecommendedTeam(modified);
493
512
  } catch { /* toast handles */ }
494
513
  setLaunching(false);
495
514
  }
@@ -507,26 +526,38 @@ function RecommendedTeamCard() {
507
526
  <button onClick={handleDismiss} className="text-text-4 hover:text-text-1 cursor-pointer"><X size={14} /></button>
508
527
  </div>
509
528
 
510
- <div className="px-4 py-3 space-y-2">
511
- {/* Phase 1 agents */}
512
- <div className="flex flex-wrap gap-2">
513
- {phase1.map((a, i) => {
514
- const Icon = ROLE_ICONS[a.role] || Code2;
515
- return (
516
- <div key={i} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-surface-4 border border-border-subtle">
517
- <Icon size={12} className="text-text-2" />
518
- <span className="text-xs font-semibold text-text-0 font-sans capitalize">{a.name || a.role}</span>
519
- {a.scope?.length > 0 && (
520
- <span className="text-2xs text-text-4 font-mono">{a.scope[0]}{a.scope.length > 1 ? ` +${a.scope.length - 1}` : ''}</span>
529
+ <div className="px-4 py-3 space-y-1.5">
530
+ {/* Phase 1 agents — editable rows */}
531
+ {agentEdits.map((a, i) => {
532
+ const Icon = ROLE_ICONS[a.role] || Code2;
533
+ const nameValid = !a.name || NAME_RE.test(a.name);
534
+ return (
535
+ <div key={i} className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-surface-4 border border-border-subtle">
536
+ <Icon size={12} className="text-text-2 shrink-0" />
537
+ <input
538
+ type="text"
539
+ value={a.name}
540
+ onChange={(e) => handleNameChange(i, e.target.value)}
541
+ placeholder={a.role}
542
+ className={cn(
543
+ 'flex-1 min-w-0 bg-transparent text-xs font-mono text-text-0 outline-none placeholder:text-text-4',
544
+ !nameValid && 'text-red-400',
521
545
  )}
522
- </div>
523
- );
524
- })}
525
- </div>
546
+ maxLength={64}
547
+ spellCheck={false}
548
+ />
549
+ {a.scope?.length > 0 && (
550
+ <span className="text-2xs text-text-4 font-mono shrink-0 truncate max-w-[120px]">
551
+ {a.scope[0]}{a.scope.length > 1 ? ` +${a.scope.length - 1}` : ''}
552
+ </span>
553
+ )}
554
+ </div>
555
+ );
556
+ })}
526
557
 
527
558
  {/* Project dir indicator */}
528
559
  {recommendedTeam.projectDir && (
529
- <div className="flex items-center gap-1.5 text-2xs text-text-2 font-mono">
560
+ <div className="flex items-center gap-1.5 text-2xs text-text-2 font-mono pt-0.5">
530
561
  <span className="text-text-4">Project:</span>
531
562
  <span className="text-accent">{recommendedTeam.projectDir}/</span>
532
563
  </div>