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,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
4
3
  import {
@@ -17,6 +16,7 @@ import {
17
16
  } from '@dnd-kit/core';
18
17
  import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
19
18
  import type { WorkItem } from '@/lib/db';
19
+ import { shadow } from '@/lib/shadows';
20
20
 
21
21
  type DropHandler = (itemId: number, newStatus: string) => Promise<void>;
22
22
  type ReorderHandler = (itemId: number, pointerY: number) => Promise<void>;
@@ -53,6 +53,8 @@ interface DragContextType {
53
53
  registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
54
54
  unregisterEpicDropZone: (id: string) => void;
55
55
  getCardPositions: () => CardPosition[];
56
+ /** Ref to current pointer position — read by EpicGroups for insertion preview without per-group listeners */
57
+ pointerPositionRef: React.RefObject<{ x: number; y: number }>;
56
58
  }
57
59
 
58
60
  const DragContext = createContext<DragContextType>({
@@ -66,6 +68,7 @@ const DragContext = createContext<DragContextType>({
66
68
  registerEpicDropZone: () => {},
67
69
  unregisterEpicDropZone: () => {},
68
70
  getCardPositions: () => [],
71
+ pointerPositionRef: { current: { x: 0, y: 0 } },
69
72
  });
70
73
 
71
74
  interface DragProviderProps {
@@ -114,6 +117,13 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
114
117
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
115
118
  const draggedItemRef = useRef<WorkItem | null>(null);
116
119
  const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
120
+ // Generation counter — prevents stale async handleDragEnd finally blocks
121
+ // from clearing state that belongs to a newer drag operation.
122
+ const dragGenRef = useRef(0);
123
+
124
+ // Ref mirrors for change detection — prevents re-renders when zone hasn't changed
125
+ const activeDropZoneRef = useRef<string | null>(null);
126
+ const activeEpicZoneRef = useRef<string | null>(null);
117
127
 
118
128
  const sensors = useSensors(
119
129
  useSensor(PointerSensor, {
@@ -147,9 +157,18 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
147
157
  epicDropZonesRef.current.delete(id);
148
158
  }, []);
149
159
 
150
- // Read fresh card positions from DOM - avoids stale cached positions
151
- // that cause jank when cards shift during drag (collapsed dragged card, placeholders)
160
+ // Read fresh card positions from DOM cached for 150ms so multiple
161
+ // EpicGroups reading positions across renders reuse the same measurements.
162
+ const positionCacheRef = useRef<{ frame: number; positions: CardPosition[] } | null>(null);
152
163
  const getCardPositions = useCallback((): CardPosition[] => {
164
+ const frame = performance.now();
165
+ // Reuse cached positions within 150ms — card positions don't change
166
+ // meaningfully during a drag gesture. Matches EpicGroup's rAF throttle
167
+ // interval so querySelectorAll + getBoundingClientRect only runs once
168
+ // per visible update, avoiding layout thrashing on slower hardware.
169
+ if (positionCacheRef.current && frame - positionCacheRef.current.frame < 150) {
170
+ return positionCacheRef.current.positions;
171
+ }
153
172
  const positions: CardPosition[] = [];
154
173
  const elements = document.querySelectorAll<HTMLElement>('[data-item-id]');
155
174
  elements.forEach((el) => {
@@ -158,6 +177,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
158
177
  positions.push({ id, rect: el.getBoundingClientRect() });
159
178
  }
160
179
  });
180
+ positionCacheRef.current = { frame, positions };
161
181
  return positions;
162
182
  }, []);
163
183
 
@@ -169,6 +189,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
169
189
  const handleDragStart = useCallback((event: DragStartEvent) => {
170
190
  const item = event.active.data.current?.item as WorkItem | undefined;
171
191
  if (item) {
192
+ dragGenRef.current += 1;
172
193
  setDraggedItem(item);
173
194
  draggedItemRef.current = item;
174
195
  }
@@ -203,44 +224,53 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
203
224
  }
204
225
 
205
226
  const overId = String(over.id);
227
+ let newDropZone: string | null = null;
228
+ let newEpicZone: string | null = null;
229
+ const px = pointerPositionRef.current.x;
230
+ const py = pointerPositionRef.current.y;
206
231
 
207
- // Check if it's an epic zone
208
232
  if (overId.startsWith('epic-')) {
209
- setActiveEpicZone(overId);
210
- // Also check if there's an underlying status zone
211
- let foundStatusZone: string | null = null;
212
- dropZonesRef.current.forEach((info, id) => {
233
+ newEpicZone = overId;
234
+ // Check which nested status zone the pointer is in (live rects)
235
+ for (const [id, info] of dropZonesRef.current) {
213
236
  const rect = info.element.getBoundingClientRect();
214
- const x = pointerPositionRef.current.x;
215
- const y = pointerPositionRef.current.y;
216
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
217
- foundStatusZone = id;
237
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
238
+ newDropZone = id;
239
+ break;
218
240
  }
219
- });
220
- setActiveDropZone(foundStatusZone);
241
+ }
221
242
  } else if (dropZonesRef.current.has(overId)) {
222
- setActiveDropZone(overId);
223
- // Epic zones are nested inside status zones, so collision detection
224
- // frequently returns the status zone instead. Check if pointer is
225
- // also within an epic zone (mirror of status zone check above).
226
- let foundEpicZone: string | null = null;
227
- epicDropZonesRef.current.forEach((info, id) => {
243
+ newDropZone = overId;
244
+ // Check which nested epic zone the pointer is in (live rects)
245
+ for (const [id, info] of epicDropZonesRef.current) {
228
246
  const rect = info.element.getBoundingClientRect();
229
- const x = pointerPositionRef.current.x;
230
- const y = pointerPositionRef.current.y;
231
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
232
- foundEpicZone = id;
247
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
248
+ newEpicZone = id;
249
+ break;
233
250
  }
234
- });
235
- setActiveEpicZone(foundEpicZone);
236
- } else {
237
- setActiveDropZone(null);
238
- setActiveEpicZone(null);
251
+ }
252
+ }
253
+
254
+ // Only update state when zone actually changed — prevents redundant re-renders
255
+ // when pointer stays within the same zone across collision events.
256
+ if (newDropZone !== activeDropZoneRef.current) {
257
+ activeDropZoneRef.current = newDropZone;
258
+ setActiveDropZone(newDropZone);
259
+ }
260
+ if (newEpicZone !== activeEpicZoneRef.current) {
261
+ activeEpicZoneRef.current = newEpicZone;
262
+ setActiveEpicZone(newEpicZone);
239
263
  }
240
264
  }, []);
241
265
 
242
266
  const handleDragEnd = useCallback(async (event: DragEndEvent) => {
243
267
  const { over, activatorEvent, delta } = event;
268
+ // Snapshot the current drag generation so the finally block can detect
269
+ // whether a new drag started while we were awaiting the drop handler.
270
+ const endGen = dragGenRef.current;
271
+
272
+ // Clear position cache so drop handlers get fresh DOM measurements
273
+ positionCacheRef.current = null;
244
274
 
245
275
  // Get final pointer position (both x and y needed for epic zone bounds check)
246
276
  const pointerEvent = activatorEvent as PointerEvent;
@@ -268,44 +298,47 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
268
298
  const overId = over ? String(over.id) : null;
269
299
  const isEpicZoneDrop = overId?.startsWith('epic-') ?? false;
270
300
 
271
- // Check for epic zone drop first (higher precedence)
272
- if (isEpicZoneDrop && overId) {
273
- const epicZoneInfo = epicDropZonesRef.current.get(overId);
274
- if (epicZoneInfo) {
275
- // Same epic - reorder within epic
276
- if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
277
- await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
278
- } else if (currentEpicId !== epicZoneInfo.epicId) {
279
- // Different epic - assign to new epic
280
- await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
281
- }
282
- }
283
- } else if (overId) {
284
- // Collision detection returned a status zone, but epic zones are nested
285
- // inside status zones. Check if pointer is actually within an epic zone.
286
- let resolvedEpicZone: string | null = null;
301
+ // Helper: resolve drop target from pointer position against live DOM rects.
302
+ // Used both when dnd-kit returns a status zone (epic zones are nested) and
303
+ // as a fallback when collision detection misses (over === null). This
304
+ // prevents dropped reorders from being silently swallowed when the pointer
305
+ // is near zone boundaries especially common when dragging upward.
306
+ const resolveFromPointer = (): { epicZone: string | null; dropZone: string | null } => {
307
+ const px = pointerPositionRef.current.x;
308
+ const py = pointerPositionRef.current.y;
309
+ let epicZone: string | null = null;
310
+ let dropZone: string | null = null;
287
311
  epicDropZonesRef.current.forEach((info, id) => {
288
312
  const rect = info.element.getBoundingClientRect();
289
- const x = pointerPositionRef.current.x;
290
- const y = pointerPositionRef.current.y;
291
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
292
- resolvedEpicZone = id;
313
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
314
+ epicZone = id;
293
315
  }
294
316
  });
317
+ for (const [id, info] of dropZonesRef.current) {
318
+ const rect = info.element.getBoundingClientRect();
319
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
320
+ dropZone = id;
321
+ break;
322
+ }
323
+ }
324
+ return { epicZone, dropZone };
325
+ };
295
326
 
296
- if (resolvedEpicZone) {
297
- // Pointer is within an epic zone - route to epic handler
298
- const epicZoneInfo = epicDropZonesRef.current.get(resolvedEpicZone);
327
+ // Helper: route a resolved drop to the appropriate handler
328
+ const routeDrop = async (epicZone: string | null, dropZone: string | null) => {
329
+ if (epicZone) {
330
+ const epicZoneInfo = epicDropZonesRef.current.get(epicZone);
299
331
  if (epicZoneInfo) {
300
332
  if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
301
333
  await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
302
334
  } else if (currentEpicId !== epicZoneInfo.epicId) {
303
335
  await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
304
336
  }
337
+ return;
305
338
  }
306
- } else {
307
- // Truly a status zone drop
308
- const zoneInfo = dropZonesRef.current.get(overId);
339
+ }
340
+ if (dropZone) {
341
+ const zoneInfo = dropZonesRef.current.get(dropZone);
309
342
  if (zoneInfo) {
310
343
  if (item.status !== zoneInfo.targetStatus) {
311
344
  await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
@@ -314,18 +347,50 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
314
347
  }
315
348
  }
316
349
  }
350
+ };
351
+
352
+ // Check for epic zone drop first (higher precedence)
353
+ if (isEpicZoneDrop && overId) {
354
+ const epicZoneInfo = epicDropZonesRef.current.get(overId);
355
+ if (epicZoneInfo) {
356
+ // Same epic - reorder within epic
357
+ if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
358
+ await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
359
+ } else if (currentEpicId !== epicZoneInfo.epicId) {
360
+ // Different epic - assign to new epic
361
+ await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
362
+ }
363
+ }
364
+ } else if (overId) {
365
+ // Collision detection returned a status zone, but epic zones are nested
366
+ // inside status zones. Check pointer against live rects to find the real target.
367
+ const { epicZone, dropZone } = resolveFromPointer();
368
+ await routeDrop(epicZone, dropZone || overId);
369
+ } else {
370
+ // Collision detection missed (over === null) — common when pointer is
371
+ // near zone boundaries during upward drags. Fall back to pointer
372
+ // position against live DOM rects instead of silently dropping the op.
373
+ const { epicZone, dropZone } = resolveFromPointer();
374
+ if (epicZone || dropZone) {
375
+ await routeDrop(epicZone, dropZone);
376
+ }
317
377
  }
