jettypod 4.4.98 → 4.4.100

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,11 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback, useMemo } from 'react';
3
+ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
4
4
  import { KanbanBoard } from './KanbanBoard';
5
+ import { ClaudePanel } from './ClaudePanel';
5
6
  import { RecentDecisionsWidget } from './RecentDecisionsWidget';
6
7
  import { ToastProvider, useToast } from './Toast';
7
8
  import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
8
- import type { InFlightItem, KanbanGroup, Decision } from '@/lib/db';
9
+ import { useClaudeStream } from '../hooks/useClaudeStream';
10
+ import type { InFlightItem, KanbanGroup, Decision, WorkItem } from '@/lib/db';
9
11
  import { UndoStack, type UndoAction } from '@/lib/undoStack';
10
12
 
11
13
  interface KanbanData {
@@ -62,6 +64,26 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
62
64
  const [decisions, setDecisions] = useState<Decision[]>(initialDecisions);
63
65
  const [statusError, setStatusError] = useState<string | null>(null);
64
66
 
67
+ // Claude panel state
68
+ const [claudePanelOpen, setClaudePanelOpen] = useState(false);
69
+ const [activeWorkItem, setActiveWorkItem] = useState<{ id: string; title: string; type?: string } | null>(null);
70
+ const pendingStartRef = useRef(false);
71
+
72
+ // Claude stream hook
73
+ const claudeStream = useClaudeStream({
74
+ workItemId: activeWorkItem?.id || '0',
75
+ title: activeWorkItem?.title,
76
+ type: activeWorkItem?.type,
77
+ });
78
+
79
+ // Start streaming after activeWorkItem state is updated (fixes race condition)
80
+ useEffect(() => {
81
+ if (pendingStartRef.current && activeWorkItem?.id) {
82
+ pendingStartRef.current = false;
83
+ claudeStream.start();
84
+ }
85
+ }, [activeWorkItem, claudeStream]);
86
+
65
87
  // Undo/redo stack - created once per component instance
66
88
  const [undoStack] = useState(() => new UndoStack());
67
89
  const [undoRedoVersion, setUndoRedoVersion] = useState(0); // Force re-render on stack changes
@@ -250,6 +272,28 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
250
272
  const canUndo = undoStack.canUndo();
251
273
  const canRedo = undoStack.canRedo();
252
274
 
275
+ // Claude panel handlers
276
+ const handleTriggerClaude = useCallback((id: number, title: string) => {
277
+ // Look up work item to get its type
278
+ const found = findItemById(data, id);
279
+ const itemType = (found?.item as WorkItem)?.type || 'chore';
280
+
281
+ pendingStartRef.current = true;
282
+ setActiveWorkItem({ id: String(id), title, type: itemType });
283
+ setClaudePanelOpen(true);
284
+ // useEffect will call claudeStream.start() after re-render with new workItemId
285
+ }, [data]);
286
+
287
+ const handleClaudeMinimize = useCallback(() => {
288
+ setClaudePanelOpen(false);
289
+ }, []);
290
+
291
+ const handleClaudeClose = useCallback(() => {
292
+ claudeStream.stop();
293
+ setClaudePanelOpen(false);
294
+ setActiveWorkItem(null);
295
+ }, [claudeStream]);
296
+
253
297
  const wsUrl = typeof window !== 'undefined'
254
298
  ? `ws://${window.location.hostname}:8080`
255
299
  : 'ws://localhost:8080';
@@ -300,7 +344,7 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
300
344
  </span>
301
345
  </div>
302
346
  <div className="flex-1 min-h-0 flex gap-4">
303
- <div className="flex-1 min-w-0">
347
+ <div className={`flex-1 min-w-0 transition-all duration-300 ${claudePanelOpen ? 'mr-[480px]' : ''}`}>
304
348
  <KanbanBoard
305
349
  inFlight={data.inFlight}
306
350
  backlog={data.backlog}
@@ -309,6 +353,7 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
309
353
  onStatusChange={handleStatusChange}
310
354
  onOrderChange={handleOrderChange}
311
355
  onEpicAssign={handleEpicAssign}
356
+ onTriggerClaude={handleTriggerClaude}
312
357
  onUndo={handleUndo}
313
358
  onRedo={handleRedo}
314
359
  canUndo={canUndo}
@@ -319,6 +364,20 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
319
364
  <RecentDecisionsWidget decisions={decisions} />
320
365
  </div>
321
366
  </div>
367
+ {/* Claude Panel */}
368
+ <ClaudePanel
369
+ isOpen={claudePanelOpen}
370
+ workItemId={activeWorkItem?.id || ''}
371
+ workItemTitle={activeWorkItem?.title || ''}
372
+ messages={claudeStream.messages}
373
+ status={claudeStream.status}
374
+ error={claudeStream.error}
375
+ exitCode={claudeStream.exitCode}
376
+ canRetry={claudeStream.canRetry}
377
+ onMinimize={handleClaudeMinimize}
378
+ onClose={handleClaudeClose}
379
+ onRetry={claudeStream.retry}
380
+ />
322
381
  </div>
323
382
  );
