jettypod 4.4.98 → 4.4.99

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.
@@ -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={(e) => handleAction(e, 'in_progress')}
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