318
- // over: null means collision detection missed - treat as no-op.
319
- // Same gap issue we handle in handleDragOver. Don't remove from epic
320
- // just because the collision detection had a gap at the moment of drop.
321
378
  } catch (error) {
322
379
  const errorMessage = error instanceof Error ? error.message : 'Failed to complete drop operation';
323
380
  onError?.(errorMessage);
324
381
  } finally {
325
- setDraggedItem(null);
326
- draggedItemRef.current = null;
327
- setActiveDropZone(null);
328
- setActiveEpicZone(null);
382
+ // Only clean up if no new drag has started while we were awaiting.
383
+ // A newer handleDragStart bumps dragGenRef, so if they differ,
384
+ // this finally belongs to a stale drag — skip cleanup.
385
+ if (dragGenRef.current === endGen) {
386
+ setDraggedItem(null);
387
+ draggedItemRef.current = null;
388
+ setActiveDropZone(null);
389
+ setActiveEpicZone(null);
390
+ activeDropZoneRef.current = null;
391
+ activeEpicZoneRef.current = null;
392
+ positionCacheRef.current = null;
393
+ }
329
394
  }
330
395
  }, [onRemoveFromEpic, onError]);
331
396
 
@@ -334,6 +399,9 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
334
399
  draggedItemRef.current = null;
