jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -1,807 +1,38 @@
1
- 'use client';
2
1
 
3
- import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
4
- import Link from 'next/link';
5
- import { useRouter } from 'next/navigation';
6
- import { AnimatePresence, motion } from 'framer-motion';
7
- import { useDroppable } from '@dnd-kit/core';
2
+ import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react';
3
+ import { useVirtualizer } from '@tanstack/react-virtual';
8
4
  import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
9
5
  import type { UndoAction } from '@/lib/undoStack';
10
- import type { Session } from '../contexts/ClaudeSessionContext';
11
- import { EditableTitle } from './EditableTitle';
12
- import { CardMenu } from './CardMenu';
13
- import { DragProvider, useDragContext } from './DragContext';
6
+ import { DragProvider } from './DragContext';
14
7
  import { DraggableCard } from './DraggableCard';
15
8
  import { DropZone } from './DropZone';
16
- import { PlaceholderCard } from './PlaceholderCard';
17
- import { CopyableId } from './CopyableId';
18
- import { WaveCompletionAnimation } from './WaveCompletionAnimation';
19
-
20
- const typeIcons: Record<string, string> = {
21
- epic: '🎯',
22
- feature: '✨',
23
- chore: '🔧',
24
- bug: '🐛',
25
- };
26
-
27
- const modeLabels: Record<string, { label: string; color: string }> = {
28
- speed: { label: 'speed', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300' },
29
- stable: { label: 'stable', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300' },
30
- production: { label: 'prod', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300' },
31
- };
32
-
33
- function getModeLabel(item: WorkItem): string {
34
- if (!item.mode) return '';
35
- const base = modeLabels[item.mode]?.label || item.mode;
36
- if (item.current_step && item.total_steps) {
37
- return `${base} ${item.current_step}/${item.total_steps}`;
9
+ import { KanbanCard } from './KanbanCard';
10
+ import { EpicGroup, MIN_DISPLAY_ORDER, MAX_DISPLAY_ORDER, DISPLAY_ORDER_INCREMENT } from './EpicGroup';
11
+ import { useDragContext } from './DragContext';
12
+ import { shadow } from '@/lib/shadows';
13
+
14
+ const BACKLOG_VISIBLE_LIMIT = 45;
15
+ const DONE_VISIBLE_LIMIT = 15;
16
+
17
+ // Returns the subset of entries to render, respecting soft epic-group boundaries.
18
+ // If adding a group would cross the limit but the count before it was under, include it fully.
19
+ function getVisibleEntries(
20
+ entries: [string, KanbanGroup][],
21
+ limit: number,
22
+ showAll: boolean
23
+ ): { visible: [string, KanbanGroup][]; totalCount: number; hasMore: boolean } {
24
+ const totalCount = entries.reduce((sum, [, g]) => sum + g.items.length, 0);
25
+ if (showAll || totalCount <= limit) {
26
+ return { visible: entries, totalCount, hasMore: false };
38
27
  }
39
- return base;
40
- }
41
-
42
-
43
- // Session indicator icon - shows when item has an active Claude session
44
- function SessionIndicatorIcon({ className }: { className?: string }) {
45
- return (
46
- <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
47
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
48
- </svg>
49
- );
50
- }
51
-
52
- interface KanbanCardProps {
53
- item: WorkItem;
54
- epicTitle?: string | null;
55
- showEpic?: boolean;
56
- isInFlight?: boolean;
57
- onTitleSave?: (id: number, newTitle: string) => Promise<void>;
58
- onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
59
- onReject?: (id: number, reason: string) => Promise<void>;
60
- onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
61
- hasActiveSession?: boolean;
62
- onOpenSession?: (id: string) => void;
63
- usageAllowed?: boolean;
64
- // Animation state lifted to board level
65
- isCompletingAnimation?: boolean;
66
- onAnimationComplete?: () => void;
67
- isHighlighted?: boolean;
68
- }
69
-
70
- function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange, onReject, onTriggerClaude, hasActiveSession, onOpenSession, usageAllowed = true, isCompletingAnimation = false, onAnimationComplete, isHighlighted = false }: KanbanCardProps) {
71
- const [expanded, setExpanded] = useState(false);
72
- const [showRejectInput, setShowRejectInput] = useState(false);
73
- const [rejectReason, setRejectReason] = useState('');
74
- const router = useRouter();
75
-
76
- const handleOpenSession = (e: React.MouseEvent) => {
77
- e.stopPropagation(); // Prevent card navigation
78
- if (onOpenSession) {
79
- onOpenSession(String(item.id));
80
- }
81
- };
82
-
83
- const handleStart = async (e: React.MouseEvent) => {
84
- e.stopPropagation(); // Prevent card navigation
85
- if (onStatusChange) {
86
- await onStatusChange(item.id, 'in_progress');
87
- if (onTriggerClaude) {
88
- onTriggerClaude(item.id, item.title, item.type, !!item.conversational, item.description);
89
- }
90
- }
91
- };
92
-
93
- const canStart = item.status === 'backlog' || item.status === 'cancelled';
94
-
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;
98
-
99
- const handleAccept = async (e: React.MouseEvent) => {
100
- e.stopPropagation();
101
- if (onStatusChange) {
102
- await onStatusChange(item.id, 'done');
103
- }
104
- };
105
-
106
- const handleRejectClick = (e: React.MouseEvent) => {
107
- e.stopPropagation();
108
- setShowRejectInput(true);
109
- };
110
-
111
- const handleRejectConfirm = async (e: React.MouseEvent) => {
112
- e.stopPropagation();
113
- if (onReject && rejectReason.trim()) {
114
- await onReject(item.id, rejectReason.trim());
115
- setShowRejectInput(false);
116
- setRejectReason('');
117
- }
118
- };
119
-
120
- const handleRejectCancel = (e: React.MouseEvent) => {
121
- e.stopPropagation();
122
- setShowRejectInput(false);
123
- setRejectReason('');
124
- };
125
-
126
- // Calculate chores for expandable section
127
- const allChores = item.chores || [];
128
- const incompleteChores = allChores.filter(c => c.status !== 'done');
129
- const hasChores = allChores.length > 0;
130
- const hasIncompleteChores = incompleteChores.length > 0;
131
-
132
- // Calculate bugs for expandable section
133
- const allBugs = item.bugs || [];
134
- const incompleteBugs = allBugs.filter(b => b.status !== 'done');
135
- const hasBugs = allBugs.length > 0;
136
-
137
- const handleCardClick = () => {
138
- router.push(`/work/${item.id}`);
139
- };
140
-
141
- const handleTitleSave = async (id: number, newTitle: string) => {
142
- if (onTitleSave) {
143
- await onTitleSave(id, newTitle);
144
- }
145
- };
146
-
147
- // Status changes are now handled by the board-level wrapper that triggers animation
148
- const handleStatusChange = async (id: number, newStatus: string) => {
149
- if (onStatusChange) {
150
- await onStatusChange(id, newStatus);
151
- }
152
- };
153
-
154
- const isDone = item.status === 'done';
155
-
156
- const getCardStyles = () => {
157
- const baseElevation = `
158
- 0 1px 2px rgba(0, 0, 0, 0.03),
159
- 0 2px 4px rgba(0, 0, 0, 0.03),
160
- 0 4px 8px rgba(0, 0, 0, 0.02)
161
- `;
162
- const hoverElevation = `
163
- 0 2px 4px rgba(0, 0, 0, 0.04),
164
- 0 4px 8px rgba(0, 0, 0, 0.04),
165
- 0 8px 16px rgba(0, 0, 0, 0.03),
166
- 0 12px 24px rgba(129, 157, 159, 0.08)
167
- `;
168
-
169
- if (isDone) {
170
- return {
171
- className: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
172
- boxShadow: baseElevation,
173
- hoverBoxShadow: hoverElevation,
174
- };
175
- }
176
- if (isInFlight) {
177
- return {
178
- className: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
179
- boxShadow: baseElevation,
180
- hoverBoxShadow: hoverElevation,
181
- };
182
- }
183
- return {
184
- className: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700',
185
- boxShadow: baseElevation,
186
- hoverBoxShadow: hoverElevation,
187
- };
188
- };
189
-
190
- const cardStyles = getCardStyles();
191
-
192
- const cardContent = (
193
- <WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
194
- <div
195
- className={`rounded-xl border transition-all duration-200 hover:-translate-y-0.5 ${cardStyles.className}`}
196
- style={{ boxShadow: cardStyles.boxShadow }}
197
- onMouseEnter={(e) => { e.currentTarget.style.boxShadow = cardStyles.hoverBoxShadow; }}
198
- onMouseLeave={(e) => { e.currentTarget.style.boxShadow = cardStyles.boxShadow; }}
199
- data-testid={`kanban-card-${item.id}`}>
200
- <div
201
- onClick={handleCardClick}
202
- className="block p-3 cursor-pointer"
203
- >
204
- <div className="flex items-start gap-2">
205
- <span className="text-sm flex-shrink-0">{typeIcons[item.type] || '📄'}</span>
206
- <div className="flex-1 min-w-0">
207
- {isDone ? (
208
- /* Compact layout for done cards: ID and title inline, no mode badge */
209
- <div className="flex items-start gap-2">
210
- <CopyableId id={item.id} title={item.title} type={item.type} />
211
- <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
212
- {item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
213
- </span>
214
- </div>
215
- ) : (
216
- /* Standard layout: ID + mode badge on line 1, title below */
217
- <>
218
- <div className="flex items-center gap-2 mb-1 flex-wrap">
219
- <CopyableId id={item.id} title={item.title} type={item.type} />
220
- {item.mode && modeLabels[item.mode] && (
221
- <span className={`text-xs px-1.5 py-0.5 rounded ${modeLabels[item.mode].color}`}>
222
- {getModeLabel(item)}
223
- </span>
224
- )}
225
- </div>
226
- <EditableTitle
227
- title={item.title}
228
- itemId={item.id}
229
- onSave={handleTitleSave}
230
- />
231
- </>
232
- )}
233
- {showEpic && epicTitle && (
234
- <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5 flex items-center gap-1">
235
- <span>🎯</span>
236
- <span>{epicTitle}</span>
237
- </p>
238
- )}
239
- </div>
240
- <div className="flex items-center gap-1">
241
- {/* Accept/Reject buttons - shown for reviewable top-level items */}
242
- {isReviewable && item.status !== 'done' && onStatusChange && (
243
- <button
244
- onClick={handleAccept}
245
- className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 transition-colors"
246
- aria-label="Accept work item"
247
- data-testid={`accept-button-${item.id}`}
248
- title="Accept"
249
- >
250
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
251
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
252
- </svg>
253
- </button>
254
- )}
255
- {isReviewable && onReject && (
256
- <button
257
- onClick={handleRejectClick}
258
- className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
259
- aria-label="Reject work item"
260
- data-testid={`reject-button-${item.id}`}
261
- title="Reject"
262
- >
263
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
264
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
265
- </svg>
266
- </button>
267
- )}
268
- {/* Session indicator - clickable icon to reopen session */}
269
- {hasActiveSession && (
270
- <button
271
- onClick={handleOpenSession}
272
- className="p-1.5 rounded hover:bg-zinc-100 dark:hover:bg-zinc-700 text-blue-500 transition-colors"
273
- aria-label="Open active session"
274
- data-testid={`session-indicator-${item.id}`}
275
- title="Open session"
276
- >
277
- <SessionIndicatorIcon className="w-4 h-4" />
278
- </button>
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
- )}
314
- {onStatusChange && (
315
- <CardMenu
316
- itemId={item.id}
317
- itemTitle={item.title}
318
- itemType={item.type}
319
- itemDescription={item.description}
320
- conversational={!!item.conversational}
321
- currentStatus={item.status}
322
- onStatusChange={handleStatusChange}
323
- onTriggerClaude={onTriggerClaude}
324
- hasActiveSession={hasActiveSession}
325
- onOpenSession={onOpenSession}
326
- usageAllowed={usageAllowed}
327
- />
328
- )}
329
- </div>
330
- </div>
331
- </div>
332
- {/* Rejection reason input */}
333
- {showRejectInput && (
334
- <div className="px-3 pb-3 border-t border-zinc-200 dark:border-zinc-700" onClick={(e) => e.stopPropagation()}>
335
- <div className="mt-2">
336
- <input
337
- type="text"
338
- value={rejectReason}
339
- onChange={(e) => setRejectReason(e.target.value)}
340
- onKeyDown={(e) => {
341
- if (e.key === 'Enter' && rejectReason.trim()) {
342
- handleRejectConfirm(e as unknown as React.MouseEvent);
343
- }
344
- if (e.key === 'Escape') {
345
- handleRejectCancel(e as unknown as React.MouseEvent);
346
- }
347
- }}
348
- placeholder="Rejection reason..."
349
- className="w-full text-xs px-2 py-1.5 rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-red-400"
350
- autoFocus
351
- data-testid={`reject-reason-input-${item.id}`}
352
- />
353
- <div className="flex items-center gap-1 mt-1.5">
354
- <button
355
- onClick={handleRejectConfirm}
356
- disabled={!rejectReason.trim()}
357
- className="px-2 py-0.5 text-xs rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
358
- data-testid={`reject-confirm-${item.id}`}
359
- >
360
- Reject
361
- </button>
362
- <button
363
- onClick={handleRejectCancel}
364
- className="px-2 py-0.5 text-xs rounded border border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
365
- data-testid={`reject-cancel-${item.id}`}
366
- >
367
- Cancel
368
- </button>
369
- </div>
370
- </div>
371
- </div>
372
- )}
373
- {/* Rejected indicator */}
374
- {item.rejection_reason && (
375
- <div className="px-3 pb-2 border-t border-red-200 dark:border-red-800">
376
- <div className="mt-1.5 flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
377
- <span className="flex-shrink-0">⚠️</span>
378
- <span className="italic">{item.rejection_reason}</span>
379
- </div>
380
- </div>
381
- )}
382
- {/* Show expandable section for features with chores or bugs */}
383
- {(hasChores || hasBugs) && (
384
- <div className={`border-t ${isDone ? 'border-green-200 dark:border-green-800' : 'border-zinc-200 dark:border-zinc-700'}`}>
385
- <button
386
- onClick={() => setExpanded(!expanded)}
387
- className={`w-full px-3 py-1.5 flex items-start gap-1.5 text-xs transition-colors ${
388
- isDone
389
- ? 'text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30'
390
- : 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
391
- }`}
392
- >
393
- <span className="mt-0.5">{expanded ? '▼' : '▶'}</span>
394
- <div className="flex flex-col gap-0.5">
395
- {hasChores && (
396
- <div className="flex items-center gap-1.5">
397
- <span>🔧</span>
398
- <span>
399
- {isDone
400
- ? `${allChores.length === 0 ? 'no' : allChores.length} chore${allChores.length !== 1 ? 's' : ''}`
401
- : `${incompleteChores.length === 0 ? 'no' : incompleteChores.length}${item.mode ? ` ${item.mode} mode` : ''} chore${incompleteChores.length !== 1 ? 's' : ''} left`}
402
- </span>
403
- </div>
404
- )}
405
- {hasBugs && (
406
- <div className="flex items-center gap-1.5">
407
- <span>🐛</span>
408
- <span>
409
- {isDone
410
- ? `${allBugs.length === 0 ? 'no' : allBugs.length} bug${allBugs.length !== 1 ? 's' : ''}`
411
- : `${incompleteBugs.length === 0 ? 'no' : incompleteBugs.length} bug${incompleteBugs.length !== 1 ? 's' : ''} left`}
412
- </span>
413
- </div>
414
- )}
415
- </div>
416
- </button>
417
- {expanded && (
418
- <div className="px-3 pb-2 space-y-1">
419
- {allChores.map((chore) => {
420
- const isComplete = chore.status === 'done';
421
- return (
422
- <Link
423
- key={chore.id}
424
- href={`/work/${chore.id}`}
425
- className={`block py-1 px-2 text-xs rounded transition-colors ${
426
- isComplete
427
- ? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
428
- : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
429
- }`}
430
- >
431
- <div className="flex items-center gap-2">
432
- <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
433
- {!isDone && chore.mode && modeLabels[chore.mode] && (
434
- <span className={`px-1 py-0.5 rounded text-[10px] ${modeLabels[chore.mode].color}`}>
435
- {getModeLabel(chore)}
436
- </span>
437
- )}
438
- <span className={`truncate ${
439
- isComplete
440
- ? 'text-zinc-500'
441
- : 'text-zinc-700 dark:text-zinc-300'
442
- }`}>
443
- {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
444
- </span>
445
- </div>
446
- </Link>
447
- );
448
- })}
449
- {allBugs.map((bug) => {
450
- const isComplete = bug.status === 'done';
451
- return (
452
- <Link
453
- key={bug.id}
454
- href={`/work/${bug.id}`}
455
- className={`block py-1 px-2 text-xs rounded transition-colors ${
456
- isComplete
457
- ? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
458
- : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
459
- }`}
460
- >
461
- <div className="flex items-center gap-2">
462
- <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
463
- <span>🐛</span>
464
- <span className={`truncate ${
465
- isComplete
466
- ? 'text-zinc-500'
467
- : 'text-zinc-700 dark:text-zinc-300'
468
- }`}>
469
- {bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
470
- </span>
471
- </div>
472
- </Link>
473
- );
474
- })}
475
- </div>
476
- )}
477
- </div>
478
- )}
479
- </div>
480
- </WaveCompletionAnimation>
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
- );
28
+ const visible: [string, KanbanGroup][] = [];
29
+ let count = 0;
30
+ for (const entry of entries) {
31
+ if (count >= limit) break;
32
+ visible.push(entry);
33
+ count += entry[1].items.length;
503
34
  }
504
-
505
- return cardContent;
506
- }
507
-
508
- // Safe bounds for display_order to prevent overflow
509
- const MIN_DISPLAY_ORDER = 0;
510
- const MAX_DISPLAY_ORDER = Number.MAX_SAFE_INTEGER - 1000;
511
- const DISPLAY_ORDER_INCREMENT = 10;
512
-
513
- interface EpicGroupProps {
514
- epicId: number | null;
515
- epicTitle: string | null;
516
- items: WorkItem[];
517
- isInFlight?: boolean;
518
- isDraggable?: boolean;
519
- onTitleSave?: (id: number, newTitle: string) => Promise<void>;
520
- onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
521
- onReject?: (id: number, reason: string) => Promise<void>;
522
- onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
523
- onOrderChange?: (id: number, newOrder: number) => Promise<void>;
524
- onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
525
- activeSessions?: Map<string, Session>;
526
- onOpenSession?: (id: string) => void;
527
- onError?: (message: string) => void;
528
- usageAllowed?: boolean;
529
- // Animation state lifted to board level
530
- animatingItemId?: number | null;
531
- onAnimationComplete?: () => void;
532
- isBlank?: boolean;
533
- }
534
-
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) {
536
- const containerRef = useRef<HTMLDivElement>(null);
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]);
548
-
549
- // Use @dnd-kit's useDroppable for epic zone collision detection
550
- const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
551
- const { setNodeRef } = useDroppable({
552
- id: zoneId || 'ungrouped',
553
- disabled: epicId === null, // Don't use droppable for ungrouped section
554
- data: { epicId },
555
- });
556
-
557
- // Combine refs
558
- const setRefs = useCallback((node: HTMLDivElement | null) => {
559
- if (epicId !== null) {
560
- setNodeRef(node);
561
- }
562
- (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
563
- }, [epicId, setNodeRef]);
564
-
565
- // Use ref for items to avoid re-registering drop zone when items change
566
- const itemsRef = useRef(items);
567
- itemsRef.current = items;
568
-
569
- // Use ref for callbacks to keep drop zone registration stable
570
- const onOrderChangeRef = useRef(onOrderChange);
571
- onOrderChangeRef.current = onOrderChange;
572
-
573
- // Use ref for error handler to keep reorder handler stable
574
- const onErrorRef = useRef(onError);
575
- onErrorRef.current = onError;
576
-
577
- // Stable reorder handler that reads from refs
578
- const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
579
- if (!onOrderChangeRef.current) {
580
- return;
581
- }
582
-
583
- const currentItems = itemsRef.current.filter(item => item.id !== itemId);
584
- if (currentItems.length === 0) {
585
- return;
586
- }
587
-
588
- // Read fresh positions from DOM (not stale cache)
589
- const allPositions = getCardPositions();
590
- const itemIds = new Set(currentItems.map(item => item.id));
591
- const cardPositions = allPositions
592
- .filter(pos => itemIds.has(pos.id))
593
- .map(pos => ({
594
- id: pos.id,
595
- midY: (pos.rect.top + pos.rect.bottom) / 2,
596
- }))
597
- .sort((a, b) => a.midY - b.midY);
598
-
599
- if (cardPositions.length === 0) {
600
- return;
601
- }
602
-
603
- // Find insertion index based on pointer Y
604
- let insertIndex = cardPositions.length;
605
- for (let i = 0; i < cardPositions.length; i++) {
606
- if (pointerY < cardPositions[i].midY) {
607
- insertIndex = i;
608
- break;
609
- }
610
- }
611
-
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
-
632
- newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
633
-
634
- try {
635
- await onOrderChangeRef.current(itemId, newOrder);
636
- } catch (error) {
637
- const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
638
- onErrorRef.current?.(errorMessage);
639
- }
640
- }, [getCardPositions]);
641
-
642
- // Register as epic drop zone - stable registration that doesn't change with items
643
- useEffect(() => {
644
- if (!containerRef.current || !onEpicAssign || epicId === null) return;
645
-
646
- const zoneId = `epic-${epicId}`;
647
- registerEpicDropZone(zoneId, {
648
- epicId,
649
- element: containerRef.current,
650
- onEpicAssign,
651
- onReorder: handleEpicReorder,
652
- });
653
-
654
- return () => {
655
- unregisterEpicDropZone(zoneId);
656
- };
657
- }, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
658
-
659
- // Check if this epic zone is the active drop target
660
- const isActiveTarget = activeEpicZone === `epic-${epicId}`;
661
-
662
- // Check if the dragged item is from a different epic or same epic
663
- const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
664
- const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
665
- const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
666
-
667
- // Show highlight when dragging an item from different epic over this group (indigo)
668
- const showHighlight = isActiveTarget && isDifferentEpic;
669
- // Show reorder highlight when dragging within same epic (purple)
670
- const showReorderHighlight = isActiveTarget && isSameEpic;
671
-
672
- // For ungrouped section (epicId === null)
673
- const isUngroupedSection = epicId === null;
674
- // Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
675
- const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
676
-
677
- // Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
678
- const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
679
- const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
680
-
681
- // Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
682
- const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
683
-
684
- // Calculate insertion preview for this group - only for the active zone
685
- const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
686
- let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
687
-
688
- if (showPreview && draggedItem) {
689
- const allPositions = getCardPositions();
690
- const itemIds = new Set(items.map(item => item.id));
691
- const groupPositions = allPositions
692
- .filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
693
- .map(pos => ({
694
- id: pos.id,
695
- midY: (pos.rect.top + pos.rect.bottom) / 2,
696
- }))
697
- .sort((a, b) => a.midY - b.midY);
698
-
699
- // Find which card the pointer is after
700
- insertAfterItemId = null; // Default to beginning
701
- for (const pos of groupPositions) {
702
- if (pointerY > pos.midY) {
703
- insertAfterItemId = pos.id;
704
- } else {
705
- break;
706
- }
707
- }
708
- }
709
-
710
- if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
711
-
712
- // Standalone done items (single item, no epic) use tighter spacing
713
- const isStandaloneItem = !epicTitle && items.length === 1;
714
-
715
- return (
716
- <div
717
- ref={setRefs}
718
- className={`${isStandaloneItem ? 'mb-2' : 'mb-4 p-2 -mx-2'} rounded-lg transition-all ${
719
- showHighlight
720
- ? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
721
- : showReorderHighlight
722
- ? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
723
- : showRemoveFromEpicZone
724
- ? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
725
- : ''
726
- }`}
727
- data-epic-id={epicId}
728
- >
729
- {epicTitle && (
730
- <div className="flex items-center gap-2 mb-2">
731
- <Link
732
- href={`/work/${epicId}`}
733
- className="flex items-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
734
- >
735
- <span>🎯</span>
736
- <span>{epicTitle}</span>
737
- </Link>
738
- {isInFlight && (
739
- <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
740
- in flight
741
- </span>
742
- )}
743
- {showHighlight && (
744
- <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
745
- drop to assign
746
- </span>
747
- )}
748
- {showReorderHighlight && (
749
- <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
750
- reorder
751
- </span>
752
- )}
753
- </div>
754
- )}
755
- {/* Ungrouped section header - shown when dragging from epic */}
756
- {isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
757
- <div className="flex items-center gap-2 py-3">
758
- <span className="text-xs font-medium text-orange-600 dark:text-orange-400">
759
- Drop here to remove from epic
760
- </span>
761
- </div>
762
- )}
763
- {isUngroupedSection && items.length > 0 && isDraggable && showRemoveFromEpicZone && (
764
- <div className="flex items-center gap-2 mb-2">
765
- <span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300">
766
- drop to remove from epic
767
- </span>
768
- </div>
769
- )}
770
- <div className="space-y-2">
771
- {/* Placeholder at the beginning (insertAfterItemId === null) */}
772
- <AnimatePresence>
773
- {insertAfterItemId === null && (
774
- <PlaceholderCard key="placeholder-start" />
775
- )}
776
- </AnimatePresence>
777
- {items.map((item) => (
778
- <div key={item.id}>
779
- <DraggableCard item={item} disabled={!isDraggable}>
780
- <KanbanCard
781
- item={item}
782
- onTitleSave={onTitleSave}
783
- onStatusChange={onStatusChange}
784
- onReject={onReject}
785
- onTriggerClaude={onTriggerClaude}
786
- hasActiveSession={activeSessions?.has(String(item.id))}
787
- onOpenSession={onOpenSession}
788
- usageAllowed={usageAllowed}
789
- isCompletingAnimation={animatingItemId === item.id}
790
- onAnimationComplete={onAnimationComplete}
791
- isHighlighted={isBlank && item.status === 'backlog' && item.title === 'Align on the user journey'}
792
- />
793
- </DraggableCard>
794
- {/* Placeholder after this card */}
795
- <AnimatePresence>
796
- {insertAfterItemId === item.id && (
797
- <PlaceholderCard key={`placeholder-${item.id}`} />
798
- )}
799
- </AnimatePresence>
800
- </div>
801
- ))}
802
- </div>
803
- </div>
804
- );
35
+ return { visible, totalCount, hasMore: count < totalCount };
805
36
  }
806
37
 
807
38
  interface KanbanColumnProps {
@@ -810,51 +41,44 @@ interface KanbanColumnProps {
810
41
  count: number;
811
42
  onAdd?: () => void;
812
43
  addDisabled?: boolean;
44
+ scrollRef?: React.RefObject<HTMLDivElement | null>;
813
45
  }
814
46
 
815
- function KanbanColumn({ title, children, count, onAdd, addDisabled }: KanbanColumnProps) {
47
+ function KanbanColumn({ title, children, count, onAdd, addDisabled, scrollRef }: KanbanColumnProps) {
816
48
  const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
817
49
  return (
818
- <div className="flex-1 min-w-[300px] max-w-[400px] flex flex-col min-h-0" data-testid={testId}>
50
+ <div className="flex-1 max-w-[600px] flex flex-col min-h-0" data-testid={testId}>
819
51
  <div
820
- className="bg-zinc-100 dark:bg-zinc-900 rounded-xl p-3 flex flex-col flex-1 min-h-0"
821
- style={{
822
- boxShadow: `
823
- 0 1px 2px rgba(0, 0, 0, 0.04),
824
- 0 2px 4px rgba(0, 0, 0, 0.04),
825
- 0 4px 8px rgba(0, 0, 0, 0.04),
826
- 0 8px 16px rgba(0, 0, 0, 0.02),
827
- inset 0 1px 0 rgba(255, 255, 255, 0.5)
828
- `,
829
- }}
52
+ className="bg-zinc-100 dark:bg-zinc-900 rounded-xl p-4 flex flex-col flex-1 min-h-0"
53
+ style={{ boxShadow: shadow.sm }}
830
54
  >
831
- <div className="flex items-center justify-between mb-3 flex-shrink-0">
55
+ <div className="flex items-center justify-between mb-4 flex-shrink-0">
832
56
  <h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
833
57
  <div className="flex items-center gap-2">
834
58
  {onAdd && (
835
59
  <button
836
60
  onClick={onAdd}
837
61
  disabled={addDisabled}
838
- className={`p-1 rounded transition-colors ${
62
+ className={`p-1 rounded transition-colors duration-200 ease-out ${
839
63
  addDisabled
840
64
  ? 'text-zinc-300 dark:text-zinc-700 cursor-not-allowed'
841
65
  : '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
66
  }`}
843
67
  aria-label={`Add to ${title.toLowerCase()}`}
844
68
  data-testid={`${testId}-add-button`}
845
- title={addDisabled ? 'Weekly usage limit reached' : undefined}
69
+ title={addDisabled ? 'Weekly usage limit reached' : `Add to ${title.toLowerCase()}`}
846
70
  >
847
71
  <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
848
72
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
849
73
  </svg>
850
74
  </button>
851
75
  )}
852
- <span className="text-xs bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-0.5 rounded-full">
76
+ <span className="text-xs bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-3 py-1 rounded-full">
853
77
  {count}
854
78
  </span>
855
79
  </div>
856
80
  </div>
857
- <div className="overflow-y-auto flex-1 min-h-0 px-1 -mx-1">
81
+ <div ref={scrollRef} className="overflow-y-auto overflow-x-hidden flex-1 min-h-0 px-3 -mx-3" style={{ contain: 'paint' }}>
858
82
  {children}
859
83
  </div>
860
84
  </div>
@@ -921,19 +145,20 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
921
145
  const itemMap = new Map(backlogItems.map(item => [item.id, item]));
922
146
  const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
923
147
 
924
- // Calculate proper midpoint display_order between surrounding items
148
+ // Calculate proper midpoint display_order between surrounding items.
149
+ // Fallback uses id * INCREMENT to match the sort comparator and give proper gaps.
925
150
  let newOrder: number;
926
151
  if (visualOrder.length === 0) {
927
152
  newOrder = DISPLAY_ORDER_INCREMENT;
928
153
  } else if (insertIndex === 0) {
929
- const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
154
+ const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id * DISPLAY_ORDER_INCREMENT;
930
155
  newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
931
156
  } else if (insertIndex >= visualOrder.length) {
932
- const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id;
157
+ const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id * DISPLAY_ORDER_INCREMENT;
933
158
  newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
934
159
  } else {
935
- const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
936
- const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id;
160
+ const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id * DISPLAY_ORDER_INCREMENT;
161
+ const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id * DISPLAY_ORDER_INCREMENT;
937
162
  newOrder = Math.floor((before + after) / 2);
938
163
  }
939
164
 
@@ -950,9 +175,9 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
950
175
  }}
951
176
  onReorder={handleBacklogReorder}
952
177
  allowReorder={true}
953
- className="rounded-lg p-2 -m-2 min-h-[100px]"
954
- highlightClassName="ring-2 ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30"
955
- reorderHighlightClassName="ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30"
178
+ className="rounded-lg p-3 -m-3 min-h-[100px]"
179
+ highlightClassName="ring-2 ring-[#819D9F] bg-[#E8EEEF]/50 dark:bg-[#819D9F]/20"
180
+ reorderHighlightClassName="ring-2 ring-[#E3D985] bg-[#F9F7E8]/50 dark:bg-[#E3D985]/20"
956
181
  data-testid="backlog-drop-zone"
957
182
  >
958
183
  {children}
@@ -960,6 +185,153 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
960
185
  );
961
186
  }
962
187
 
188
+ // Virtualized list of EpicGroups within a column's scroll container.
189
+ // Each EpicGroup is a virtual row — off-screen groups are not rendered at all,
190
+ // saving DOM nodes and mount/unmount overhead.
191
+ type VirtualRow =
192
+ | { type: 'group'; key: string; group: KanbanGroup }
193
+ | { type: 'showMore' }
194
+ | { type: 'empty' };
195
+
196
+ interface VirtualizedEpicListProps {
197
+ entries: [string, KanbanGroup][];
198
+ scrollRef: React.RefObject<HTMLDivElement | null>;
199
+ scrollMargin?: number;
200
+ hasMore: boolean;
201
+ onShowMore: () => void;
202
+ emptyMessage?: string;
203
+ // EpicGroup props pass-through
204
+ inFlightByEpic?: Map<number, InFlightItem[]>;
205
+ isDraggable?: boolean;
206
+ onTitleSave?: (id: number, newTitle: string) => Promise<void>;
207
+ onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
208
+ onReject?: (id: number, reason: string) => Promise<void>;
209
+ onRestart?: (id: number) => void;
210
+ onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
211
+ onOrderChange?: (id: number, newOrder: number) => Promise<void>;
212
+ onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
213
+ activeSessionIds?: Set<string>;
214
+ onOpenSession?: (id: string) => void;
215
+ onCloseSession?: (id: string) => void;
216
+ onError?: (message: string) => void;
217
+ usageAllowed?: boolean;
218
+ animatingItemId?: number | null;
219
+ onAnimationComplete?: () => void;
220
+ }
221
+
222
+ const VirtualizedEpicList = memo(function VirtualizedEpicList({
223
+ entries,
224
+ scrollRef,
225
+ scrollMargin = 0,
226
+ hasMore,
227
+ onShowMore,
228
+ emptyMessage,
229
+ inFlightByEpic,
230
+ isDraggable,
231
+ onTitleSave,
232
+ onStatusChange,
233
+ onReject,
234
+ onRestart,
235
+ onEpicAssign,
236
+ onOrderChange,
237
+ onTriggerClaude,
238
+ activeSessionIds,
239
+ onOpenSession,
240
+ onCloseSession,
241
+ onError,
242
+ usageAllowed,
243
+ animatingItemId,
244
+ onAnimationComplete,
245
+ }: VirtualizedEpicListProps) {
246
+ const rows = useMemo<VirtualRow[]>(() => {
247
+ const r: VirtualRow[] = [];
248
+ for (const [key, group] of entries) {
249
+ r.push({ type: 'group', key, group });
250
+ }
251
+ if (hasMore) r.push({ type: 'showMore' });
252
+ if (entries.length === 0 && emptyMessage) r.push({ type: 'empty' });
253
+ return r;
254
+ }, [entries, hasMore, emptyMessage]);
255
+
256
+ const virtualizer = useVirtualizer({
257
+ count: rows.length,
258
+ getScrollElement: () => scrollRef.current,
259
+ estimateSize: (index) => {
260
+ const row = rows[index];
261
+ if (!row || row.type === 'showMore' || row.type === 'empty') return 44;
262
+ // Estimate: header (~36px) + cards * ~95px per card + group padding
263
+ const isStandalone = !row.group.epicTitle && row.group.items.length === 1;
264
+ return isStandalone
265
+ ? 82 + 8 // card height + spacing
266
+ : 36 + row.group.items.length * 95 + 24; // header + cards + spacing
267
+ },
268
+ scrollMargin,
269
+ overscan: 3,
270
+ });
271
+
272
+ if (rows.length === 0) return null;
273
+
274
+ return (
275
+ <div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
276
+ {virtualizer.getVirtualItems().map((virtualItem) => {
277
+ const row = rows[virtualItem.index];
278
+ // Bottom padding replaces EpicGroup's mb-* (margin is invisible with absolute positioning)
279
+ const isStandalone = row.type === 'group' && !row.group.epicTitle && row.group.items.length === 1;
280
+ const rowPaddingBottom = row.type === 'group' ? (isStandalone ? 8 : 24) : 0;
281
+ return (
282
+ <div
283
+ key={virtualItem.key}
284
+ data-index={virtualItem.index}
285
+ ref={virtualizer.measureElement}
286
+ style={{
287
+ position: 'absolute',
288
+ top: 0,
289
+ left: 0,
290
+ width: '100%',
291
+ transform: `translateY(${virtualItem.start - scrollMargin}px)`,
292
+ paddingBottom: rowPaddingBottom,
293
+ }}
294
+ >
295
+ {row.type === 'group' ? (
296
+ <EpicGroup
297
+ epicId={row.group.epicId}
298
+ epicTitle={row.group.epicTitle}
299
+ items={row.group.items}
300
+ isInFlight={row.group.epicId ? inFlightByEpic?.has(row.group.epicId) : false}
301
+ inFlightItems={row.group.epicId ? inFlightByEpic?.get(row.group.epicId) : undefined}
302
+ isDraggable={isDraggable}
303
+ onTitleSave={onTitleSave}
304
+ onStatusChange={onStatusChange}
305
+ onReject={onReject}
306
+ onRestart={onRestart}
307
+ onEpicAssign={onEpicAssign}
308
+ onOrderChange={onOrderChange}
309
+ onTriggerClaude={onTriggerClaude}
310
+ activeSessionIds={activeSessionIds}
311
+ onOpenSession={onOpenSession}
312
+ onCloseSession={onCloseSession}
313
+ onError={onError}
314
+ usageAllowed={usageAllowed}
315
+ animatingItemId={animatingItemId}
316
+ onAnimationComplete={onAnimationComplete}
317
+ />
318
+ ) : row.type === 'showMore' ? (
319
+ <button
320
+ onClick={onShowMore}
321
+ className="w-full mt-3 py-2 text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-200/50 dark:hover:bg-zinc-800/50 rounded-lg transition-colors duration-200 ease-out"
322
+ >
323
+ Show more
324
+ </button>
325
+ ) : (
326
+ <p className="text-base text-zinc-500 text-center py-4">{emptyMessage}</p>
327
+ )}
328
+ </div>
329
+ );
330
+ })}
331
+ </div>
332
+ );
333
+ });
334
+
963
335
  interface KanbanBoardProps {
964
336
  inFlight: InFlightItem[];
965
337
  backlog: Map<string, KanbanGroup>;
@@ -967,12 +339,14 @@ interface KanbanBoardProps {
967
339
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
968
340
  onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
969
341
  onReject?: (id: number, reason: string) => Promise<void>;
342
+ onRestart?: (id: number) => void;
970
343
  onOrderChange?: (id: number, newOrder: number) => Promise<void>;
971
344
  onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
972
345
  onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
973
346
  // Multi-session support
974
347
  onOpenSession?: (id: string) => void;
975
- activeSessions?: Map<string, Session>;
348
+ onCloseSession?: (id: string) => void;
349
+ activeSessionIds?: Set<string>;
976
350
  // Undo/redo support
977
351
  onUndo?: () => Promise<UndoAction | null>;
978
352
  onRedo?: () => Promise<UndoAction | null>;
@@ -980,6 +354,8 @@ interface KanbanBoardProps {
980
354
  canRedo?: boolean;
981
355
  // Error handler for drag-drop operations
982
356
  onError?: (message: string) => void;
357
+ // Pre-built status map from data-bridge (avoids O(N) rebuild per render)
358
+ itemStatusMap?: Map<number, string>;
983
359
  // Add to backlog
984
360
  onAddToBacklog?: () => void;
985
361
  // Usage limits
@@ -987,13 +363,46 @@ interface KanbanBoardProps {
987
363
  // External animation trigger (e.g., from CLI/DB completions detected via WebSocket)
988
364
  externalAnimatingItemId?: number | null;
989
365
  onExternalAnimationComplete?: () => void;
990
- isBlank?: boolean;
991
366
  }
992
367
 
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) {
368
+ export const KanbanBoard = memo(function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onRestart, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, onCloseSession, activeSessionIds, onUndo, onRedo, canUndo, canRedo, onError, itemStatusMap: externalStatusMap, onAddToBacklog, usageAllowed = true, externalAnimatingItemId, onExternalAnimationComplete }: KanbanBoardProps) {
994
369
  const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
995
370
  const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
996
371
 
372
+ // Scroll container refs for virtualized columns
373
+ const backlogScrollRef = useRef<HTMLDivElement>(null);
374
+ const doneScrollRef = useRef<HTMLDivElement>(null);
375
+
376
+ // Measure non-virtualized content above the backlog virtualizer (In Flight + divider)
377
+ // so the virtualizer knows the correct scroll offset
378
+ const preBacklogRef = useRef<HTMLDivElement>(null);
379
+ const [backlogScrollMargin, setBacklogScrollMargin] = useState(0);
380
+ useEffect(() => {
381
+ const el = preBacklogRef.current;
382
+ if (!el) return;
383
+ const ro = new ResizeObserver((entries) => {
384
+ setBacklogScrollMargin(entries[0]?.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight);
385
+ });
386
+ ro.observe(el);
387
+ return () => ro.disconnect();
388
+ }, []);
389
+
390
+ // Lazy loading state for backlog and done columns
391
+ const [showAllBacklog, setShowAllBacklog] = useState(false);
392
+ const [showAllDone, setShowAllDone] = useState(false);
393
+
394
+ const backlogEntries = useMemo(() => Array.from(backlog.entries()), [backlog]);
395
+ const doneEntries = useMemo(() => Array.from(done.entries()), [done]);
396
+ const backlogLimit = Math.max(0, BACKLOG_VISIBLE_LIMIT - inFlight.length);
397
+ const { visible: visibleBacklog, hasMore: hasMoreBacklog } = useMemo(
398
+ () => getVisibleEntries(backlogEntries, backlogLimit, showAllBacklog),
399
+ [backlogEntries, backlogLimit, showAllBacklog]
400
+ );
401
+ const { visible: visibleDone, hasMore: hasMoreDone } = useMemo(
402
+ () => getVisibleEntries(doneEntries, DONE_VISIBLE_LIMIT, showAllDone),
403
+ [doneEntries, showAllDone]
404
+ );
405
+
997
406
  // Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
998
407
  useEffect(() => {
999
408
  const handleKeyDown = async (e: KeyboardEvent) => {
@@ -1026,14 +435,22 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1026
435
  return () => document.removeEventListener('keydown', handleKeyDown);
1027
436
  }, [onUndo, onRedo, canUndo, canRedo]);
1028
437
 
1029
- // Build a set of epic IDs that have in-flight items
1030
- const inFlightEpicIds = new Set<number>();
1031
- for (const item of inFlight) {
1032
- const epicId = item.parent_id || item.epic_id;
1033
- if (epicId) {
1034
- inFlightEpicIds.add(epicId);
438
+ // Build a map of epic IDs to their in-flight items
439
+ const inFlightByEpic = useMemo(() => {
440
+ const map = new Map<number, InFlightItem[]>();
441
+ for (const item of inFlight) {
442
+ const epicId = item.parent_id || item.epic_id;
443
+ if (epicId) {
444
+ const existing = map.get(epicId);
445
+ if (existing) {
446
+ existing.push(item);
447
+ } else {
448
+ map.set(epicId, [item]);
449
+ }
450
+ }
1035
451
  }
1036
- }
452
+ return map;
453
+ }, [inFlight]);
1037
454
 
1038
455
  // Board-level animation state - tracks which item is playing the completion animation
1039
456
  const [internalAnimatingItemId, setInternalAnimatingItemId] = useState<number | null>(null);
@@ -1042,24 +459,8 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1042
459
  // Merge internal (UI-driven) and external (CLI/DB-driven) animation triggers
1043
460
  const animatingItemId = internalAnimatingItemId ?? externalAnimatingItemId ?? null;
1044
461
 
1045
- // Build a map of all item IDs to their current status for checking if transitioning to done
1046
- const itemStatusMap = useMemo(() => {
1047
- const map = new Map<number, string>();
1048
- for (const item of inFlight) {
1049
- map.set(item.id, item.status);
1050
- }
1051
- for (const group of backlog.values()) {
1052
- for (const item of group.items) {
1053
- map.set(item.id, item.status);
1054
- }
1055
- }
1056
- for (const group of done.values()) {
1057
- for (const item of group.items) {
1058
- map.set(item.id, item.status);
1059
- }
1060
- }
1061
- return map;
1062
- }, [inFlight, backlog, done]);
462
+ // Use pre-built statusMap from data-bridge when available (avoids O(N) rebuild)
463
+ const itemStatusMap = externalStatusMap ?? new Map<number, string>();
1063
464
 
1064
465
  // Wrapper for onStatusChange that intercepts "done" transitions to play animation first
1065
466
  const handleStatusChangeWithAnimation = useCallback(async (id: number, newStatus: string) => {
@@ -1069,6 +470,12 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1069
470
 
1070
471
  // If transitioning to done from non-done status, play animation first in backlog/in-flight
1071
472
  if (newStatus === 'done' && currentStatus !== 'done') {
473
+ // Flush any existing pending change before replacing (rapid acceptance race)
474
+ if (pendingStatusChangeRef.current) {
475
+ const { id: prevId, status: prevStatus } = pendingStatusChangeRef.current;
476
+ pendingStatusChangeRef.current = null;
477
+ onStatusChange(prevId, prevStatus);
478
+ }
1072
479
  pendingStatusChangeRef.current = { id, status: newStatus };
1073
480
  setInternalAnimatingItemId(id);
1074
481
  return;
@@ -1094,10 +501,18 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1094
501
  }
1095
502
  }, [onStatusChange, internalAnimatingItemId, externalAnimatingItemId, onExternalAnimationComplete]);
1096
503
 
504
+ // Map for O(1) lookup in drag overlay instead of O(N) find
505
+ const inFlightMap = useMemo(() => {
506
+ const map = new Map<number, InFlightItem>();
507
+ for (const item of inFlight) {
508
+ map.set(item.id, item);
509
+ }
510
+ return map;
511
+ }, [inFlight]);
512
+
1097
513
  // Render function for the drag overlay
1098
514
  const renderDragOverlay = useCallback((item: WorkItem) => {
1099
- // Find epic title if this is an in-flight item
1100
- const inFlightItem = inFlight.find(i => i.id === item.id);
515
+ const inFlightItem = inFlightMap.get(item.id);
1101
516
  const epicTitle = inFlightItem?.epicTitle || null;
1102
517
  const isInFlightCard = inFlightItem !== undefined;
1103
518
 
@@ -1109,141 +524,136 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
1109
524
  isInFlight={isInFlightCard}
1110
525
  />
1111
526
  );
1112
- }, [inFlight]);
527
+ }, [inFlightMap]);
1113
528
 
1114
529
  return (
1115
530
  <DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign} onError={onError}>
1116
- <div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
531
+ {/* height: --main-h (from AppShell ResizeObserver) minus page py-4 padding */}
532
+ <div className="flex gap-4 overflow-x-auto" style={{ height: 'calc(var(--main-h, 100vh) - 2rem)' }} data-testid="kanban-board">
1117
533
  {/* Backlog Column */}
1118
- <KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed}>
1119
- {/* In Flight Section - Drop Zone */}
1120
- <DropZone
1121
- targetStatus="in_progress"
1122
- onDrop={async (itemId, newStatus) => {
1123
- await handleStatusChangeWithAnimation(itemId, newStatus);
1124
- }}
1125
- className="rounded-lg mb-4 p-2 -m-2"
1126
- highlightClassName="ring-2 ring-blue-400 bg-blue-100/50 dark:bg-blue-900/30"
1127
- data-testid="in-flight-drop-zone"
1128
- >
1129
- {inFlight.length > 0 ? (
1130
- <div data-testid="in-flight-section">
1131
- <div className="flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 mb-2">
1132
- <span>🔥</span>
1133
- <span>In Flight</span>
534
+ <KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed} scrollRef={backlogScrollRef}>
535
+ {/* Non-virtualized section: In Flight + divider (measured for scrollMargin) */}
536
+ <div ref={preBacklogRef}>
537
+ {/* In Flight Section - Drop Zone */}
538
+ <DropZone
539
+ targetStatus="in_progress"
540
+ onDrop={async (itemId, newStatus) => {
541
+ await handleStatusChangeWithAnimation(itemId, newStatus);
542
+ }}
543
+ className="rounded-lg mb-6 p-3 -m-3"
544
+ highlightClassName="ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20"
545
+ data-testid="in-flight-drop-zone"
546
+ >
547
+ {inFlight.length > 0 ? (
548
+ <div data-testid="in-flight-section" className="bg-[#e8f0f0] dark:bg-[#819D9F]/20 rounded-lg p-3 -m-1">
549
+ <div className="flex items-center gap-2 text-base font-medium text-[#5a7d7f] dark:text-[#a3bfc0] mb-3">
550
+ <img src="/in-flight-seagull.png" alt="" className="w-6 h-6 object-contain" />
551
+ <span>In Flight</span>
552
+ </div>
553
+ <div className="space-y-3">
554
+ {inFlight.map((item) => (
555
+ <DraggableCard key={item.id} item={item}>
556
+ <KanbanCard
557
+ item={item}
558
+ epicTitle={item.epicTitle}
559
+ showEpic={true}
560
+ isInFlight={true}
561
+ onTitleSave={onTitleSave}
562
+ onStatusChange={handleStatusChangeWithAnimation}
563
+ onReject={onReject}
564
+ onRestart={onRestart}
565
+ onTriggerClaude={onTriggerClaude}
566
+ hasActiveSession={activeSessionIds?.has(String(item.id))}
567
+ onOpenSession={onOpenSession}
568
+ onCloseSession={onCloseSession}
569
+ usageAllowed={usageAllowed}
570
+ isCompletingAnimation={animatingItemId === item.id}
571
+ onAnimationComplete={handleAnimationComplete}
572
+ />
573
+ </DraggableCard>
574
+ ))}
575
+ </div>
1134
576
  </div>
1135
- <div className="space-y-2">
1136
- {inFlight.map((item) => (
1137
- <DraggableCard key={item.id} item={item}>
1138
- <KanbanCard
1139
- item={item}
1140
- epicTitle={item.epicTitle}
1141
- showEpic={true}
1142
- isInFlight={true}
1143
- onTitleSave={onTitleSave}
1144
- onStatusChange={handleStatusChangeWithAnimation}
1145
- onReject={onReject}
1146
- onTriggerClaude={onTriggerClaude}
1147
- hasActiveSession={activeSessions?.has(String(item.id))}
1148
- onOpenSession={onOpenSession}
1149
- usageAllowed={usageAllowed}
1150
- isCompletingAnimation={animatingItemId === item.id}
1151
- onAnimationComplete={handleAnimationComplete}
1152
- />
1153
- </DraggableCard>
1154
- ))}
577
+ ) : (
578
+ <div className="flex items-center gap-2 text-base font-medium text-zinc-400 dark:text-zinc-500 py-3">
579
+ <img src="/in-flight-seagull.png" alt="" className="w-6 h-6 object-contain opacity-50" />
580
+ <span>Drop here to start work</span>
1155
581
  </div>
1156
- </div>
1157
- ) : (
1158
- <div className="flex items-center gap-1.5 text-xs font-medium text-zinc-400 dark:text-zinc-500 py-2">
1159
- <span>🔥</span>
1160
- <span>Drop here to start work</span>
1161
- </div>
1162
- )}
1163
- </DropZone>
582
+ )}
583
+ </DropZone>
1164
584
 
1165
- {/* Divider if both sections have content */}
1166
- {(inFlight.length > 0 || backlog.size > 0) && (
1167
- <hr className="border-zinc-300 dark:border-zinc-700 my-4" />
1168
- )}
585
+ {/* Divider if both sections have content */}
586
+ {(inFlight.length > 0 || backlog.size > 0) && (
587
+ <hr className="border-zinc-300 dark:border-zinc-700 my-6" />
588
+ )}
589
+ </div>
1169
590
 
1170
- {/* Backlog Section - Drop Zone with Reordering */}
591
+ {/* Virtualized Backlog Section - Drop Zone with Reordering */}
1171
592
  <BacklogDropZoneWrapper
1172
593
  backlog={backlog}
1173
594
  onStatusChange={handleStatusChangeWithAnimation}
1174
595
  onOrderChange={onOrderChange}
1175
596
  >
1176
- <div>
1177
- {/* Grouped Backlog Items */}
1178
- {Array.from(backlog.entries()).map(([key, group]) => (
1179
- <EpicGroup
1180
- key={key}
1181
- epicId={group.epicId}
1182
- epicTitle={group.epicTitle}
1183
- items={group.items}
1184
- isInFlight={group.epicId ? inFlightEpicIds.has(group.epicId) : false}
1185
- onTitleSave={onTitleSave}
1186
- onStatusChange={handleStatusChangeWithAnimation}
1187
- onReject={onReject}
1188
- onEpicAssign={onEpicAssign}
1189
- onOrderChange={onOrderChange}
1190
- onTriggerClaude={onTriggerClaude}
1191
- activeSessions={activeSessions}
1192
- onOpenSession={onOpenSession}
1193
- onError={onError}
1194
- usageAllowed={usageAllowed}
1195
- animatingItemId={animatingItemId}
1196
- onAnimationComplete={handleAnimationComplete}
1197
- isBlank={isBlank}
1198
- />
1199
- ))}
1200
-
1201
- {backlog.size === 0 && (
1202
- <p className="text-sm text-zinc-500 text-center py-4">Drop items here for backlog</p>
1203
- )}
1204
- </div>
597
+ <VirtualizedEpicList
598
+ entries={visibleBacklog}
599
+ scrollRef={backlogScrollRef}
600
+ scrollMargin={backlogScrollMargin}
601
+ hasMore={hasMoreBacklog}
602
+ onShowMore={() => setShowAllBacklog(true)}
603
+ emptyMessage={backlog.size === 0 ? 'Drop items here for backlog' : undefined}
604
+ inFlightByEpic={inFlightByEpic}
605
+ onTitleSave={onTitleSave}
606
+ onStatusChange={handleStatusChangeWithAnimation}
607
+ onReject={onReject}
608
+ onRestart={onRestart}
609
+ onEpicAssign={onEpicAssign}
610
+ onOrderChange={onOrderChange}
611
+ onTriggerClaude={onTriggerClaude}
612
+ activeSessionIds={activeSessionIds}
613
+ onOpenSession={onOpenSession}
614
+ onCloseSession={onCloseSession}
615
+ onError={onError}
616
+ usageAllowed={usageAllowed}
617
+ animatingItemId={animatingItemId}
618
+ onAnimationComplete={handleAnimationComplete}
619
+ />
1205
620
  </BacklogDropZoneWrapper>
1206
621
 
1207
622
  {backlogCount === 0 && inFlight.length === 0 && (
1208
- <p className="text-sm text-zinc-500 text-center py-4">No items in backlog</p>
623
+ <p className="text-base text-zinc-500 text-center py-4">No items in backlog</p>
1209
624
  )}
1210
625
  </KanbanColumn>
1211
626
 
1212
627
  {/* Done Column */}
1213
- <KanbanColumn title="Done" count={doneCount}>
628
+ <KanbanColumn title="Done" count={doneCount} scrollRef={doneScrollRef}>
1214
629
  <DropZone
1215
630
  targetStatus="done"
1216
631
  onDrop={async (itemId, newStatus) => {
1217
632
  await handleStatusChangeWithAnimation(itemId, newStatus);
1218
633
  }}
1219
- className="rounded-lg p-2 -m-2 min-h-[100px]"
1220
- highlightClassName="ring-2 ring-green-400 bg-green-100/50 dark:bg-green-900/30"
634
+ className="rounded-lg p-3 -m-3 min-h-[100px]"
635
+ highlightClassName="ring-2 ring-zinc-400 bg-zinc-100/50 dark:bg-zinc-800/50"
1221
636
  data-testid="done-drop-zone"
1222
637
  >
1223
- {Array.from(done.entries()).map(([key, group]) => (
1224
- <EpicGroup
1225
- key={key}
1226
- epicId={group.epicId}
1227
- epicTitle={group.epicTitle}
1228
- items={group.items}
1229
- isDraggable={true}
1230
- onTitleSave={onTitleSave}
1231
- onStatusChange={handleStatusChangeWithAnimation}
1232
- onReject={onReject}
1233
- onEpicAssign={onEpicAssign}
1234
- activeSessions={activeSessions}
1235
- onOpenSession={onOpenSession}
1236
- onError={onError}
1237
- usageAllowed={usageAllowed}
1238
- />
1239
- ))}
1240
-
1241
- {doneCount === 0 && (
1242
- <p className="text-sm text-zinc-500 text-center py-4">Drop here to mark complete</p>
1243
- )}
638
+ <VirtualizedEpicList
639
+ entries={visibleDone}
640
+ scrollRef={doneScrollRef}
641
+ hasMore={hasMoreDone}
642
+ onShowMore={() => setShowAllDone(true)}
643
+ emptyMessage={doneCount === 0 ? 'Drop here to mark complete' : undefined}
644
+ isDraggable={true}
645
+ onTitleSave={onTitleSave}
646
+ onStatusChange={handleStatusChangeWithAnimation}
647
+ onReject={onReject}
648
+ activeSessionIds={activeSessionIds}
649
+ onOpenSession={onOpenSession}
650
+ onCloseSession={onCloseSession}
651
+ onError={onError}
652
+ usageAllowed={usageAllowed}
653
+ />
1244
654
  </DropZone>
1245
655
  </KanbanColumn>
1246
656
  </div>
1247
657
  </DragProvider>
1248
658
  );
1249
- }
659
+ });