jettypod 4.4.120 → 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 (208) hide show
  1. package/.env +2 -1
  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 +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  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 +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  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 +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,9 +1,8 @@
1
- 'use client';
2
1
 
3
- import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
2
+ import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react';
3
+ import { useVirtualizer } from '@tanstack/react-virtual';
4
4
  import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
5
5
  import type { UndoAction } from '@/lib/undoStack';
6
- import type { Session } from '../contexts/ClaudeSessionContext';
7
6
  import { DragProvider } from './DragContext';
8
7
  import { DraggableCard } from './DraggableCard';
9
8
  import { DropZone } from './DropZone';
@@ -42,9 +41,10 @@ interface KanbanColumnProps {
42
41
  count: number;
43
42
  onAdd?: () => void;
44
43
  addDisabled?: boolean;
44
+ scrollRef?: React.RefObject<HTMLDivElement | null>;
45
45
  }
46
46
 
47
- function KanbanColumn({ title, children, count, onAdd, addDisabled }: KanbanColumnProps) {
47
+ function KanbanColumn({ title, children, count, onAdd, addDisabled, scrollRef }: KanbanColumnProps) {
48
48
  const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
49
49
  return (
50
50
  <div className="flex-1 max-w-[600px] flex flex-col min-h-0" data-testid={testId}>
@@ -78,7 +78,7 @@ function KanbanColumn({ title, children, count, onAdd, addDisabled }: KanbanColu
78
78
  </span>
79
79
  </div>
80
80
  </div>
81
- <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' }}>
82
82
  {children}
83
83
  </div>
84
84
  </div>
@@ -145,19 +145,20 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
145
145
  const itemMap = new Map(backlogItems.map(item => [item.id, item]));
146
146
  const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
147
147
 
148
- // 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.
149
150
  let newOrder: number;
150
151
  if (visualOrder.length === 0) {
151
152
  newOrder = DISPLAY_ORDER_INCREMENT;
152
153
  } else if (insertIndex === 0) {
153
- const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id;
154
+ const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id * DISPLAY_ORDER_INCREMENT;
154
155
  newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
155
156
  } else if (insertIndex >= visualOrder.length) {
156
- 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;
157
158
  newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
158
159
  } else {
159
- const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id;
160
- 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;
161
162
  newOrder = Math.floor((before + after) / 2);
162
163
  }
163
164
 
@@ -175,8 +176,8 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
175
176
  onReorder={handleBacklogReorder}
176
177
  allowReorder={true}
177
178
  className="rounded-lg p-3 -m-3 min-h-[100px]"
178
- highlightClassName="ring-2 ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30"
179
- reorderHighlightClassName="ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30"
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"
180
181
  data-testid="backlog-drop-zone"
181
182
  >
182
183
  {children}
@@ -184,6 +185,153 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
184
185
  );
185
186
  }
186
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
+
187
335
  interface KanbanBoardProps {
188
336
  inFlight: InFlightItem[];
189
337
  backlog: Map<string, KanbanGroup>;
@@ -198,7 +346,7 @@ interface KanbanBoardProps {
198
346
  // Multi-session support
199
347
  onOpenSession?: (id: string) => void;
200
348
  onCloseSession?: (id: string) => void;
201
- activeSessions?: Map<string, Session>;
349
+ activeSessionIds?: Set<string>;
202
350
  // Undo/redo support
203
351
  onUndo?: () => Promise<UndoAction | null>;
204
352
  onRedo?: () => Promise<UndoAction | null>;
@@ -206,6 +354,8 @@ interface KanbanBoardProps {
206
354
  canRedo?: boolean;
207
355
  // Error handler for drag-drop operations
208
356
  onError?: (message: string) => void;
357
+ // Pre-built status map from data-bridge (avoids O(N) rebuild per render)
358
+ itemStatusMap?: Map<number, string>;
209
359
  // Add to backlog
210
360
  onAddToBacklog?: () => void;
211
361
  // Usage limits
@@ -215,10 +365,28 @@ interface KanbanBoardProps {
215
365
  onExternalAnimationComplete?: () => void;
216
366
  }
217
367
 
218
- export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onReject, onRestart, onOrderChange, onEpicAssign, onTriggerClaude, onOpenSession, onCloseSession, activeSessions, onUndo, onRedo, canUndo, canRedo, onError, onAddToBacklog, usageAllowed = true, externalAnimatingItemId, onExternalAnimationComplete }: 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) {
219
369
  const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
220
370
  const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
221
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
+
222
390
  // Lazy loading state for backlog and done columns
223
391
  const [showAllBacklog, setShowAllBacklog] = useState(false);
224
392
  const [showAllDone, setShowAllDone] = useState(false);
@@ -226,8 +394,14 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
226
394
  const backlogEntries = useMemo(() => Array.from(backlog.entries()), [backlog]);
227
395
  const doneEntries = useMemo(() => Array.from(done.entries()), [done]);
228
396
  const backlogLimit = Math.max(0, BACKLOG_VISIBLE_LIMIT - inFlight.length);
229
- const { visible: visibleBacklog, hasMore: hasMoreBacklog } = getVisibleEntries(backlogEntries, backlogLimit, showAllBacklog);
230
- const { visible: visibleDone, hasMore: hasMoreDone } = getVisibleEntries(doneEntries, DONE_VISIBLE_LIMIT, showAllDone);
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
+ );
231
405
 
232
406
  // Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
233
407
  useEffect(() => {
@@ -262,18 +436,21 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
262
436
  }, [onUndo, onRedo, canUndo, canRedo]);
263
437
 
264
438
  // Build a map of epic IDs to their in-flight items
265
- const inFlightByEpic = new Map<number, InFlightItem[]>();
266
- for (const item of inFlight) {
267
- const epicId = item.parent_id || item.epic_id;
268
- if (epicId) {
269
- const existing = inFlightByEpic.get(epicId);
270
- if (existing) {
271
- existing.push(item);
272
- } else {
273
- inFlightByEpic.set(epicId, [item]);
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
+ }
274
450
  }
275
451
  }
276
- }
452
+ return map;
453
+ }, [inFlight]);
277
454
 