335
400
  setActiveDropZone(null);
336
401
  setActiveEpicZone(null);
402
+ activeDropZoneRef.current = null;
403
+ activeEpicZoneRef.current = null;
404
+ positionCacheRef.current = null;
337
405
  }, []);
338
406
 
339
407
  // Cancel drag on Escape key
@@ -363,6 +431,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
363
431
  registerEpicDropZone,
364
432
  unregisterEpicDropZone,
365
433
  getCardPositions,
434
+ pointerPositionRef,
366
435
  }}
367
436
  >
368
437
  <DndContext
@@ -388,9 +457,11 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
388
457
  <div
389
458
  style={{
390
459
  transform: 'scale(1.02)',
391
- boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
460
+ boxShadow: shadow.overlay,
392
461
  borderRadius: 8,
393
462
  overflow: 'hidden',
463
+ WebkitBackfaceVisibility: 'hidden',
464
+ backfaceVisibility: 'hidden',
394
465
  }}
395
466
  >
396
467
  {renderDragOverlay(draggedItem)}
@@ -1,6 +1,5 @@
1
- 'use client';
2
1
 
3
- import { useRef, useEffect } from 'react';
2
+ import { memo, useRef, useEffect } from 'react';
4
3
  import { useDraggable } from '@dnd-kit/core';