324
383
  }
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { TestDashboardData, TestEpic, TestFeature, TestScenario } from '@/lib/tests';
5
+
6
+ const statusIcons: Record<string, string> = {
7
+ pass: '✅',
8
+ fail: '❌',
9
+ pending: '⏳',
10
+ };
11
+
12
+ const statusColors: Record<string, string> = {
13
+ pass: 'text-green-600 dark:text-green-400',
14
+ fail: 'text-red-600 dark:text-red-400',
15
+ pending: 'text-amber-600 dark:text-amber-400',
16
+ };
17
+
18
+ interface ScenarioNodeProps {
19
+ scenario: TestScenario;
20
+ depth: number;
21
+ }
22
+
23
+ function ScenarioNode({ scenario, depth }: ScenarioNodeProps) {
24
+ const [expanded, setExpanded] = useState(false);
25
+ const hasError = scenario.status === 'fail' && (scenario.error || scenario.failedStep);
26
+
27
+ return (
28
+ <div className="select-none">
29
+ <div
30
+ className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors ${hasError ? 'cursor-pointer' : ''}`}
31
+ style={{ paddingLeft: `${depth * 20 + 8}px` }}
32
+ onClick={() => hasError && setExpanded(!expanded)}
33
+ >
34
+ {hasError ? (
35
+ <button className="w-4 h-4 flex items-center justify-center text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
36
+ {expanded ? '▼' : '▶'}
37
+ </button>
38
+ ) : (
39
+ <span className="w-4" />
40
+ )}
41
+ <span className="text-sm">{statusIcons[scenario.status]}</span>
42
+ <span className={`flex-1 text-sm ${statusColors[scenario.status]}`}>
43
+ {scenario.title}
44
+ </span>
45
+ <span className="text-xs text-zinc-400 font-mono">{scenario.duration}</span>
46
+ </div>
47
+
48
+ {/* Error details panel */}
49
+ {expanded && hasError && (
50
+ <div
51
+ className="mt-1 mb-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm"
52
+ style={{ marginLeft: `${depth * 20 + 28}px` }}
53
+ >
54
+ {scenario.failedStep && (
55
+ <div className="mb-2">
56
+ <span className="font-semibold text-red-700 dark:text-red-300">Failed step: </span>
57
+ <span className="text-red-600 dark:text-red-400">{scenario.failedStep}</span>
58
+ </div>
59
+ )}
60
+ {scenario.error && (
61
+ <div>
62
+ <span className="font-semibold text-red-700 dark:text-red-300">Error: </span>
63
+ <pre className="mt-1 p-2 bg-red-100 dark:bg-red-900/40 rounded text-xs text-red-800 dark:text-red-200 overflow-x-auto whitespace-pre-wrap">
64
+ {scenario.error}
65
+ </pre>
66
+ </div>
67
+ )}
68
+ </div>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ interface FeatureNodeProps {
75
+ feature: TestFeature;
76
+ depth: number;
77
+ }
78
+
79
+ function FeatureNode({ feature, depth }: FeatureNodeProps) {
80
+ const [expanded, setExpanded] = useState(true);
81
+ const hasScenarios = feature.scenarios.length > 0;
82
+
83
+ const passingCount = feature.scenarios.filter(s => s.status === 'pass').length;
84
+ const failingCount = feature.scenarios.filter(s => s.status === 'fail').length;
85
+ const allPassing = failingCount === 0 && passingCount > 0;
86
+
87
+ return (
88
+ <div className="select-none">
89
+ <div
90
+ className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
91
+ style={{ paddingLeft: `${depth * 20 + 8}px` }}
92
+ onClick={() => setExpanded(!expanded)}
93
+ >
94
+ {hasScenarios ? (
95
+ <button className="w-4 h-4 flex items-center justify-center text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
96
+ {expanded ? '▼' : '▶'}
97
+ </button>
98
+ ) : (
99
+ <span className="w-4" />
100
+ )}
101
+ <span className="text-sm">✨</span>
102
+ <span className={`flex-1 font-medium ${allPassing ? 'text-zinc-900 dark:text-zinc-100' : 'text-red-600 dark:text-red-400'}`}>
103
+ {feature.title}
104
+ </span>
105
+ {/* Health badge */}
106
+ <span className="text-xs px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800">
107
+ <span className="text-green-600 dark:text-green-400">{passingCount}</span>
108
+ <span className="text-zinc-400 mx-1">/</span>
109
+ <span className={failingCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-zinc-400'}>
110
+ {failingCount}
111
+ </span>
112
+ </span>
113
+ </div>
114
+
115
+ {expanded && hasScenarios && (
116
+ <div>
117
+ {feature.scenarios.map(scenario => (
118
+ <ScenarioNode key={scenario.id} scenario={scenario} depth={depth + 1} />
119
+ ))}
120
+ </div>
121
+ )}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ interface EpicNodeProps {
127
+ epic: TestEpic;
128
+ }
129
+
130
+ function EpicNode({ epic }: EpicNodeProps) {
131
+ const [expanded, setExpanded] = useState(true);
132
+ const hasFeatures = epic.features.length > 0;
133
+ const allPassing = epic.healthBadge.failing === 0;
134
+
135
+ return (
136
+ <div className="select-none">
137
+ <div
138
+ className="flex items-center gap-2 py-2 px-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
139
+ onClick={() => setExpanded(!expanded)}
140
+ >
141
+ {hasFeatures ? (
142
+ <button className="w-4 h-4 flex items-center justify-center text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
143
+ {expanded ? '▼' : '▶'}
144
+ </button>
145
+ ) : (
146
+ <span className="w-4" />
147
+ )}
148
+ <span className="text-base">🎯</span>
149
+ <span className={`flex-1 font-semibold ${allPassing ? 'text-zinc-900 dark:text-zinc-100' : 'text-red-600 dark:text-red-400'}`}>
150
+ {epic.title}
151
+ </span>
152
+ {/* Health badge */}
153
+ <span className="text-xs px-2 py-1 rounded bg-zinc-100 dark:bg-zinc-800 font-mono">
154
+ <span className="text-green-600 dark:text-green-400">{epic.healthBadge.passing}</span>
155
+ <span className="text-zinc-400 mx-1">/</span>
156
+ <span className={epic.healthBadge.failing > 0 ? 'text-red-600 dark:text-red-400' : 'text-zinc-400'}>
157
+ {epic.healthBadge.failing}
158
+ </span>
159
+ </span>
160
+ </div>
161
+
162
+ {expanded && hasFeatures && (
163
+ <div>
164
+ {epic.features.map(feature => (
165
+ <FeatureNode key={feature.id} feature={feature} depth={1} />
166
+ ))}
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ interface TestTreeProps {
174
+ data: TestDashboardData;
175
+ }
176
+
177
+ export function TestTree({ data }: TestTreeProps) {
178
+ if (data.epics.length === 0 && data.standaloneFeatures.length === 0) {
179
+ return (
180
+ <div className="text-zinc-500 text-center py-8">
181
+ No test features found
182
+ </div>
183
+ );
184
+ }
185
+
186
+ return (
187
+ <div className="font-mono text-sm space-y-2">
188
+ {/* Epics */}
189
+ {data.epics.map(epic => (
190
+ <EpicNode key={epic.id} epic={epic} />
191
+ ))}
192
+
193
+ {/* Standalone Features */}
194
+ {data.standaloneFeatures.length > 0 && (
195
+ <div className="mt-4">
196
+ {data.epics.length > 0 && (
197
+ <div className="text-xs text-zinc-400 uppercase tracking-wider mb-2 px-2">
198
+ Standalone Features
199
+ </div>
200
+ )}
201
+ {data.standaloneFeatures.map(feature => (
202
+ <FeatureNode key={feature.id} feature={feature} depth={0} />
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+ );
208
+ }
@@ -0,0 +1,265 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef, useEffect } from 'react';
4
+ import type { ClaudeMessage, ClaudeStreamStatus } from './useClaudeStream';
5
+
6
+ export interface ClaudeSession {
7
+ workItemId: string;
8
+ title: string;
9
+ description?: string;
10
+ messages: ClaudeMessage[];
11
+ status: ClaudeStreamStatus;
12
+ error: string | null;
13
+ exitCode: number | null;
14
+ canRetry: boolean;
15
+ abortController: AbortController | null;
16
+ }
17
+
18
+ interface UseClaudeSessionsReturn {
19
+ sessions: Map<string, ClaudeSession>;
20
+ activeSessionId: string | null;
21
+ activeSession: ClaudeSession | null;
22
+ startSession: (workItemId: string, title: string, description?: string) => void;
23
+ switchSession: (workItemId: string) => void;
24
+ stopSession: (workItemId: string) => void;
25
+ retrySession: (workItemId: string) => void;
26
+ closeAllSessions: () => void;
27
+ isSessionRunning: (workItemId: string) => boolean;
28
+ }
29
+
30
+ export function useClaudeSessions(): UseClaudeSessionsReturn {
31
+ const [sessions, setSessions] = useState<Map<string, ClaudeSession>>(new Map());
32
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
33
+ const sessionsRef = useRef<Map<string, ClaudeSession>>(new Map());
34
+
35
+ // Keep ref in sync with state for cleanup
36
+ useEffect(() => {
37
+ sessionsRef.current = sessions;
38
+ }, [sessions]);
39
+
40
+ // Cleanup all sessions on unmount (browser close/refresh)
41
+ useEffect(() => {
42
+ return () => {
43
+ sessionsRef.current.forEach((session) => {
44
+ if (session.abortController) {
45
+ session.abortController.abort();
46
+ }
47
+ });
48
+ };
49
+ }, []);
50
+
51
+ const updateSession = useCallback((workItemId: string, updates: Partial<ClaudeSession>) => {
52
+ setSessions((prev) => {
53
+ const newMap = new Map(prev);
54
+ const session = newMap.get(workItemId);
55
+ if (session) {
56
+ newMap.set(workItemId, { ...session, ...updates });
57
+ }
58
+ return newMap;
59
+ });
60
+ }, []);
61
+
62
+ const startSession = useCallback(async (workItemId: string, title: string, description?: string) => {
63
+ // Check if session already exists and is running
64
+ const existingSession = sessionsRef.current.get(workItemId);
65
+ if (existingSession?.status === 'streaming') {
66
+ // Just switch to it
67
+ setActiveSessionId(workItemId);
68
+ return;
69
+ }
70
+
71
+ // Create or reset session
72
+ const controller = new AbortController();
73
+ const newSession: ClaudeSession = {
74
+ workItemId,
75
+ title,
76
+ description,
77
+ messages: [],
78
+ status: 'connecting',
79
+ error: null,
80
+ exitCode: null,
81
+ canRetry: false,
82
+ abortController: controller,
83
+ };
84
+
85
+ setSessions((prev) => {
86
+ const newMap = new Map(prev);
87
+ newMap.set(workItemId, newSession);
88
+ return newMap;
89
+ });
90
+ setActiveSessionId(workItemId);
91
+
92
+ try {
93
+ const response = await fetch(`/api/claude/${workItemId}`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({ title, description }),
97
+ signal: controller.signal,
98
+ });
99
+
100
+ if (!response.ok) {
101
+ let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
102
+ try {
103
+ const errorData = await response.json();
104
+ if (errorData.message) {
105
+ errorMessage = errorData.message;
106
+ }
107
+ } catch {
108
+ // Response wasn't JSON
109
+ }
110
+ const retryable = response.status >= 500 || response.status === 503;
111
+ updateSession(workItemId, {
112
+ status: 'error',
113
+ error: errorMessage,
114
+ canRetry: retryable,
115
+ });
116
+ return;
117
+ }
118
+
119
+ if (!response.body) {
120
+ updateSession(workItemId, {
121
+ status: 'error',
122
+ error: 'Response body is null',
123
+ canRetry: true,
124
+ });
125
+ return;
126
+ }
127
+
128
+ updateSession(workItemId, { status: 'streaming' });
129
+
130
+ const reader = response.body.getReader();
131
+ const decoder = new TextDecoder();
132
+ let buffer = '';
133
+
134
+ while (true) {
135
+ const { done, value } = await reader.read();
136
+
137
+ if (done) {
138
+ updateSession(workItemId, { status: 'done' });
139
+ break;
140
+ }
141
+
142
+ buffer += decoder.decode(value, { stream: true });
143
+ const lines = buffer.split('\n');
144
+ buffer = lines.pop() || '';
145
+
146
+ for (const line of lines) {
147
+ if (line.startsWith('data: ')) {
148
+ const data = line.slice(6);
149
+ try {
150
+ const parsed = JSON.parse(data);
151
+ const message: ClaudeMessage = {
152
+ ...parsed,
153
+ timestamp: Date.now(),
154
+ };
155
+
156
+ setSessions((prev) => {
157
+ const newMap = new Map(prev);
158
+ const session = newMap.get(workItemId);
159
+ if (session) {
160
+ newMap.set(workItemId, {
161
+ ...session,
162
+ messages: [...session.messages, message],
163
+ });
164
+ }
165
+ return newMap;
166
+ });
167
+
168
+ if (parsed.type === 'done') {
169
+ if (parsed.exitCode !== undefined && parsed.exitCode !== 0) {
170
+ updateSession(workItemId, {
171
+ status: 'error',
172
+ exitCode: parsed.exitCode,
173
+ error: `Process exited with code ${parsed.exitCode}`,
174
+ canRetry: true,
175
+ });
176
+ } else {
177
+ updateSession(workItemId, { status: 'done' });
178
+ }
179
+ } else if (parsed.type === 'error') {
180
+ updateSession(workItemId, {
181
+ status: 'error',
182
+ error: parsed.content || 'Unknown error',
183
+ canRetry: true,
184
+ });
185
+ }
186
+ } catch {
187
+ // Non-JSON line, skip
188
+ }
189
+ }
190
+ }
191
+ }
192
+ } catch (err) {
193
+ if (err instanceof Error && err.name === 'AbortError') {
194
+ updateSession(workItemId, { status: 'idle' });
195
+ } else {
196
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
197
+ const isNetworkError = err instanceof TypeError ||
198
+ (err instanceof Error && (
199
+ err.message.includes('network') ||
200
+ err.message.includes('fetch') ||
201
+ err.message.includes('Failed to fetch')
202
+ ));
203
+
204
+ updateSession(workItemId, {
205
+ status: 'error',
206
+ error: isNetworkError ? 'Connection lost' : errorMessage,
207
+ canRetry: true,
208
+ });
209
+ }
210
+ }
211
+ }, [updateSession]);
212
+
213
+ const switchSession = useCallback((workItemId: string) => {
214
+ if (sessions.has(workItemId)) {
215
+ setActiveSessionId(workItemId);
216
+ }
217
+ }, [sessions]);
218
+
219
+ const stopSession = useCallback((workItemId: string) => {
220
+ const session = sessions.get(workItemId);
221
+ if (session?.abortController) {
222
+ session.abortController.abort();
223
+ }
224
+ updateSession(workItemId, {
225
+ status: 'idle',
226
+ abortController: null,
227
+ });
228
+ }, [sessions, updateSession]);
229
+
230
+ const retrySession = useCallback((workItemId: string) => {
231
+ const session = sessions.get(workItemId);
232
+ if (session) {
233
+ startSession(workItemId, session.title, session.description);
234
+ }
235
+ }, [sessions, startSession]);
236
+
237
+ const closeAllSessions = useCallback(() => {
238
+ sessions.forEach((session) => {
239
+ if (session.abortController) {
240
+ session.abortController.abort();
241
+ }
242
+ });
243
+ setSessions(new Map());
244
+ setActiveSessionId(null);
245
+ }, [sessions]);
246
+
247
+ const isSessionRunning = useCallback((workItemId: string) => {
248
+ const session = sessions.get(workItemId);
249
+ return session?.status === 'streaming' || session?.status === 'connecting';
250
+ }, [sessions]);
251
+
252
+ const activeSession = activeSessionId ? sessions.get(activeSessionId) ?? null : null;
253
+
254
+ return {
255
+ sessions,
256
+ activeSessionId,
257
+ activeSession,
258
+ startSession,
259
+ switchSession,
260
+ stopSession,
261
+ retrySession,
262
+ closeAllSessions,
263
+ isSessionRunning,
264
+ };
265
+ }