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.
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +127 -0
- package/apps/dashboard/app/page.tsx +10 -0
- package/apps/dashboard/app/tests/page.tsx +73 -0
- package/apps/dashboard/components/CardMenu.tsx +19 -2
- package/apps/dashboard/components/ClaudePanel.tsx +271 -0
- package/apps/dashboard/components/KanbanBoard.tsx +11 -4
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +62 -3
- package/apps/dashboard/components/TestTree.tsx +208 -0
- package/apps/dashboard/hooks/useClaudeSessions.ts +265 -0
- package/apps/dashboard/hooks/useClaudeStream.ts +205 -0
- package/apps/dashboard/lib/tests.ts +201 -0
- package/apps/dashboard/next.config.ts +31 -1
- package/apps/dashboard/package.json +1 -1
- package/cucumber-results.json +12970 -0
- package/lib/git-hooks/pre-commit +6 -0
- package/lib/work-commands/index.js +20 -10
- package/package.json +1 -1
- package/skills-templates/chore-mode/SKILL.md +14 -1
- package/skills-templates/chore-planning/SKILL.md +35 -3
- package/skills-templates/epic-planning/SKILL.md +148 -9
- package/skills-templates/feature-planning/SKILL.md +6 -2
- package/skills-templates/request-routing/SKILL.md +24 -0
- package/skills-templates/simple-improvement/SKILL.md +30 -4
|
@@ -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
|
|
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=
|
|
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
|
+
}
|