5
4
  import type { WorkItem } from '@/lib/db';
6
5
  import { useDragContext } from './DragContext';
@@ -11,7 +10,7 @@ interface DraggableCardProps {
11
10
  disabled?: boolean;
12
11
  }
13
12
 
14
- export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
13
+ export const DraggableCard = memo(function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
15
14
  const { draggedItem, setDraggedItem } = useDragContext();
16
15
  const prevDisabledRef = useRef(disabled);
17
16
 
@@ -46,7 +45,6 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
46
45
  const style = {
47
46
  opacity: isDragging ? 0.2 : 1,
48
47
  transition: 'opacity 150ms ease',
49
- touchAction: 'none' as const,
50
48
  };
51
49
 
52
50
  return (
@@ -62,4 +60,4 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
62
60
  {children}
63
61
  </div>
64
62
  );
65
- }
63
+ });
@@ -1,6 +1,5 @@
1
- 'use client';
2
1
 
3
- import { useRef, useEffect, useId } from 'react';
2
+ import { memo, useRef, useEffect, useId } from 'react';
4
3
  import { useDroppable } from '@dnd-kit/core';
5
4
  import { useDragContext } from './DragContext';
6
5
 
@@ -16,15 +15,15 @@ interface DropZoneProps {
16
15
  'data-testid'?: string;
17
16
  }
18
17
 