278
455
  // Board-level animation state - tracks which item is playing the completion animation
279
456
  const [internalAnimatingItemId, setInternalAnimatingItemId] = useState<number | null>(null);
@@ -282,24 +459,8 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
282
459
  // Merge internal (UI-driven) and external (CLI/DB-driven) animation triggers
283
460
  const animatingItemId = internalAnimatingItemId ?? externalAnimatingItemId ?? null;
284
461
 
285
- // Build a map of all item IDs to their current status for checking if transitioning to done
286
- const itemStatusMap = useMemo(() => {
287
- const map = new Map<number, string>();
288
- for (const item of inFlight) {
289
- map.set(item.id, item.status);
290
- }
291
- for (const group of backlog.values()) {
292
- for (const item of group.items) {
293
- map.set(item.id, item.status);
294
- }
295
- }
296
- for (const group of done.values()) {
297
- for (const item of group.items) {
298
- map.set(item.id, item.status);
299
- }
300
- }
301
- return map;
302
- }, [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>();
303
464
 
304
465
  // Wrapper for onStatusChange that intercepts "done" transitions to play animation first
305
466
  const handleStatusChangeWithAnimation = useCallback(async (id: number, newStatus: string) => {
@@ -309,6 +470,12 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
309
470
 
310
471
  // If transitioning to done from non-done status, play animation first in backlog/in-flight
311
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
+ }
312
479
  pendingStatusChangeRef.current = { id, status: newStatus };
313
480
  setInternalAnimatingItemId(id);
314
481
  return;
@@ -334,10 +501,18 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
334
501
  }
335
502
  }, [onStatusChange, internalAnimatingItemId, externalAnimatingItemId, onExternalAnimationComplete]);
336
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
+
337
513
  // Render function for the drag overlay
338
514
  const renderDragOverlay = useCallback((item: WorkItem) => {
339
- // Find epic title if this is an in-flight item
340
- const inFlightItem = inFlight.find(i => i.id === item.id);
515
+ const inFlightItem = inFlightMap.get(item.id);
341
516
  const epicTitle = inFlightItem?.epicTitle || null;
342
517
  const isInFlightCard = inFlightItem !== undefined;
343
518
 
@@ -349,113 +524,99 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
349
524
  isInFlight={isInFlightCard}
350
525
  />
351
526
  );
352
- }, [inFlight]);
527
+ }, [inFlightMap]);
353
528
 
354
529
  return (
355
530
  <DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign} onError={onError}>
356
- <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">
357
533
  {/* Backlog Column */}
358
- <KanbanColumn title="Backlog" count={backlogCount} onAdd={onAddToBacklog} addDisabled={!usageAllowed}>
359
- {/* In Flight Section - Drop Zone */}
360
- <DropZone
361
- targetStatus="in_progress"
362
- onDrop={async (itemId, newStatus) => {
363
- await handleStatusChangeWithAnimation(itemId, newStatus);
364
- }}
365
- className="rounded-lg mb-6 p-3 -m-3"
366
- highlightClassName="ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20"
367
- data-testid="in-flight-drop-zone"
368
- >
369
- {inFlight.length > 0 ? (
370
- <div data-testid="in-flight-section" className="bg-[#e8f0f0] dark:bg-[#819D9F]/20 rounded-lg p-3 -m-1">
371
- <div className="flex items-center gap-2 text-base font-medium text-[#5a7d7f] dark:text-[#a3bfc0] mb-3">
372
- <img src="/in-flight-seagull.svg" alt="" className="w-5 h-5" />
373
- <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>
374
576
  </div>
375
- <div className="space-y-3">
376
- {inFlight.map((item) => (
377
- <DraggableCard key={item.id} item={item}>
378
- <KanbanCard
379
- item={item}
380
- epicTitle={item.epicTitle}
381
- showEpic={true}
382
- isInFlight={true}
383
- onTitleSave={onTitleSave}
384
- onStatusChange={handleStatusChangeWithAnimation}
385
- onReject={onReject}
386
- onRestart={onRestart}
387
- onTriggerClaude={onTriggerClaude}
388
- hasActiveSession={activeSessions?.has(String(item.id))}
389
- onOpenSession={onOpenSession}
390
- onCloseSession={onCloseSession}
391
- usageAllowed={usageAllowed}
392
- isCompletingAnimation={animatingItemId === item.id}
393
- onAnimationComplete={handleAnimationComplete}
394
- />
395
- </DraggableCard>
396
- ))}
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>
397
581
  </div>
398
- </div>
399
- ) : (
400
- <div className="flex items-center gap-2 text-base font-medium text-zinc-400 dark:text-zinc-500 py-3">
401
- <img src="/in-flight-seagull.svg" alt="" className="w-5 h-5 opacity-50" />
402
- <span>Drop here to start work</span>
403
- </div>
404
- )}
405
- </DropZone>
582
+ )}
583
+ </DropZone>
406
584
 
