jettypod 4.4.115 → 4.4.118
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/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/install-claude/page.tsx +8 -6
- package/apps/dashboard/app/login/page.tsx +229 -0
- package/apps/dashboard/app/page.tsx +5 -3
- package/apps/dashboard/app/settings/page.tsx +2 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +23 -0
- package/apps/dashboard/components/AppShell.tsx +51 -9
- package/apps/dashboard/components/CardMenu.tsx +14 -5
- package/apps/dashboard/components/ClaudePanel.tsx +65 -9
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
- package/apps/dashboard/components/DragContext.tsx +73 -64
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/GateCard.tsx +21 -0
- package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
- package/apps/dashboard/components/KanbanBoard.tsx +173 -56
- package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
- package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
- package/apps/dashboard/components/SubscribeContent.tsx +191 -0
- package/apps/dashboard/components/TipCard.tsx +176 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
- package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
- package/apps/dashboard/contexts/UsageContext.tsx +131 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +220 -114
- package/apps/dashboard/electron/main.js +415 -37
- package/apps/dashboard/electron/preload.js +23 -4
- package/apps/dashboard/electron/session-manager.js +141 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/lib/claude-process-manager.ts +6 -4
- package/apps/dashboard/lib/db-bridge.ts +32 -0
- package/apps/dashboard/lib/db.ts +159 -13
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +76 -13
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/next.config.js +19 -14
- package/apps/dashboard/package.json +3 -1
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1074 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/docs/bdd-guidance.md +390 -0
- package/jettypod.js +5 -4
- package/lib/migrations/027-plan-at-creation-column.js +31 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/schema.js +3 -1
- package/lib/seed-onboarding.js +100 -68
- package/lib/work-commands/index.js +43 -13
- package/lib/work-tracking/index.js +46 -27
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +5 -11
- package/skills-templates/request-routing/SKILL.md +24 -11
- package/skills-templates/simple-improvement/SKILL.md +35 -19
- package/skills-templates/stable-mode/SKILL.md +5 -6
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- package/skills-templates/project-discovery/SKILL.md +0 -372
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useRouter } from 'next/navigation';
|
|
6
|
-
import { AnimatePresence } from 'framer-motion';
|
|
6
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
7
7
|
import { useDroppable } from '@dnd-kit/core';
|
|
8
8
|
import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
|
|
9
9
|
import type { UndoAction } from '@/lib/undoStack';
|
|
@@ -57,15 +57,17 @@ interface KanbanCardProps {
|
|
|
57
57
|
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
58
58
|
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
59
59
|
onReject?: (id: number, reason: string) => Promise<void>;
|
|
60
|
-
onTriggerClaude?: (id: number, title: string, type: string) => void;
|
|
60
|
+
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
61
61
|
hasActiveSession?: boolean;
|
|
62
62
|
onOpenSession?: (id: string) => void;
|
|
63
|
+
usageAllowed?: boolean;
|
|
63
64
|
// Animation state lifted to board level
|
|
64
65
|
isCompletingAnimation?: boolean;
|
|
65
66
|
onAnimationComplete?: () => void;
|
|
67
|
+
isHighlighted?: boolean;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onReject, onTriggerClaude, hasActiveSession, onOpenSession, isCompletingAnimation = false, onAnimationComplete }: KanbanCardProps) {
|
|
70
|
+
function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onReject, onTriggerClaude, hasActiveSession, onOpenSession, usageAllowed = true, isCompletingAnimation = false, onAnimationComplete, isHighlighted = false }: KanbanCardProps) {
|
|
69
71
|
const [expanded, setExpanded] = useState(false);
|
|
70
72
|
const [showRejectInput, setShowRejectInput] = useState(false);
|
|
71
73
|
const [rejectReason, setRejectReason] = useState('');
|
|
@@ -83,17 +85,16 @@ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onT
|
|
|
83
85
|
if (onStatusChange) {
|
|
84
86
|
await onStatusChange(item.id, 'in_progress');
|
|
85
87
|
if (onTriggerClaude) {
|
|
86
|
-
onTriggerClaude(item.id, item.title, item.type);
|
|
88
|
+
onTriggerClaude(item.id, item.title, item.type, !!item.conversational, item.description);
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
const canStart = item.status === 'backlog' || item.status === 'cancelled';
|
|
92
94
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
const isReviewable = isTopLevel && (item.status === 'in_progress' || item.status === 'done');
|
|
95
|
+
// An item is reviewable when it has ready_for_review flag set
|
|
96
|
+
// This applies to kanban-visible items: features, standalone chores/bugs, and items under epics
|
|
97
|
+
const isReviewable = !!item.ready_for_review;
|
|
97
98
|
|
|
98
99
|
const handleAccept = async (e: React.MouseEvent) => {
|
|
99
100
|
e.stopPropagation();
|
|
@@ -188,7 +189,7 @@ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onT
|
|
|
188
189
|
|
|
189
190
|
const cardStyles = getCardStyles();
|
|
190
191
|
|
|
191
|
-
|
|
192
|
+
const cardContent = (
|
|
192
193
|
<WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
|
|
193
194
|
<div
|
|
194
195
|
className={`rounded-xl border transition-all duration-200 hover:-translate-y-0.5 ${cardStyles.className}`}
|
|
@@ -264,17 +265,6 @@ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onT
|
|
|
264
265
|
</svg>
|
|
265
266
|
</button>
|
|
266
267
|
)}
|
|
267
|
-
{/* Start button - shown for backlog/cancelled items */}
|
|
268
|
-
{canStart && onStatusChange && (
|
|
269
|
-
<button
|
|
270
|
-
onClick={handleStart}
|
|
271
|
-
className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 transition-colors"
|
|
272
|
-
aria-label="Start work"
|
|
273
|
-
data-testid={`start-button-${item.id}`}
|
|
274
|
-
>
|
|
275
|
-
start
|
|
276
|
-
</button>
|
|
277
|
-
)}
|
|
278
268
|
{/* Session indicator - clickable icon to reopen session */}
|
|
279
269
|
{hasActiveSession && (
|
|
280
270
|
<button
|
|
@@ -287,16 +277,53 @@ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onT
|
|
|
287
277
|
<SessionIndicatorIcon className="w-4 h-4" />
|
|
288
278
|
</button>
|
|
289
279
|
)}
|
|
280
|
+
{/* Start button - shown for backlog/cancelled items */}
|
|
281
|
+
{canStart && onStatusChange && (
|
|
282
|
+
isHighlighted ? (
|
|
283
|
+
<motion.button
|
|
284
|
+
onClick={handleStart}
|
|
285
|
+
className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
|
|
286
|
+
animate={{
|
|
287
|
+
backgroundColor: [
|
|
288
|
+
'rgba(59, 130, 246, 0)',
|
|
289
|
+
'rgba(59, 130, 246, 0.15)',
|
|
290
|
+
'rgba(59, 130, 246, 0)',
|
|
291
|
+
],
|
|
292
|
+
}}
|
|
293
|
+
transition={{
|
|
294
|
+
duration: 2,
|
|
295
|
+
repeat: Infinity,
|
|
296
|
+
ease: 'easeInOut',
|
|
297
|
+
}}
|
|
298
|
+
aria-label="Start work"
|
|
299
|
+
data-testid={`start-button-${item.id}`}
|
|
300
|
+
>
|
|
301
|
+
start
|
|
302
|
+
</motion.button>
|
|
303
|
+
) : (
|
|
304
|
+
<button
|
|
305
|
+
onClick={handleStart}
|
|
306
|
+
className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 transition-colors"
|
|
307
|
+
aria-label="Start work"
|
|
308
|
+
data-testid={`start-button-${item.id}`}
|
|
309
|
+
>
|
|
310
|
+
start
|
|
311
|
+
</button>
|
|
312
|
+
)
|
|
313
|
+
)}
|
|
290
314
|
{onStatusChange && (
|
|
291
315
|
<CardMenu
|
|
292
316
|
itemId={item.id}
|
|
293
317
|
itemTitle={item.title}
|
|
294
318
|
itemType={item.type}
|
|
319
|
+
itemDescription={item.description}
|
|
320
|
+
conversational={!!item.conversational}
|
|
295
321
|
currentStatus={item.status}
|
|
296
322
|
onStatusChange={handleStatusChange}
|
|
297
323
|
onTriggerClaude={onTriggerClaude}
|
|
298
324
|
hasActiveSession={hasActiveSession}
|
|
299
325
|
onOpenSession={onOpenSession}
|
|
326
|
+
usageAllowed={usageAllowed}
|
|
300
327
|
/>
|
|
301
328
|
)}
|
|
302
329
|
</div>
|
|
@@ -452,6 +479,30 @@ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onT
|
|
|
452
479
|
</div>
|
|
453
480
|
</WaveCompletionAnimation>
|
|
454
481
|
);
|
|
482
|
+
|
|
483
|
+
if (isHighlighted) {
|
|
484
|
+
return (
|
|
485
|
+
<motion.div
|
|
486
|
+
animate={{
|
|
487
|
+
boxShadow: [
|
|
488
|
+
'0 0 0 0px rgba(129, 157, 159, 0)',
|
|
489
|
+
'0 0 0 3px rgba(129, 157, 159, 0.4)',
|
|
490
|
+
'0 0 0 0px rgba(129, 157, 159, 0)',
|
|
491
|
+
],
|
|
492
|
+
}}
|
|
493
|
+
transition={{
|
|
494
|
+
duration: 2,
|
|
495
|
+
repeat: Infinity,
|
|
496
|
+
ease: 'easeInOut',
|
|
497
|
+
}}
|
|
498
|
+
className="rounded-xl"
|
|
499
|
+
>
|
|
500
|
+
{cardContent}
|
|
501
|
+
</motion.div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return cardContent;
|
|
455
506
|
}
|
|
456
507
|
|
|
457
508
|
// Safe bounds for display_order to prevent overflow
|
|
@@ -470,18 +521,30 @@ interface EpicGroupProps {
|
|
|
470
521
|
onReject?: (id: number, reason: string) => Promise<void>;
|
|
471
522
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
472
523
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
473
|
-
onTriggerClaude?: (id: number, title: string, type: string) => void;
|
|
524
|
+
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
474
525
|
activeSessions?: Map<string, Session>;
|
|
475
526
|
onOpenSession?: (id: string) => void;
|
|
476
527
|
onError?: (message: string) => void;
|
|
528
|
+
usageAllowed?: boolean;
|
|
477
529
|
// Animation state lifted to board level
|
|
478
530
|
animatingItemId?: number | null;
|
|
479
531
|
onAnimationComplete?: () => void;
|
|
532
|
+
isBlank?: boolean;
|
|
480
533
|
}
|
|
481
534
|
|
|
482
|
-
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onReject, onEpicAssign, onOrderChange, onTriggerClaude, activeSessions, onOpenSession, onError, animatingItemId, onAnimationComplete }: EpicGroupProps) {
|
|
535
|
+
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onReject, onEpicAssign, onOrderChange, onTriggerClaude, activeSessions, onOpenSession, onError, usageAllowed = true, animatingItemId, onAnimationComplete, isBlank }: EpicGroupProps) {
|
|
483
536
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
484
|
-
const { isDragging, draggedItem, activeEpicZone, activeDropZone,
|
|
537
|
+
const { isDragging, draggedItem, activeEpicZone, activeDropZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
538
|
+
|
|
539
|
+
// Local pointer tracking - only this component needs pointer Y for insertion preview.
|
|
540
|
+
// Using local state avoids re-rendering every context consumer at 60fps.
|
|
541
|
+
const [pointerY, setPointerY] = useState(0);
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
if (!isDragging) return;
|
|
544
|
+
const onPointerMove = (e: PointerEvent) => { setPointerY(e.clientY); };
|
|
545
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
546
|
+
return () => window.removeEventListener('pointermove', onPointerMove);
|
|
547
|
+
}, [isDragging]);
|
|
485
548
|
|
|
486
549
|
// Use @dnd-kit's useDroppable for epic zone collision detection
|
|
487
550
|
const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
|
|
@@ -517,26 +580,26 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
517
580
|
return;
|
|
518
581
|
}
|
|
519
582
|
|
|
520
|
-
|
|
521
|
-
|
|
583
|
+
const currentItems = itemsRef.current.filter(item => item.id !== itemId);
|
|
584
|
+
if (currentItems.length === 0) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
522
587
|
|
|
523
|
-
//
|
|
588
|
+
// Read fresh positions from DOM (not stale cache)
|
|
524
589
|
const allPositions = getCardPositions();
|
|
590
|
+
const itemIds = new Set(currentItems.map(item => item.id));
|
|
525
591
|
const cardPositions = allPositions
|
|
526
|
-
.filter(pos => itemIds.has(pos.id)
|
|
592
|
+
.filter(pos => itemIds.has(pos.id))
|
|
527
593
|
.map(pos => ({
|
|
528
594
|
id: pos.id,
|
|
529
595
|
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
530
|
-
}))
|
|
596
|
+
}))
|
|
597
|
+
.sort((a, b) => a.midY - b.midY);
|
|
531
598
|
|
|
532
|
-
// Skip reorder if this is the only item in the epic (no other cards to reorder against)
|
|
533
599
|
if (cardPositions.length === 0) {
|
|
534
600
|
return;
|
|
535
601
|
}
|
|
536
602
|
|
|
537
|
-
// Sort by Y position
|
|
538
|
-
cardPositions.sort((a, b) => a.midY - b.midY);
|
|
539
|
-
|
|
540
603
|
// Find insertion index based on pointer Y
|
|
541
604
|
let insertIndex = cardPositions.length;
|
|
542
605
|
for (let i = 0; i < cardPositions.length; i++) {
|
|
@@ -546,15 +609,31 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
546
609
|
}
|
|
547
610
|
}
|
|
548
611
|
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
612
|
+
// Map visual positions to items for display_order midpoint calculation
|
|
613
|
+
const itemMap = new Map(currentItems.map(item => [item.id, item]));
|
|
614
|
+
const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
|
|
615
|
+
|
|
616
|
+
// Calculate proper midpoint display_order between surrounding items
|
|
617
|
+
let newOrder: number;
|
|
618
|
+
if (visualOrder.length === 0) {
|
|
619
|
+
newOrder = DISPLAY_ORDER_INCREMENT;
|
|
620
|
+
} else if (insertIndex === 0) {
|
|
621
|
+
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
|
|
622
|
+
newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
|
|
623
|
+
} else if (insertIndex >= visualOrder.length) {
|
|
624
|
+
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
|
|
625
|
+
newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
|
|
626
|
+
} else {
|
|
627
|
+
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
|
|
628
|
+
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
|
|
629
|
+
newOrder = Math.floor((before + after) / 2);
|
|
630
|
+
}
|
|
631
|
+
|
|
552
632
|
newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
|
|
553
633
|
|
|
554
634
|
try {
|
|
555
635
|
await onOrderChangeRef.current(itemId, newOrder);
|
|
556
636
|
} catch (error) {
|
|
557
|
-
// Notify user via callback instead of alert() - items remain in original order
|
|
558
637
|
const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
|
|
559
638
|
onErrorRef.current?.(errorMessage);
|
|
560
639
|
}
|
|
@@ -620,7 +699,7 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
620
699
|
// Find which card the pointer is after
|
|
621
700
|
insertAfterItemId = null; // Default to beginning
|
|
622
701
|
for (const pos of groupPositions) {
|
|
623
|
-
if (
|
|
702
|
+
if (pointerY > pos.midY) {
|
|
624
703
|
insertAfterItemId = pos.id;
|
|
625
704
|
} else {
|
|
626
705
|
break;
|
|
@@ -692,7 +771,7 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
692
771
|
{/* Placeholder at the beginning (insertAfterItemId === null) */}
|
|
693
772
|
<AnimatePresence>
|
|
694
773
|
{insertAfterItemId === null && (
|
|
695
|
-
<PlaceholderCard key="placeholder-start"
|
|
774
|
+
<PlaceholderCard key="placeholder-start" />
|
|
696
775
|
)}
|
|
697
776
|
</AnimatePresence>
|
|
698
777
|
{items.map((item) => (
|
|
@@ -706,14 +785,16 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
706
785
|
onTriggerClaude={onTriggerClaude}
|
|
707
786
|
hasActiveSession={activeSessions?.has(String(item.id))}
|
|
708
787
|
onOpenSession={onOpenSession}
|
|
788
|
+
usageAllowed={usageAllowed}
|
|
709
789
|
isCompletingAnimation={animatingItemId === item.id}
|
|
710
790
|
onAnimationComplete={onAnimationComplete}
|
|
791
|
+
isHighlighted={isBlank && item.status === 'backlog' && item.title === 'Align on the user journey'}
|
|
711
792
|
/>
|
|
712
793
|
</DraggableCard>
|
|
713
794
|
{/* Placeholder after this card */}
|
|
714
795
|
<AnimatePresence>
|
|
715
796
|
{insertAfterItemId === item.id && (
|
|
716
|
-
<PlaceholderCard key={`placeholder-${item.id}`}
|
|
797
|
+
<PlaceholderCard key={`placeholder-${item.id}`} />
|
|
717
798
|
)}
|
|
718
799
|
</AnimatePresence>
|
|
719
800
|
</div>
|
|
@@ -728,9 +809,10 @@ interface KanbanColumnProps {
|
|
|
728
809
|
children: React.ReactNode;
|
|
729
810
|
count: number;
|
|
730
811
|
onAdd?: () => void;
|
|
812
|
+
addDisabled?: boolean;
|
|
731
813
|
}
|
|
732
814
|
|
|
733
|
-
function KanbanColumn({ title, children, count, onAdd }: KanbanColumnProps) {
|
|
815
|
+
function KanbanColumn({ title, children, count, onAdd, addDisabled }: KanbanColumnProps) {
|
|
734
816
|
const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
|
|
735
817
|
return (
|
|
736
818
|
<div className="flex-1 min-w-[300px] max-w-[400px] flex flex-col min-h-0" data-testid={testId}>
|
|
@@ -752,9 +834,15 @@ function KanbanColumn({ title, children, count, onAdd }: KanbanColumnProps) {
|
|
|
752
834
|
{onAdd && (
|
|
753
835
|
<button
|
|
754
836
|
onClick={onAdd}
|
|
755
|
-
|
|
837
|
+
disabled={addDisabled}
|
|
838
|
+
className={`p-1 rounded transition-colors ${
|
|
839
|
+
addDisabled
|
|
840
|
+
? 'text-zinc-300 dark:text-zinc-700 cursor-not-allowed'
|
|
841
|
+
: 'hover:bg-zinc-200 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
842
|
+
}`}
|
|
756
843
|
aria-label={`Add to ${title.toLowerCase()}`}
|
|
757
844
|
data-testid={`${testId}-add-button`}
|
|
845
|
+
title={addDisabled ? 'Weekly usage limit reached' : undefined}
|
|
758
846
|
>
|
|
759
847
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
760
848
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
@@ -766,7 +854,7 @@ function KanbanColumn({ title, children, count, onAdd }: KanbanColumnProps) {
|
|
|
766
854
|
</span>
|
|
767
855
|
</div>
|
|
768
856
|
</div>
|
|
769
|
-
<div className="overflow-y-auto flex-1 min-h-0">
|
|
857
|
+
<div className="overflow-y-auto flex-1 min-h-0 px-1 -mx-1">
|
|
770
858
|
{children}
|
|
771
859
|
</div>
|
|
772
860
|
</div>
|
|
@@ -795,25 +883,30 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
|
|
|
795
883
|
const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
796
884
|
if (!onOrderChangeRef.current) return;
|
|
797
885
|
|
|
798
|
-
//
|
|
799
|
-
const
|
|
886
|
+
// Collect all backlog items (excluding dragged item) with their data
|
|
887
|
+
const backlogItems: WorkItem[] = [];
|
|
800
888
|
for (const group of backlogRef.current.values()) {
|
|
801
889
|
for (const item of group.items) {
|
|
802
|
-
|
|
890
|
+
if (item.id !== itemId) {
|
|
891
|
+
backlogItems.push(item);
|
|
892
|
+
}
|
|
803
893
|
}
|
|
804
894
|
}
|
|
805
895
|
|
|
806
|
-
|
|
896
|
+
if (backlogItems.length === 0) return;
|
|
897
|
+
|
|
898
|
+
// Read fresh positions from DOM (not stale cache)
|
|
807
899
|
const allPositions = getCardPositions();
|
|
900
|
+
const backlogItemIds = new Set(backlogItems.map(item => item.id));
|
|
808
901
|
const cardPositions = allPositions
|
|
809
|
-
.filter(pos => backlogItemIds.has(pos.id)
|
|
902
|
+
.filter(pos => backlogItemIds.has(pos.id))
|
|
810
903
|
.map(pos => ({
|
|
811
904
|
id: pos.id,
|
|
812
905
|
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
813
|
-
}))
|
|
906
|
+
}))
|
|
907
|
+
.sort((a, b) => a.midY - b.midY);
|
|
814
908
|
|
|
815
|
-
|
|
816
|
-
cardPositions.sort((a, b) => a.midY - b.midY);
|
|
909
|
+
if (cardPositions.length === 0) return;
|
|
817
910
|
|
|
818
911
|
// Find insertion index based on pointer Y
|
|
819
912
|
let insertIndex = cardPositions.length;
|
|
@@ -824,9 +917,26 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
|
|
|
824
917
|
}
|
|
825
918
|
}
|
|
826
919
|
|
|
827
|
-
//
|
|
828
|
-
|
|
829
|
-
|
|
920
|
+
// Map visual positions to items for display_order midpoint calculation
|
|
921
|
+
const itemMap = new Map(backlogItems.map(item => [item.id, item]));
|
|
922
|
+
const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
|
|
923
|
+
|
|
924
|
+
// Calculate proper midpoint display_order between surrounding items
|
|
925
|
+
let newOrder: number;
|
|
926
|
+
if (visualOrder.length === 0) {
|
|
927
|
+
newOrder = DISPLAY_ORDER_INCREMENT;
|
|
928
|
+
} else if (insertIndex === 0) {
|
|
929
|
+
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
|
|
930
|
+
newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
|
|
931
|
+
} else if (insertIndex >= visualOrder.length) {
|
|
932
|
+
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
|
|
933
|
+
newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
|
|
934
|
+
} else {
|
|
935
|
+
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
|
|
936
|
+
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
|
|
937
|
+
newOrder = Math.floor((before + after) / 2);
|
|
938
|
+
}
|
|
939
|
+
|
|
830
940
|
newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
|
|
831
941
|
|
|
832
942
|
await onOrderChangeRef.current(itemId, newOrder);
|
|
@@ -859,7 +969,7 @@ interface KanbanBoardProps {
|
|
|
859
969
|
onReject?: (id: number, reason: string) => Promise<void>;
|
|
860
970
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
861
971
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
862
|
-
onTriggerClaude?: (id: number, title: string, type: string) => void;
|
|
972
|
+
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
863
973
|
// Multi-session support
|
|
864
974
|
onOpenSession?: (id: string) => void;
|
|
865
975
|
activeSessions?: Map<string, Session>;
|
|
@@ -872,12 +982,15 @@ interface KanbanBoardProps {
|
|
|
872
982
|
onError?: (message: string) => void;
|
|
873
983
|
// Add to backlog
|
|
874
984
|
onAddToBacklog?: () => void;
|
|
985
|
+
// Usage limits
|
|
986
|
+
usageAllowed?: boolean;
|
|
875
987
|
// External animation trigger (e.g., from CLI/DB completions detected via WebSocket)
|
|
876
988
|
externalAnimatingItemId?: number | null;
|
|
877
989
|
onExternalAnimationComplete?: () => void;
|
|
990
|
+
isBlank?: boolean;
|
|
878
991
|
}
|
|
879
992
|
|
|
880
|
-
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, activeSessions, onUndo, onRedo, canUndo, canRedo, onError, onAddToBacklog, externalAnimatingItemId, onExternalAnimationComplete }: KanbanBoardProps) {
|
|
993
|
+
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, activeSessions, onUndo, onRedo, canUndo, canRedo, onError, onAddToBacklog, usageAllowed = true, externalAnimatingItemId, onExternalAnimationComplete, isBlank }: KanbanBoardProps) {
|
|
881
994
|
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
882
995
|
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
883
996
|
|
|
@@ -1002,7 +1115,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1002
1115
|
<DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign} onError={onError}>
|
|
1003
1116
|
<div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
|
|
1004
1117
|
{/* Backlog Column */}
|
|
1005
|
-
<KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog}>
|
|
1118
|
+
<KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed}>
|
|
1006
1119
|
{/* In Flight Section - Drop Zone */}
|
|
1007
1120
|
<DropZone
|
|
1008
1121
|
targetStatus="in_progress"
|
|
@@ -1033,6 +1146,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1033
1146
|
onTriggerClaude={onTriggerClaude}
|
|
1034
1147
|
hasActiveSession={activeSessions?.has(String(item.id))}
|
|
1035
1148
|
onOpenSession={onOpenSession}
|
|
1149
|
+
usageAllowed={usageAllowed}
|
|
1036
1150
|
isCompletingAnimation={animatingItemId === item.id}
|
|
1037
1151
|
onAnimationComplete={handleAnimationComplete}
|
|
1038
1152
|
/>
|
|
@@ -1077,8 +1191,10 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1077
1191
|
activeSessions={activeSessions}
|
|
1078
1192
|
onOpenSession={onOpenSession}
|
|
1079
1193
|
onError={onError}
|
|
1194
|
+
usageAllowed={usageAllowed}
|
|
1080
1195
|
animatingItemId={animatingItemId}
|
|
1081
1196
|
onAnimationComplete={handleAnimationComplete}
|
|
1197
|
+
isBlank={isBlank}
|
|
1082
1198
|
/>
|
|
1083
1199
|
))}
|
|
1084
1200
|
|
|
@@ -1118,6 +1234,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
1118
1234
|
activeSessions={activeSessions}
|
|
1119
1235
|
onOpenSession={onOpenSession}
|
|
1120
1236
|
onError={onError}
|
|
1237
|
+
usageAllowed={usageAllowed}
|
|
1121
1238
|
/>
|
|
1122
1239
|
))}
|
|
1123
1240
|
|
|
@@ -2,29 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import { motion } from 'framer-motion';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
height: number;
|
|
7
|
-
minHeight?: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const DEFAULT_MIN_HEIGHT = 40;
|
|
11
|
-
|
|
12
|
-
export function PlaceholderCard({ height, minHeight = DEFAULT_MIN_HEIGHT }: PlaceholderCardProps) {
|
|
13
|
-
const effectiveHeight = Math.max(height, minHeight);
|
|
14
|
-
|
|
5
|
+
export function PlaceholderCard() {
|
|
15
6
|
return (
|
|
16
7
|
<motion.div
|
|
17
8
|
data-testid="drag-placeholder"
|
|
18
|
-
initial={{ opacity: 0,
|
|
19
|
-
animate={{ opacity:
|
|
20
|
-
exit={{ opacity: 0,
|
|
21
|
-
transition={{ duration: 0.
|
|
9
|
+
initial={{ opacity: 0, scaleX: 0.3 }}
|
|
10
|
+
animate={{ opacity: 1, scaleX: 1 }}
|
|
11
|
+
exit={{ opacity: 0, scaleX: 0.3 }}
|
|
12
|
+
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
22
13
|
style={{
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
marginBottom: 8,
|
|
14
|
+
height: 3,
|
|
15
|
+
borderRadius: 2,
|
|
16
|
+
background: 'linear-gradient(90deg, transparent, rgb(99, 102, 241), transparent)',
|
|
17
|
+
margin: '2px 8px',
|
|
28
18
|
}}
|
|
29
19
|
/>
|
|
30
20
|
);
|
|
@@ -72,6 +72,19 @@ export function ProjectSwitcher({ projectName }: ProjectSwitcherProps) {
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
const handleNewProject = async () => {
|
|
76
|
+
setError(null);
|
|
77
|
+
setIsOpen(false);
|
|
78
|
+
try {
|
|
79
|
+
const result = await window.electronAPI!.project.newProject();
|
|
80
|
+
if (result.success) {
|
|
81
|
+
window.location.reload();
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
setError('Failed to create new project.');
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
75
88
|
const handleOpenProject = async () => {
|
|
76
89
|
setError(null);
|
|
77
90
|
setIsOpen(false);
|
|
@@ -111,8 +124,12 @@ export function ProjectSwitcher({ projectName }: ProjectSwitcherProps) {
|
|
|
111
124
|
</div>
|
|
112
125
|
|
|
113
126
|
{/* Other recent projects */}
|
|
127
|
+
{recentProjects.filter(p => p.name !== projectName).length > 0 && (
|
|
128
|
+
<div className="px-3 pt-2 pb-1 text-xs font-medium text-zinc-400 dark:text-zinc-500 uppercase tracking-wider">Recent</div>
|
|
129
|
+
)}
|
|
114
130
|
{recentProjects
|
|
115
131
|
.filter(p => p.name !== projectName)
|
|
132
|
+
.slice(0, 4)
|
|
116
133
|
.map((project) => (
|
|
117
134
|
<button
|
|
118
135
|
key={project.path}
|
|
@@ -127,6 +144,17 @@ export function ProjectSwitcher({ projectName }: ProjectSwitcherProps) {
|
|
|
127
144
|
{/* Divider */}
|
|
128
145
|
<div className="border-t border-zinc-200 dark:border-zinc-700 my-1" />
|
|
129
146
|
|
|
147
|
+
{/* New Project action */}
|
|
148
|
+
<button
|
|
149
|
+
onClick={handleNewProject}
|
|
150
|
+
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"
|
|
151
|
+
>
|
|
152
|
+
<svg className="w-3.5 h-3.5 text-zinc-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
153
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
154
|
+
</svg>
|
|
155
|
+
<span>New Project...</span>
|
|
156
|
+
</button>
|
|
157
|
+
|
|
130
158
|
{/* Open Project action */}
|
|
131
159
|
<button
|
|
132
160
|
onClick={handleOpenProject}
|
|
@@ -6,6 +6,8 @@ import { useToast } from './Toast';
|
|
|
6
6
|
import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
|
|
7
7
|
import { useClaudeSession } from '../contexts/ClaudeSessionContext';
|
|
8
8
|
import { useConnectionStatus } from '../contexts/ConnectionStatusContext';
|
|
9
|
+
import { useUsage } from '../contexts/UsageContext';
|
|
10
|
+
import { UpgradeBanner } from './UpgradeBanner';
|
|
9
11
|
import type { InFlightItem, KanbanGroup, WorkItem } from '@/lib/db';
|
|
10
12
|
import { UndoStack, type UndoAction } from '@/lib/undoStack';
|
|
11
13
|
import { getRegistry } from '@/lib/stream-manager-registry';
|
|
@@ -22,6 +24,8 @@ interface RealTimeKanbanWrapperProps {
|
|
|
22
24
|
backlog: [string, KanbanGroup][];
|
|
23
25
|
done: [string, KanbanGroup][];
|
|
24
26
|
};
|
|
27
|
+
isBlank?: boolean;
|
|
28
|
+
projectPath?: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// Helper to find item by ID in the kanban data
|
|
@@ -47,8 +51,9 @@ function findItemById(data: KanbanData, id: number): { item: InFlightItem; statu
|
|
|
47
51
|
|
|
48
52
|
// Component uses context providers from AppShell (ToastProvider, ClaudeSessionProvider)
|
|
49
53
|
// DO NOT wrap with duplicate providers - it creates isolated context state
|
|
50
|
-
export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProps) {
|
|
54
|
+
export function RealTimeKanbanWrapper({ initialData, isBlank, projectPath }: RealTimeKanbanWrapperProps) {
|
|
51
55
|
const { showToast } = useToast();
|
|
56
|
+
const { allowed: usageAllowed } = useUsage();
|
|
52
57
|
const [data, setData] = useState<KanbanData>(() => ({
|
|
53
58
|
inFlight: initialData.inFlight,
|
|
54
59
|
backlog: new Map(initialData.backlog),
|
|
@@ -67,8 +72,28 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
67
72
|
openSession,
|
|
68
73
|
switchSession,
|
|
69
74
|
createAddToBacklogSession,
|
|
75
|
+
createWelcomeSession,
|
|
70
76
|
} = useClaudeSession();
|
|
71
77
|
|
|
78
|
+
// Auto-open welcome session for blank projects (once per project lifetime)
|
|
79
|
+
const welcomeTriggeredRef = useRef(false);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (isBlank && !welcomeTriggeredRef.current) {
|
|
82
|
+
try {
|
|
83
|
+
const welcomeKey = `jettypod-welcome-shown-${projectPath || 'default'}`;
|
|
84
|
+
if (localStorage.getItem(welcomeKey)) return;
|
|
85
|
+
welcomeTriggeredRef.current = true;
|
|
86
|
+
localStorage.setItem(welcomeKey, 'true');
|
|
87
|
+
} catch {
|
|
88
|
+
// localStorage unavailable (private browsing, full storage) — proceed anyway
|
|
89
|
+
welcomeTriggeredRef.current = true;
|
|
90
|
+
}
|
|
91
|
+
createWelcomeSession().catch((err) => {
|
|
92
|
+
console.error('[RealTimeKanbanWrapper] Welcome session failed:', err);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}, [isBlank, createWelcomeSession]);
|
|
96
|
+
|
|
72
97
|
// Undo/redo stack - created once per component instance
|
|
73
98
|
const [undoStack] = useState(() => new UndoStack());
|
|
74
99
|
const [undoRedoVersion, setUndoRedoVersion] = useState(0); // Force re-render on stack changes
|
|
@@ -449,9 +474,9 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
449
474
|
}, []);
|
|
450
475
|
|
|
451
476
|
// Claude panel handlers - multi-session support
|
|
452
|
-
const handleTriggerClaude = useCallback((id: number, title: string, type: string) => {
|
|
477
|
+
const handleTriggerClaude = useCallback((id: number, title: string, type: string, conversational?: boolean, description?: string | null) => {
|
|
453
478
|
// Use context's openSession - handles existing session check, creation, and streaming
|
|
454
|
-
openSession(String(id), title, type);
|
|
479
|
+
openSession(String(id), title, type, conversational, description);
|
|
455
480
|
}, [openSession]);
|
|
456
481
|
|
|
457
482
|
// Open an existing session (for card icon click)
|
|
@@ -484,6 +509,10 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
484
509
|
|
|
485
510
|
return (
|
|
486
511
|
<div className="h-full flex flex-col">
|
|
512
|
+
{/* Usage Limit Banner */}
|
|
513
|
+
<div className="px-4 pt-2">
|
|
514
|
+
<UpgradeBanner />
|
|
515
|
+
</div>
|
|
487
516
|
{/* Status Error Display */}
|
|
488
517
|
{statusError && (
|
|
489
518
|
<div
|
|
@@ -519,8 +548,10 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
519
548
|
canRedo={canRedo}
|
|
520
549
|
onError={handleDragError}
|
|
521
550
|
onAddToBacklog={handleAddToBacklog}
|
|
551
|
+
usageAllowed={usageAllowed}
|
|
522
552
|
externalAnimatingItemId={externalAnimatingItemId}
|
|
523
553
|
onExternalAnimationComplete={handleExternalAnimationComplete}
|
|
554
|
+
isBlank={isBlank}
|
|
524
555
|
/>
|
|
525
556
|
</div>
|
|
526
557
|
{/* ClaudePanel is rendered by AppShell - DO NOT duplicate here */}
|