19
- export function DropZone({
18
+ export const DropZone = memo(function DropZone({
20
19
  targetStatus,
21
20
  onDrop,
22
21
  onReorder,
23
22
  allowReorder = false,
24
23
  children,
25
24
  className = '',
26
- highlightClassName = 'ring-2 ring-blue-400 bg-blue-50/50 dark:bg-blue-900/20',
27
- reorderHighlightClassName = 'ring-2 ring-amber-400 bg-amber-50/50 dark:bg-amber-900/20',
25
+ highlightClassName = 'ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20',
26
+ reorderHighlightClassName = 'ring-2 ring-[#E3D985] bg-[#F9F7E8]/50 dark:bg-[#E3D985]/20',
28
27
  'data-testid': testId,
29
28
  }: DropZoneProps) {
30
29
  const { isDragging, draggedItem, activeDropZone, activeEpicZone, registerDropZone, unregisterDropZone } = useDragContext();
@@ -72,7 +71,7 @@ export function DropZone({
72
71
  return (
73
72
  <div
74
73
  ref={setRefs}
75
- className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-all duration-200`}
74
+ className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-[color,background-color] duration-200 ease-out`}
76
75
  data-testid={testId}
77
76
  data-drop-zone={targetStatus}
78
77
  data-is-active={isActive}
@@ -81,4 +80,4 @@ export function DropZone({
81
80
  {children}
82
81
  </div>
83
82
  );
84
- }
83
+ });
@@ -1,15 +1,13 @@
1
- 'use client';
2
-
3
1
  import { useState, useRef, useEffect, useCallback } from 'react';
4
- import { useRouter } from 'next/navigation';
2
+ import { dataBridge } from '@/lib/data-bridge';
5
3
 
6
4
  interface EditableDetailDescriptionProps {
7
5
  description: string | null;
8
6
  itemId: number;
7
+ onDescriptionChange?: (newDescription: string) => void;
9
8
  }
10
9
 
11
- export function EditableDetailDescription({ description, itemId }: EditableDetailDescriptionProps) {
12
- const router = useRouter();
10
+ export function EditableDetailDescription({ description, itemId, onDescriptionChange }: EditableDetailDescriptionProps) {
13
11
  const [isEditing, setIsEditing] = useState(false);
14
12
  const [editValue, setEditValue] = useState(description ?? '');
15
13
  const [error, setError] = useState<string | null>(null);
@@ -30,12 +28,8 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
30
28
  const newDescription = editValue.trim();
31
29
  if (newDescription !== (description ?? '')) {
32
30
  try {
33
- await fetch(`/api/work/${itemId}/description`, {
34
- method: 'PATCH',
35
- headers: { 'Content-Type': 'application/json' },
36
- body: JSON.stringify({ description: newDescription }),
37
- });
38
- router.refresh();
31
+ await dataBridge.updateDescription(itemId, newDescription);
32
+ onDescriptionChange?.(newDescription);
39
33
  } catch (err) {
40
34
  const message = err instanceof Error ? err.message : 'Unknown error';
41
35
  setError(`Failed to save: ${message}`);
@@ -43,7 +37,7 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
43
37
  }
44
38
  }
45
39
  setIsEditing(false);
46
- }, [editValue, description, itemId, router]);
40
+ }, [editValue, description, itemId]);
47
41
 
48
42
  const handleClick = () => {
49
43
  setEditValue(description ?? '');
@@ -81,7 +75,7 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
81
75
  className={`w-full text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-700 border rounded px-2 py-1.5 focus:outline-none focus:ring-2 resize-y ${
82
76
  error
83
77
  ? 'border-red-500 focus:ring-red-500'
84
- : 'border-zinc-300 dark:border-zinc-600 focus:ring-blue-500'
78
+ : 'border-zinc-300 dark:border-zinc-600 focus:ring-[#819D9F]'
85
79
  }`}
86
80
  />
87
81
  {error && (
@@ -1,25 +1,18 @@
1
- 'use client';
2
-
3
1
  import { useCallback } from 'react';
4
- import { useRouter } from 'next/navigation';
5
2
  import { EditableTitle } from './EditableTitle';
3
+ import { dataBridge } from '@/lib/data-bridge';
6
4
 
7
5
  interface EditableDetailTitleProps {
8
6
  title: string;
9
7
  itemId: number;
8
+ onTitleChange?: (newTitle: string) => void;
10
9
  }
11
10
 
12
- export function EditableDetailTitle({ title, itemId }: EditableDetailTitleProps) {
13
- const router = useRouter();
14
-
11
+ export function EditableDetailTitle({ title, itemId, onTitleChange }: EditableDetailTitleProps) {
15
12
  const handleSave = useCallback(async (id: number, newTitle: string) => {
16
- await fetch(`/api/work/${id}/title`, {
17
- method: 'PATCH',
18
- headers: { 'Content-Type': 'application/json' },
19
- body: JSON.stringify({ title: newTitle }),
20
- });
21
- router.refresh();
22
- }, [router]);
13
+ await dataBridge.updateTitle(id, newTitle);
14
+ onTitleChange?.(newTitle);
15
+ }, [onTitleChange]);
23
16
 
24
17
  return <EditableTitle title={title} itemId={itemId} onSave={handleSave} variant="page" />;
25
18
  }
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { useState, useRef, useEffect } from 'react';
4
3
 
@@ -7,12 +6,15 @@ interface EditableTitleProps {
7
6
  itemId: number;
8
7
  onSave: (id: number, newTitle: string) => Promise<void>;
9
8
  variant?: 'card' | 'page';
9
+ isEditing?: boolean;
10
+ onEditingChange?: (editing: boolean) => void;
11
+ clickToEdit?: boolean;
10
12
  }
11
13
 
12
14
  const variantStyles = {
13
15
  card: {
14
- display: 'text-sm text-zinc-900 dark:text-zinc-100 leading-snug cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5',
15
- input: 'text-sm text-zinc-900 dark:text-zinc-100 leading-snug w-full bg-white dark:bg-zinc-700 border rounded px-1 py-0.5 focus:outline-none focus:ring-2',
16
+ display: 'text-base font-medium text-zinc-900 dark:text-zinc-100 leading-snug cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5',
17
+ input: 'text-base font-medium text-zinc-900 dark:text-zinc-100 leading-snug w-full bg-white dark:bg-zinc-700 border rounded px-1 py-0.5 focus:outline-none focus:ring-2',
16
18
  },
17
19
  page: {
18
20
  display: 'text-2xl font-bold text-zinc-900 dark:text-zinc-100 cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5',
@@ -20,8 +22,13 @@ const variantStyles = {
20
22
  },
21
23
  };
22
24
 
23
- export function EditableTitle({ title, itemId, onSave, variant = 'card' }: EditableTitleProps) {
24
- const [isEditing, setIsEditing] = useState(false);
25
+ export function EditableTitle({ title, itemId, onSave, variant = 'card', isEditing: externalIsEditing, onEditingChange, clickToEdit = true }: EditableTitleProps) {
26
+ const [internalIsEditing, setInternalIsEditing] = useState(false);
27
+ const isEditing = externalIsEditing ?? internalIsEditing;
28
+ const setIsEditing = (value: boolean) => {
29
+ setInternalIsEditing(value);
30
+ onEditingChange?.(value);
31
+ };
25
32
  const [editValue, setEditValue] = useState(title);
26
33
  const [error, setError] = useState<string | null>(null);
27
34
  const inputRef = useRef<HTMLInputElement>(null);
@@ -33,7 +40,15 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
33
40
  }
34
41
  }, [isEditing]);
35
42
 
43
+ useEffect(() => {
44
+ if (isEditing) {
45
+ setEditValue(title);
46
+ setError(null);
47
+ }
48
+ }, [isEditing, title]);
49
+
36
50
  const handleClick = (e: React.MouseEvent) => {
51
+ if (!clickToEdit) return;
37
52
  e.preventDefault();
38
53
  e.stopPropagation();
39
54
  setEditValue(title);
@@ -93,7 +108,7 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
93
108
  className={`${variantStyles[variant].input} ${
94
109
  error
95
110
  ? 'border-red-500 focus:ring-red-500'
96
- : 'border-zinc-300 dark:border-zinc-600 focus:ring-blue-500'
111
+ : 'border-zinc-300 dark:border-zinc-600 focus:ring-[#819D9F]'
97
112
  }`}
98
113
  />
99
114
  {error && (
@@ -103,10 +118,14 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
103
118
  );
104
119
  }
105
120
 
121
+ const displayClass = clickToEdit
122
+ ? variantStyles[variant].display
123
+ : variantStyles[variant].display.replace('cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5', '');
124
+
106
125
  return (
107
126
  <p
108
127
  onClick={handleClick}
109
- className={variantStyles[variant].display}
128
+ className={displayClass}
110
129
  >
111
130
  {title}
112
131
  </p>
@@ -0,0 +1,66 @@
1
+
2
+ import { useState, useEffect } from 'react';
3
+
4
+ // Persist timer start timestamps outside the component so they survive remounts (e.g., tab switches).
5
+ // Capped at MAX_ENTRIES to prevent unbounded growth — evicts oldest entry on overflow.
6
+ const MAX_TIMER_ENTRIES = 50;
7
+ const timerStartTimes = new Map<string, number>();
8
+
9
+ export function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean; timerKey: string }) {
10
+ const [elapsed, setElapsed] = useState(() => {
11
+ const existing = timerStartTimes.get(timerKey);
12
+ return existing ? Math.floor((Date.now() - existing) / 1000) : 0;
13
+ });
14
+
15
+ useEffect(() => {
16
+ if (isStreaming) {
17
+ // Start or continue timing — reuse persisted start time if available
18
+ if (!timerStartTimes.has(timerKey)) {
19
+ timerStartTimes.set(timerKey, Date.now());
20
+ // Evict oldest entry if Map exceeds cap
21
+ if (timerStartTimes.size > MAX_TIMER_ENTRIES) {
22
+ const oldest = timerStartTimes.keys().next().value;
23
+ if (oldest != null) timerStartTimes.delete(oldest);
24
+ }
25
+ }
26
+ // Immediately sync elapsed value for this timerKey (prevents stale value on tab switch)
27
+ const startTime = timerStartTimes.get(timerKey);
28
+ if (startTime != null) {
29
+ setElapsed(Math.floor((Date.now() - startTime) / 1000));
30
+ }
31
+ const interval = setInterval(() => {
32
+ const startTime = timerStartTimes.get(timerKey);
33
+ if (startTime != null) {
34
+ setElapsed(Math.floor((Date.now() - startTime) / 1000));
35
+ }
36
+ }, 1000);
37
+ return () => {
38
+ clearInterval(interval);
39
+ // Do NOT delete timerStartTimes here — component may unmount due to
40
+ // navigation while session is still streaming. The start time must
41
+ // persist so the timer resumes correctly when the user navigates back.
42
+ // Cleanup happens in the else branch when streaming actually stops.
43
+ };
44
+ } else {
45
+ // Reset when not streaming
46
+ timerStartTimes.delete(timerKey);
47
+ setElapsed(0);
48
+ }
49
+ }, [isStreaming, timerKey]);
50
+
51
+ if (!isStreaming) return null;
52
+
53
+ const minutes = Math.floor(elapsed / 60);
54
+ const seconds = elapsed % 60;
55
+ const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
56
+
57
+ return (
58
+ <div
59
+ className="text-xs text-zinc-500"
60
+ style={{ width: '3rem', flexShrink: 0, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
61
+ data-testid="elapsed-timer"
62
+ >
63
+ {display}
64
+ </div>
65
+ );
66
+ }