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.
Files changed (73) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +25 -9
  3. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +7 -3
  4. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  5. package/apps/dashboard/app/api/usage/route.ts +17 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  7. package/apps/dashboard/app/install-claude/page.tsx +8 -6
  8. package/apps/dashboard/app/login/page.tsx +229 -0
  9. package/apps/dashboard/app/page.tsx +5 -3
  10. package/apps/dashboard/app/settings/page.tsx +2 -0
  11. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  12. package/apps/dashboard/app/welcome/page.tsx +23 -0
  13. package/apps/dashboard/components/AppShell.tsx +51 -9
  14. package/apps/dashboard/components/CardMenu.tsx +14 -5
  15. package/apps/dashboard/components/ClaudePanel.tsx +65 -9
  16. package/apps/dashboard/components/ConnectClaudeScreen.tsx +223 -0
  17. package/apps/dashboard/components/DragContext.tsx +73 -64
  18. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  19. package/apps/dashboard/components/GateCard.tsx +21 -0
  20. package/apps/dashboard/components/InstallClaudeScreen.tsx +132 -30
  21. package/apps/dashboard/components/KanbanBoard.tsx +173 -56
  22. package/apps/dashboard/components/PlaceholderCard.tsx +9 -19
  23. package/apps/dashboard/components/ProjectSwitcher.tsx +28 -0
  24. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +34 -3
  25. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +30 -2
  26. package/apps/dashboard/components/SubscribeContent.tsx +191 -0
  27. package/apps/dashboard/components/TipCard.tsx +176 -0
  28. package/apps/dashboard/components/UpgradeBanner.tsx +29 -0
  29. package/apps/dashboard/components/WelcomeScreen.tsx +14 -4
  30. package/apps/dashboard/components/settings/AccountSection.tsx +163 -0
  31. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +292 -29
  32. package/apps/dashboard/contexts/UsageContext.tsx +131 -0
  33. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  34. package/apps/dashboard/electron/ipc-handlers.js +220 -114
  35. package/apps/dashboard/electron/main.js +415 -37
  36. package/apps/dashboard/electron/preload.js +23 -4
  37. package/apps/dashboard/electron/session-manager.js +141 -0
  38. package/apps/dashboard/electron-builder.config.js +3 -5
  39. package/apps/dashboard/lib/claude-process-manager.ts +6 -4
  40. package/apps/dashboard/lib/db-bridge.ts +32 -0
  41. package/apps/dashboard/lib/db.ts +159 -13
  42. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  43. package/apps/dashboard/lib/session-stream-manager.ts +76 -13
  44. package/apps/dashboard/lib/tests.ts +3 -1
  45. package/apps/dashboard/next.config.js +19 -14
  46. package/apps/dashboard/package.json +3 -1
  47. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  48. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  49. package/apps/update-server/package.json +16 -0
  50. package/apps/update-server/schema.sql +31 -0
  51. package/apps/update-server/src/index.ts +1074 -0
  52. package/apps/update-server/tsconfig.json +16 -0
  53. package/apps/update-server/wrangler.toml +35 -0
  54. package/docs/bdd-guidance.md +390 -0
  55. package/jettypod.js +5 -4
  56. package/lib/migrations/027-plan-at-creation-column.js +31 -0
  57. package/lib/migrations/028-ready-for-review-column.js +27 -0
  58. package/lib/schema.js +3 -1
  59. package/lib/seed-onboarding.js +100 -68
  60. package/lib/work-commands/index.js +43 -13
  61. package/lib/work-tracking/index.js +46 -27
  62. package/package.json +1 -1
  63. package/skills-templates/bug-mode/SKILL.md +5 -11
  64. package/skills-templates/request-routing/SKILL.md +24 -11
  65. package/skills-templates/simple-improvement/SKILL.md +35 -19
  66. package/skills-templates/stable-mode/SKILL.md +5 -6
  67. package/templates/bdd-guidance.md +139 -0
  68. package/templates/bdd-scaffolding/wait.js +18 -0
  69. package/templates/bdd-scaffolding/world.js +19 -0
  70. package/.jettypod-backup/work.db +0 -0
  71. package/apps/dashboard/app/access-code/page.tsx +0 -110
  72. package/lib/discovery-checkpoint.js +0 -123
  73. 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
- // A top-level item is reviewable when it's in-flight (in_progress) or in the done column
94
- // Non-top-level items (chores/bugs with parents) are not reviewable
95
- const isTopLevel = !item.parent_id;
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
- return (
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, dragPosition, draggedCardHeight, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
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
- // Get current item IDs from ref
521
- const itemIds = new Set(itemsRef.current.map(item => item.id));
583
+ const currentItems = itemsRef.current.filter(item => item.id !== itemId);
584
+ if (currentItems.length === 0) {
585
+ return;
586
+ }
522
587
 
523
- // Use cached card positions from registry, filtered to this epic's items
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) && pos.id !== itemId)
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
- // Calculate new display_order with bounds checking
550
- let newOrder = insertIndex * DISPLAY_ORDER_INCREMENT;
551
- // Clamp to safe bounds to prevent overflow
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 (dragPosition.y > pos.midY) {
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" height={draggedCardHeight} />
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}`} height={draggedCardHeight} />
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
- className="p-1 rounded 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 transition-colors"
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
- // Get current backlog item IDs from ref
799
- const backlogItemIds = new Set<number>();
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
- backlogItemIds.add(item.id);
890
+ if (item.id !== itemId) {
891
+ backlogItems.push(item);
892
+ }
803
893
  }
804
894
  }
805
895
 
806
- // Use cached card positions from registry, filtered to backlog items
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) && pos.id !== itemId)
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
- // Sort by Y position
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
- // Calculate new display_order with bounds checking
828
- let newOrder = insertIndex * DISPLAY_ORDER_INCREMENT;
829
- // Clamp to safe bounds to prevent overflow
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
- interface PlaceholderCardProps {
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, height: 0 }}
19
- animate={{ opacity: 0.6, height: effectiveHeight }}
20
- exit={{ opacity: 0, height: 0 }}
21
- transition={{ duration: 0.2, ease: 'easeOut' }}
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
- borderRadius: 8,
24
- border: '2px dashed rgba(100, 116, 139, 0.4)',
25
- backgroundColor: 'rgba(100, 116, 139, 0.1)',
26
- marginTop: 8,
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 */}