407
- {/* Divider if both sections have content */}
408
- {(inFlight.length > 0 || backlog.size > 0) && (
409
- <hr className="border-zinc-300 dark:border-zinc-700 my-6" />
410
- )}
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>
411
590
 
412
- {/* Backlog Section - Drop Zone with Reordering */}
591
+ {/* Virtualized Backlog Section - Drop Zone with Reordering */}
413
592
  <BacklogDropZoneWrapper
414
593
  backlog={backlog}
415
594
  onStatusChange={handleStatusChangeWithAnimation}
416
595
  onOrderChange={onOrderChange}
417
596
  >
418
- <div>
419
- {/* Grouped Backlog Items (lazy loaded) */}
420
- {visibleBacklog.map(([key, group]) => (
421
- <EpicGroup
422
- key={key}
423
- epicId={group.epicId}
424
- epicTitle={group.epicTitle}
425
- items={group.items}
426
- isInFlight={group.epicId ? inFlightByEpic.has(group.epicId) : false}
427
- inFlightItems={group.epicId ? inFlightByEpic.get(group.epicId) : undefined}
428
- onTitleSave={onTitleSave}
429
- onStatusChange={handleStatusChangeWithAnimation}
430
- onReject={onReject}
431
- onRestart={onRestart}
432
- onEpicAssign={onEpicAssign}
433
- onOrderChange={onOrderChange}
434
- onTriggerClaude={onTriggerClaude}
435
- activeSessions={activeSessions}
436
- onOpenSession={onOpenSession}
437
- onCloseSession={onCloseSession}
438
- onError={onError}
439
- usageAllowed={usageAllowed}
440
- animatingItemId={animatingItemId}
441
- onAnimationComplete={handleAnimationComplete}
442
- />
443
- ))}
444
-
445
- {hasMoreBacklog && (
446
- <button
447
- onClick={() => setShowAllBacklog(true)}
448
- 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"
449
- data-testid="backlog-show-more"
450
- >
451
- Show more
452
- </button>
453
- )}
454
-
455
- {backlog.size === 0 && (
456
- <p className="text-base text-zinc-500 text-center py-4">Drop items here for backlog</p>
457
- )}
458
- </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
+ />
459
620
  </BacklogDropZoneWrapper>
460
621
 
461
622
  {backlogCount === 0 && inFlight.length === 0 && (
@@ -464,7 +625,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
464
625
  </KanbanColumn>
465
626
 
466
627
  {/* Done Column */}
467
- <KanbanColumn title="Done" count={doneCount}>
628
+ <KanbanColumn title="Done" count={doneCount} scrollRef={doneScrollRef}>
468
629
  <DropZone
469
630
  targetStatus="done"
470
631
  onDrop={async (itemId, newStatus) => {
@@ -474,40 +635,25 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
474
635
  highlightClassName="ring-2 ring-zinc-400 bg-zinc-100/50 dark:bg-zinc-800/50"
475
636
  data-testid="done-drop-zone"
476
637
  >
477
- {visibleDone.map(([key, group]) => (
478
- <EpicGroup
479
- key={key}
480
- epicId={group.epicId}
481
- epicTitle={group.epicTitle}
482
- items={group.items}
483
- isDraggable={true}
484
- onTitleSave={onTitleSave}
485
- onStatusChange={handleStatusChangeWithAnimation}
486
- onReject={onReject}
487
- activeSessions={activeSessions}
488
- onOpenSession={onOpenSession}
489
- onCloseSession={onCloseSession}
490
- onError={onError}
491
- usageAllowed={usageAllowed}
492
- />
493
- ))}
494
-
495
- {hasMoreDone && (
496
- <button
497
- onClick={() => setShowAllDone(true)}
498
- 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"
499
- data-testid="done-show-more"
500
- >
501
- Show more
502
- </button>
503
- )}
504
-
505
- {doneCount === 0 && (
506
- <p className="text-base text-zinc-500 text-center py-4">Drop here to mark complete</p>
507
- )}
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
+ />
508
654
  </DropZone>
509
655
  </KanbanColumn>
510
656
  </div>
511
657
  </DragProvider>
512
658
  );
513
- }
659
+ });