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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
// Import worktree facade for worktree management
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
7
|
+
const worktreeFacade = require('../../../../../../lib/worktree-facade');
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic';
|
|
10
|
+
|
|
11
|
+
function isClaudeCliAvailable(): boolean {
|
|
12
|
+
const result = spawnSync('which', ['claude'], { encoding: 'utf-8' });
|
|
13
|
+
return result.status === 0 && result.stdout.trim().length > 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isValidWorkItemId(id: string): boolean {
|
|
17
|
+
const parsed = parseInt(id, 10);
|
|
18
|
+
return !isNaN(parsed) && parsed > 0 && String(parsed) === id;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function POST(
|
|
22
|
+
request: NextRequest,
|
|
23
|
+
{ params }: { params: Promise<{ workItemId: string }> }
|
|
24
|
+
) {
|
|
25
|
+
const { workItemId } = await params;
|
|
26
|
+
|
|
27
|
+
// Validate work item ID
|
|
28
|
+
if (!isValidWorkItemId(workItemId)) {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ type: 'error', message: 'Invalid work item' },
|
|
31
|
+
{ status: 400 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check if Claude CLI is available
|
|
36
|
+
if (!isClaudeCliAvailable()) {
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ type: 'error', message: 'Claude CLI not found' },
|
|
39
|
+
{ status: 503 }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get the work item context to build the prompt
|
|
44
|
+
const body = await request.json().catch(() => ({}));
|
|
45
|
+
const { title, description, type } = body;
|
|
46
|
+
|
|
47
|
+
// Determine work item type label for the prompt
|
|
48
|
+
const workItemType = type || 'chore';
|
|
49
|
+
|
|
50
|
+
// Get or create worktree for this work item
|
|
51
|
+
const workItem = {
|
|
52
|
+
id: parseInt(workItemId, 10),
|
|
53
|
+
title: title || `Work item ${workItemId}`
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Determine the repo path (go up from the API route to project root)
|
|
57
|
+
const repoPath = path.resolve(process.cwd());
|
|
58
|
+
|
|
59
|
+
// Check for existing worktree or create one
|
|
60
|
+
const workResult = await worktreeFacade.startWork(workItem, { repoPath });
|
|
61
|
+
|
|
62
|
+
// Use worktree path if available, otherwise fall back to main repo
|
|
63
|
+
const claudeCwd = workResult.path;
|
|
64
|
+
|
|
65
|
+
// Build the prompt for Claude based on work item type
|
|
66
|
+
const prompt = `You are working on ${workItemType} #${workItemId}: ${title || 'Unknown task'}
|
|
67
|
+
${description ? `\nDescription: ${description}` : ''}
|
|
68
|
+
|
|
69
|
+
Please start working on this ${workItemType}. Use the appropriate tools to implement the required changes.`;
|
|
70
|
+
|
|
71
|
+
// Create a readable stream that we'll pipe Claude's output to
|
|
72
|
+
const encoder = new TextEncoder();
|
|
73
|
+
|
|
74
|
+
const stream = new ReadableStream({
|
|
75
|
+
start(controller) {
|
|
76
|
+
// Spawn Claude CLI with streaming JSON output in the worktree directory
|
|
77
|
+
const claude = spawn('claude', [
|
|
78
|
+
'-p', prompt,
|
|
79
|
+
'--output-format', 'stream-json'
|
|
80
|
+
], {
|
|
81
|
+
cwd: claudeCwd,
|
|
82
|
+
env: { ...process.env },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
claude.stdout.on('data', (data: Buffer) => {
|
|
86
|
+
// Parse each line of streaming JSON and send as SSE
|
|
87
|
+
const lines = data.toString().split('\n').filter(line => line.trim());
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(line);
|
|
91
|
+
const sseData = `data: ${JSON.stringify(parsed)}\n\n`;
|
|
92
|
+
controller.enqueue(encoder.encode(sseData));
|
|
93
|
+
} catch {
|
|
94
|
+
// If not valid JSON, send as raw text
|
|
95
|
+
const sseData = `data: ${JSON.stringify({ type: 'text', content: line })}\n\n`;
|
|
96
|
+
controller.enqueue(encoder.encode(sseData));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
claude.stderr.on('data', (data: Buffer) => {
|
|
102
|
+
const sseData = `data: ${JSON.stringify({ type: 'error', content: data.toString() })}\n\n`;
|
|
103
|
+
controller.enqueue(encoder.encode(sseData));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
claude.on('close', (code) => {
|
|
107
|
+
const sseData = `data: ${JSON.stringify({ type: 'done', exitCode: code })}\n\n`;
|
|
108
|
+
controller.enqueue(encoder.encode(sseData));
|
|
109
|
+
controller.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
claude.on('error', (err) => {
|
|
113
|
+
const sseData = `data: ${JSON.stringify({ type: 'error', content: err.message })}\n\n`;
|
|
114
|
+
controller.enqueue(encoder.encode(sseData));
|
|
115
|
+
controller.close();
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return new Response(stream, {
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'text/event-stream',
|
|
123
|
+
'Cache-Control': 'no-cache',
|
|
124
|
+
'Connection': 'keep-alive',
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
1
2
|
import { getKanbanData, getProjectName, getRecentDecisions } from '@/lib/db';
|
|
2
3
|
import { RealTimeKanbanWrapper } from '@/components/RealTimeKanbanWrapper';
|
|
3
4
|
|
|
@@ -27,6 +28,15 @@ export default function Home() {
|
|
|
27
28
|
<span className="px-2.5 py-1 text-sm bg-zinc-100 text-zinc-600 rounded-full border border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700">
|
|
28
29
|
{projectName}
|
|
29
30
|
</span>
|
|
31
|
+
<span className="px-2.5 py-1 text-sm text-zinc-900 dark:text-zinc-100 font-medium">
|
|
32
|
+
Backlog
|
|
33
|
+
</span>
|
|
34
|
+
<Link
|
|
35
|
+
href="/tests"
|
|
36
|
+
className="px-2.5 py-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
|
|
37
|
+
>
|
|
38
|
+
Tests
|
|
39
|
+
</Link>
|
|
30
40
|
</div>
|
|
31
41
|
</div>
|
|
32
42
|
</header>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { getTestDashboardData } from '@/lib/tests';
|
|
3
|
+
import { getProjectName } from '@/lib/db';
|
|
4
|
+
import { TestTree } from '@/components/TestTree';
|
|
5
|
+
|
|
6
|
+
export default function TestsPage() {
|
|
7
|
+
const data = getTestDashboardData();
|
|
8
|
+
const projectName = getProjectName();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-zinc-950">
|
|
12
|
+
<header className="sticky top-0 z-10 border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex-shrink-0">
|
|
13
|
+
<div className="max-w-6xl mx-auto px-4 py-4">
|
|
14
|
+
<div className="flex items-center gap-3">
|
|
15
|
+
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
|
16
|
+
JettyPod Dashboard
|
|
17
|
+
</h1>
|
|
18
|
+
<span className="px-2.5 py-1 text-sm bg-zinc-100 text-zinc-600 rounded-full border border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700">
|
|
19
|
+
{projectName}
|
|
20
|
+
</span>
|
|
21
|
+
<Link
|
|
22
|
+
href="/"
|
|
23
|
+
className="px-2.5 py-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
|
|
24
|
+
>
|
|
25
|
+
Backlog
|
|
26
|
+
</Link>
|
|
27
|
+
<span className="px-2.5 py-1 text-sm text-zinc-900 dark:text-zinc-100 font-medium">
|
|
28
|
+
Tests
|
|
29
|
+
</span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</header>
|
|
33
|
+
|
|
34
|
+
<main className="flex-1 overflow-auto max-w-6xl w-full mx-auto px-4 py-4">
|
|
35
|
+
{/* Summary Bar */}
|
|
36
|
+
<div className="flex gap-4 mb-6">
|
|
37
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-lg">
|
|
38
|
+
<span className="text-sm text-zinc-500 dark:text-zinc-400">Total</span>
|
|
39
|
+
<span className="font-mono font-semibold text-zinc-900 dark:text-zinc-100">
|
|
40
|
+
{data.summary.total}
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
44
|
+
<span className="text-sm text-green-600 dark:text-green-400">Passing</span>
|
|
45
|
+
<span className="font-mono font-semibold text-green-700 dark:text-green-300">
|
|
46
|
+
{data.summary.passing}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
50
|
+
<span className="text-sm text-red-600 dark:text-red-400">Failing</span>
|
|
51
|
+
<span className="font-mono font-semibold text-red-700 dark:text-red-300">
|
|
52
|
+
{data.summary.failing}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
|
56
|
+
<span className="text-sm text-amber-600 dark:text-amber-400">Pending</span>
|
|
57
|
+
<span className="font-mono font-semibold text-amber-700 dark:text-amber-300">
|
|
58
|
+
{data.summary.pending}
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
{data.summary.lastRun && (
|
|
62
|
+
<div className="flex items-center gap-2 px-4 py-2 text-sm text-zinc-500 dark:text-zinc-400">
|
|
63
|
+
Last run: {new Date(data.summary.lastRun).toLocaleString()}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Test Tree */}
|
|
69
|
+
<TestTree data={data} />
|
|
70
|
+
</main>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -4,11 +4,13 @@ import { useState, useRef, useEffect } from 'react';
|
|
|
4
4
|
|
|
5
5
|
interface CardMenuProps {
|
|
6
6
|
itemId: number;
|
|
7
|
+
itemTitle?: string;
|
|
7
8
|
currentStatus: string;
|
|
8
9
|
onStatusChange: (id: number, newStatus: string) => Promise<void>;
|
|
10
|
+
onTriggerClaude?: (id: number, title: string) => void;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
export function CardMenu({ itemId, currentStatus, onStatusChange }: CardMenuProps) {
|
|
13
|
+
export function CardMenu({ itemId, itemTitle = '', currentStatus, onStatusChange, onTriggerClaude }: CardMenuProps) {
|
|
12
14
|
const [isOpen, setIsOpen] = useState(false);
|
|
13
15
|
const [error, setError] = useState<string | null>(null);
|
|
14
16
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
@@ -42,6 +44,21 @@ export function CardMenu({ itemId, currentStatus, onStatusChange }: CardMenuProp
|
|
|
42
44
|
}
|
|
43
45
|
};
|
|
44
46
|
|
|
47
|
+
const handleStart = async (e: React.MouseEvent) => {
|
|
48
|
+
e.stopPropagation();
|
|
49
|
+
setIsOpen(false);
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
await onStatusChange(itemId, 'in_progress');
|
|
53
|
+
// Trigger Claude panel after status change succeeds
|
|
54
|
+
if (onTriggerClaude) {
|
|
55
|
+
onTriggerClaude(itemId, itemTitle);
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(err instanceof Error ? err.message : 'Failed to update status');
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
45
62
|
return (
|
|
46
63
|
<div className="relative" ref={menuRef} data-testid="card-menu">
|
|
47
64
|
<button
|
|
@@ -75,7 +92,7 @@ export function CardMenu({ itemId, currentStatus, onStatusChange }: CardMenuProp
|
|
|
75
92
|
>
|
|
76
93
|
{(currentStatus === 'backlog' || currentStatus === 'cancelled') && (
|
|
77
94
|
<button
|
|
78
|
-
onClick={
|
|
95
|
+
onClick={handleStart}
|
|
79
96
|
className="w-full px-3 py-2 text-left text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
|
|
80
97
|
data-testid="start-button"
|
|
81
98
|
>
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
5
|
+
import type { ClaudeMessage, ClaudeStreamStatus } from '../hooks/useClaudeStream';
|
|
6
|
+
|
|
7
|
+
interface ClaudePanelProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
workItemId: string;
|
|
10
|
+
workItemTitle: string;
|
|
11
|
+
messages: ClaudeMessage[];
|
|
12
|
+
status: ClaudeStreamStatus;
|
|
13
|
+
error: string | null;
|
|
14
|
+
exitCode: number | null;
|
|
15
|
+
canRetry: boolean;
|
|
16
|
+
onMinimize: () => void;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
onRetry: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ClaudePanel({
|
|
22
|
+
isOpen,
|
|
23
|
+
workItemId,
|
|
24
|
+
workItemTitle,
|
|
25
|
+
messages,
|
|
26
|
+
status,
|
|
27
|
+
error,
|
|
28
|
+
exitCode,
|
|
29
|
+
canRetry,
|
|
30
|
+
onMinimize,
|
|
31
|
+
onClose,
|
|
32
|
+
onRetry,
|
|
33
|
+
}: ClaudePanelProps) {
|
|
34
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
// Auto-scroll to bottom when new messages arrive
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (contentRef.current) {
|
|
39
|
+
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
|
40
|
+
}
|
|
41
|
+
}, [messages]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<AnimatePresence>
|
|
45
|
+
{isOpen && (
|
|
46
|
+
<motion.div
|
|
47
|
+
initial={{ x: '100%' }}
|
|
48
|
+
animate={{ x: 0 }}
|
|
49
|
+
exit={{ x: '100%' }}
|
|
50
|
+
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
51
|
+
className="fixed right-0 top-0 h-full w-[480px] bg-zinc-900 border-l border-zinc-800 flex flex-col z-50"
|
|
52
|
+
data-testid="claude-panel"
|
|
53
|
+
>
|
|
54
|
+
{/* Header */}
|
|
55
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
|
56
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
57
|
+
<StatusIndicator status={status} />
|
|
58
|
+
<div className="min-w-0">
|
|
59
|
+
<h2 className="text-sm font-semibold text-white truncate" data-testid="panel-title">
|
|
60
|
+
#{workItemId} {workItemTitle}
|
|
61
|
+
</h2>
|
|
62
|
+
<p className="text-xs text-zinc-500">
|
|
63
|
+
{status === 'connecting' && 'Connecting...'}
|
|
64
|
+
{status === 'streaming' && 'Claude is working...'}
|
|
65
|
+
{status === 'done' && 'Complete'}
|
|
66
|
+
{status === 'error' && 'Error occurred'}
|
|
67
|
+
{status === 'idle' && 'Ready'}
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
<button
|
|
73
|
+
onClick={onMinimize}
|
|
74
|
+
className="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
|
75
|
+
aria-label="Minimize panel"
|
|
76
|
+
data-testid="minimize-button"
|
|
77
|
+
>
|
|
78
|
+
<MinimizeIcon />
|
|
79
|
+
</button>
|
|
80
|
+
<button
|
|
81
|
+
onClick={onClose}
|
|
82
|
+
className="p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
|
|
83
|
+
aria-label="Close panel"
|
|
84
|
+
data-testid="close-button"
|
|
85
|
+
>
|
|
86
|
+
<CloseIcon />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Progress bar */}
|
|
92
|
+
{status === 'streaming' && (
|
|
93
|
+
<div className="h-0.5 bg-zinc-800 overflow-hidden">
|
|
94
|
+
<motion.div
|
|
95
|
+
className="h-full bg-blue-500"
|
|
96
|
+
initial={{ x: '-100%' }}
|
|
97
|
+
animate={{ x: '100%' }}
|
|
98
|
+
transition={{ duration: 1.5, repeat: Infinity, ease: 'linear' }}
|
|
99
|
+
style={{ width: '50%' }}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Error banner */}
|
|
105
|
+
{status === 'error' && error && (
|
|
106
|
+
<div className="bg-red-900/50 border-b border-red-800/50 px-4 py-3" data-testid="error-banner">
|
|
107
|
+
<div className="flex items-start gap-3">
|
|
108
|
+
<ErrorIcon />
|
|
109
|
+
<div className="flex-1 min-w-0">
|
|
110
|
+
<p className="text-sm font-medium text-red-300" data-testid="error-message">{error}</p>
|
|
111
|
+
{exitCode !== null && (
|
|
112
|
+
<p className="text-xs text-red-400/70 mt-1">Exit code: {exitCode}</p>
|
|
113
|
+
)}
|
|
114
|
+
{error === 'Claude CLI not found' && (
|
|
115
|
+
<div className="mt-2 text-xs text-red-200/70" data-testid="install-instructions">
|
|
116
|
+
<p className="font-medium mb-1">To install Claude CLI:</p>
|
|
117
|
+
<code className="block bg-red-950/50 rounded px-2 py-1 mt-1">
|
|
118
|
+
npm install -g @anthropic-ai/claude-code
|
|
119
|
+
</code>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
{canRetry && (
|
|
124
|
+
<button
|
|
125
|
+
onClick={onRetry}
|
|
126
|
+
className="px-3 py-1.5 text-xs font-medium bg-red-800/50 hover:bg-red-800 text-red-200 rounded transition-colors"
|
|
127
|
+
data-testid="retry-button"
|
|
128
|
+
>
|
|
129
|
+
Retry
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Content */}
|
|
137
|
+
<div
|
|
138
|
+
ref={contentRef}
|
|
139
|
+
className="flex-1 overflow-y-auto p-4 space-y-3"
|
|
140
|
+
data-testid="panel-content"
|
|
141
|
+
>
|
|
142
|
+
{messages.map((message, index) => (
|
|
143
|
+
<MessageBlock key={index} message={message} />
|
|
144
|
+
))}
|
|
145
|
+
{messages.length === 0 && status === 'idle' && (
|
|
146
|
+
<div className="text-zinc-500 text-sm text-center py-8">
|
|
147
|
+
Click Start to begin working on this item
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
</motion.div>
|
|
152
|
+
)}
|
|
153
|
+
</AnimatePresence>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function StatusIndicator({ status }: { status: ClaudeStreamStatus }) {
|
|
158
|
+
const colorClass = {
|
|
159
|
+
idle: 'bg-zinc-500',
|
|
160
|
+
connecting: 'bg-yellow-500 animate-pulse',
|
|
161
|
+
streaming: 'bg-blue-500 animate-pulse',
|
|
162
|
+
done: 'bg-green-500',
|
|
163
|
+
error: 'bg-red-500',
|
|
164
|
+
}[status];
|
|
165
|
+
|
|
166
|
+
return <div className={`w-2 h-2 rounded-full ${colorClass}`} />;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function MessageBlock({ message }: { message: ClaudeMessage }) {
|
|
170
|
+
if (message.type === 'assistant' || message.type === 'text') {
|
|
171
|
+
return (
|
|
172
|
+
<div className="bg-zinc-800/50 rounded-lg p-3" data-testid="output-block">
|
|
173
|
+
<div className="prose prose-invert prose-sm max-w-none">
|
|
174
|
+
<p className="text-zinc-200 text-sm whitespace-pre-wrap">{message.content}</p>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (message.type === 'tool_use') {
|
|
181
|
+
return (
|
|
182
|
+
<div className="bg-purple-900/30 border border-purple-800/50 rounded-lg p-3" data-testid="tool-call">
|
|
183
|
+
<div className="flex items-center gap-2 mb-2">
|
|
184
|
+
<ToolIcon />
|
|
185
|
+
<span className="text-xs font-medium text-purple-300">{message.tool_name}</span>
|
|
186
|
+
</div>
|
|
187
|
+
{message.tool_input && (
|
|
188
|
+
<pre className="text-xs text-purple-200/70 overflow-x-auto">
|
|
189
|
+
{JSON.stringify(message.tool_input, null, 2)}
|
|
190
|
+
</pre>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (message.type === 'tool_result') {
|
|
197
|
+
return (
|
|
198
|
+
<div className="bg-zinc-800/30 border border-zinc-700/50 rounded-lg p-3">
|
|
199
|
+
<pre className="text-xs text-zinc-400 overflow-x-auto whitespace-pre-wrap">
|
|
200
|
+
{message.result || message.content}
|
|
201
|
+
</pre>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (message.type === 'error') {
|
|
207
|
+
return (
|
|
208
|
+
<div className="bg-red-900/30 border border-red-800/50 rounded-lg p-3">
|
|
209
|
+
<div className="flex items-center gap-2 mb-1">
|
|
210
|
+
<ErrorIcon />
|
|
211
|
+
<span className="text-xs font-medium text-red-300">Error</span>
|
|
212
|
+
</div>
|
|
213
|
+
<p className="text-sm text-red-200">{message.content}</p>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (message.type === 'done') {
|
|
219
|
+
return (
|
|
220
|
+
<div className="bg-green-900/30 border border-green-800/50 rounded-lg p-3 flex items-center gap-2">
|
|
221
|
+
<CheckIcon />
|
|
222
|
+
<span className="text-sm text-green-300">
|
|
223
|
+
Completed {message.exitCode === 0 ? 'successfully' : `with exit code ${message.exitCode}`}
|
|
224
|
+
</span>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function MinimizeIcon() {
|
|
233
|
+
return (
|
|
234
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
235
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
236
|
+
</svg>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function CloseIcon() {
|
|
241
|
+
return (
|
|
242
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
243
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
244
|
+
</svg>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function ToolIcon() {
|
|
249
|
+
return (
|
|
250
|
+
<svg className="w-3.5 h-3.5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
251
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
252
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
253
|
+
</svg>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ErrorIcon() {
|
|
258
|
+
return (
|
|
259
|
+
<svg className="w-3.5 h-3.5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
260
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
261
|
+
</svg>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function CheckIcon() {
|
|
266
|
+
return (
|
|
267
|
+
<svg className="w-3.5 h-3.5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
268
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
269
|
+
</svg>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -93,9 +93,10 @@ interface KanbanCardProps {
|
|
|
93
93
|
isInFlight?: boolean;
|
|
94
94
|
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
95
95
|
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
96
|
+
onTriggerClaude?: (id: number, title: string) => void;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange }: KanbanCardProps) {
|
|
99
|
+
function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onTriggerClaude }: KanbanCardProps) {
|
|
99
100
|
const [expanded, setExpanded] = useState(false);
|
|
100
101
|
const router = useRouter();
|
|
101
102
|
|
|
@@ -185,8 +186,10 @@ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onT
|
|
|
185
186
|
{onStatusChange && (
|
|
186
187
|
<CardMenu
|
|
187
188
|
itemId={item.id}
|
|
189
|
+
itemTitle={item.title}
|
|
188
190
|
currentStatus={item.status}
|
|
189
191
|
onStatusChange={handleStatusChange}
|
|
192
|
+
onTriggerClaude={onTriggerClaude}
|
|
190
193
|
/>
|
|
191
194
|
)}
|
|
192
195
|
</div>
|
|
@@ -302,9 +305,10 @@ interface EpicGroupProps {
|
|
|
302
305
|
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
303
306
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
304
307
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
308
|
+
onTriggerClaude?: (id: number, title: string) => void;
|
|
305
309
|
}
|
|
306
310
|
|
|
307
|
-
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onEpicAssign, onOrderChange }: EpicGroupProps) {
|
|
311
|
+
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onEpicAssign, onOrderChange, onTriggerClaude }: EpicGroupProps) {
|
|
308
312
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
309
313
|
const { isDragging, draggedItem, activeEpicZone, activeDropZone, dragPosition, draggedCardHeight, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
310
314
|
|
|
@@ -511,7 +515,7 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
511
515
|
{items.map((item) => (
|
|
512
516
|
<div key={item.id}>
|
|
513
517
|
<DraggableCard item={item} disabled={!isDraggable}>
|
|
514
|
-
<KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
|
|
518
|
+
<KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} onTriggerClaude={onTriggerClaude} />
|
|
515
519
|
</DraggableCard>
|
|
516
520
|
{/* Placeholder after this card */}
|
|
517
521
|
<AnimatePresence>
|
|
@@ -634,6 +638,7 @@ interface KanbanBoardProps {
|
|
|
634
638
|
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
635
639
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
636
640
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
641
|
+
onTriggerClaude?: (id: number, title: string) => void;
|
|
637
642
|
// Undo/redo support
|
|
638
643
|
onUndo?: () => Promise<UndoAction | null>;
|
|
639
644
|
onRedo?: () => Promise<UndoAction | null>;
|
|
@@ -641,7 +646,7 @@ interface KanbanBoardProps {
|
|
|
641
646
|
canRedo?: boolean;
|
|
642
647
|
}
|
|
643
648
|
|
|
644
|
-
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign, onUndo, onRedo, canUndo, canRedo }: KanbanBoardProps) {
|
|
649
|
+
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign, onTriggerClaude, onUndo, onRedo, canUndo, canRedo }: KanbanBoardProps) {
|
|
645
650
|
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
646
651
|
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
647
652
|
|
|
@@ -734,6 +739,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
734
739
|
isInFlight={true}
|
|
735
740
|
onTitleSave={onTitleSave}
|
|
736
741
|
onStatusChange={onStatusChange}
|
|
742
|
+
onTriggerClaude={onTriggerClaude}
|
|
737
743
|
/>
|
|
738
744
|
</DraggableCard>
|
|
739
745
|
))}
|
|
@@ -771,6 +777,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
771
777
|
onStatusChange={onStatusChange}
|
|
772
778
|
onEpicAssign={onEpicAssign}
|
|
773
779
|
onOrderChange={onOrderChange}
|
|
780
|
+
onTriggerClaude={onTriggerClaude}
|
|
774
781
|
/>
|
|
775
782
|
))}
|
|
776
783
|
|