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,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { useState, useRef, useCallback, useEffect, KeyboardEvent, ChangeEvent } from 'react';
4
3
  import { m, AnimatePresence } from 'framer-motion';
@@ -40,13 +39,33 @@ export function ClaudePanelInput({
40
39
  const textareaRef = useRef<HTMLTextAreaElement>(null);
41
40
  const lastEscapeTimeRef = useRef<number>(0);
42
41
 
42
+ // Per-session draft map: save/restore draft text when switching tabs
43
+ const draftsRef = useRef(new Map<string, string>());
44
+ const prevSessionRef = useRef(activeSessionId);
45
+ const messageRef = useRef(message);
46
+ messageRef.current = message;
47
+
48
+ useEffect(() => {
49
+ const prevId = prevSessionRef.current;
50
+ if (prevId && prevId !== activeSessionId) {
51
+ draftsRef.current.set(prevId, messageRef.current);
52
+ }
53
+ const restored = activeSessionId ? draftsRef.current.get(activeSessionId) ?? '' : '';
54
+ setMessage(restored);
55
+ if (textareaRef.current) {
56
+ textareaRef.current.style.height = 'auto';
57
+ }
58
+ prevSessionRef.current = activeSessionId;
59
+ }, [activeSessionId]);
60
+
43
61
  // Use external image state if provided (panel-level drag-drop), otherwise internal
44
62
  const attachedImages = externalImages ?? internalImages;
45
63
  const setAttachedImages = onImagesChange ?? setInternalImages;
46
64
 
47
- // Auto-focus textarea when active session changes (new session or tab switch)
65
+ // Auto-focus textarea on mount and when active session changes.
66
+ // The mount case handles when ClaudePanelInput replaces ReviewFooter after rejection.
48
67
  useEffect(() => {
49
- if (activeSessionId && textareaRef.current) {
68
+ if (textareaRef.current) {
50
69
  textareaRef.current.focus();
51
70
  }
52
71
  }, [activeSessionId]);
@@ -107,7 +126,7 @@ export function ClaudePanelInput({
107
126
  >
108
127
  <div
109
128
  className={`
110
- relative rounded-lg border-2 transition-[border-color,box-shadow] duration-200 ease-out
129
+ relative rounded-lg border-2 transition-[border-color] duration-200 ease-out
111
130
  ${isFocused ? 'border-[#819D9F] bg-white' : 'border-zinc-300 bg-white'}
112
131
  ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
113
132
  `}
@@ -1,7 +1,4 @@
1
- 'use client';
2
-
3
1
  import { useState, useEffect, useRef } from 'react';
4
- import Image from 'next/image';
5
2
  import { Button } from '@/components/ui/Button';
6
3
 
7
4
  type ConnectState = 'idle' | 'waiting' | 'success' | 'error';
@@ -93,12 +90,11 @@ export function ConnectClaudeScreen({ onConnect, onCheckAuth }: ConnectClaudeScr
93
90
  <div className="max-w-md w-full space-y-10">
94
91
  {/* Logo */}
95
92
  <div className="flex flex-col items-center space-y-6">
96
- <Image
93
+ <img
97
94
  src="/jettypod_wordmark.png"
98
95
  alt="JettyPod"
99
96
  width={160}
100
97
  height={40}
101
- priority
102
98
  />
103
99
  <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
104
100
  Connect Claude Code
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { useState } from 'react';
4
3
 
@@ -59,7 +58,7 @@ export function CopyableId({ id, title, type, size = 'sm' }: CopyableIdProps) {
59
58
  return (
60
59
  <button
61
60
  onClick={handleCopy}
62
- className={`flex items-center gap-1 text-zinc-400 font-mono -mx-1 rounded cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 active:scale-95 transition-[color,background-color,transform] duration-200 ease-out ${sizeClasses}`}
61
+ className={`flex items-center gap-1 text-zinc-400 font-mono -mx-1 rounded cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 active:scale-95 transition-[color,background-color] duration-200 ease-out ${sizeClasses}`}
63
62
  title={`Copy: #${id} ${title} (${type})`}
64
63
  >
65
64
  <span>#{id}</span>
@@ -1,30 +1,25 @@
1
- 'use client';
2
-
3
1
  import { useState } from 'react';
4
- import { useRouter } from 'next/navigation';
2
+ import { useNavigate } from 'react-router-dom';
5
3
  import { Button } from '@/components/ui/Button';
6
4
  import { Input } from '@/components/ui/Input';
5
+ import { dataBridge } from '@/lib/data-bridge';
7
6
 
8
7
  interface DetailReviewActionsProps {
9
8
  workItemId: number;
10
9
  }
11
10
 
12
11
  export function DetailReviewActions({ workItemId }: DetailReviewActionsProps) {
13
- const router = useRouter();
12
+ const navigate = useNavigate();
14
13
  const [showRejectInput, setShowRejectInput] = useState(false);
15
14
  const [rejectReason, setRejectReason] = useState('');
16
15
  const [isSubmitting, setIsSubmitting] = useState(false);
17
16
 
18
17
  const handleAccept = async () => {
19
18
  setIsSubmitting(true);
20
- const res = await fetch(`/api/work/${workItemId}/status`, {
21
- method: 'PATCH',
22
- headers: { 'Content-Type': 'application/json' },
23
- body: JSON.stringify({ status: 'done' }),
24
- });
25
- if (res.ok) {
26
- router.push('/');
27
- } else {
19
+ try {
20
+ await dataBridge.updateStatus(workItemId, 'done');
21
+ navigate('/');
22
+ } catch {
28
23
  setIsSubmitting(false);
29
24
  }
30
25
  };
@@ -32,14 +27,10 @@ export function DetailReviewActions({ workItemId }: DetailReviewActionsProps) {
32
27
  const handleRejectConfirm = async () => {
33
28
  if (!rejectReason.trim()) return;
34
29
  setIsSubmitting(true);
35
- const res = await fetch(`/api/work/${workItemId}/status`, {
36
- method: 'PATCH',
37
- headers: { 'Content-Type': 'application/json' },
38
- body: JSON.stringify({ status: 'in_progress', rejectionReason: rejectReason.trim() }),
39
- });
40
- if (res.ok) {
41
- router.push(`/?rejected=${workItemId}&reason=${encodeURIComponent(rejectReason.trim())}`);
42
- } else {
30
+ try {
31
+ await dataBridge.updateStatus(workItemId, 'in_progress', rejectReason.trim());
32
+ navigate(`/?rejected=${workItemId}&reason=${encodeURIComponent(rejectReason.trim())}`);
33
+ } catch {
43
34
  setIsSubmitting(false);
44
35
  }
45
36
  };
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
4
3
  import {
@@ -54,6 +53,8 @@ interface DragContextType {
54
53
  registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
55
54
  unregisterEpicDropZone: (id: string) => void;
56
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 }>;
57
58
  }
58
59
 
59
60
  const DragContext = createContext<DragContextType>({
@@ -67,6 +68,7 @@ const DragContext = createContext<DragContextType>({
67
68
  registerEpicDropZone: () => {},
68
69
  unregisterEpicDropZone: () => {},
69
70
  getCardPositions: () => [],
71
+ pointerPositionRef: { current: { x: 0, y: 0 } },
70
72
  });
71
73
 
72
74
  interface DragProviderProps {
@@ -115,6 +117,13 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
115
117
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
116
118
  const draggedItemRef = useRef<WorkItem | null>(null);
117
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);
118
127
 
119
128
  const sensors = useSensors(
120
129
  useSensor(PointerSensor, {
@@ -148,9 +157,18 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
148
157
  epicDropZonesRef.current.delete(id);
149
158
  }, []);
150
159
 
151
- // Read fresh card positions from DOM - avoids stale cached positions
152
- // 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);
153
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
+ }
154
172
  const positions: CardPosition[] = [];
155
173
  const elements = document.querySelectorAll<HTMLElement>('[data-item-id]');
156
174
  elements.forEach((el) => {
@@ -159,6 +177,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
159
177
  positions.push({ id, rect: el.getBoundingClientRect() });
160
178
  }
161
179
  });
180
+ positionCacheRef.current = { frame, positions };
162
181
  return positions;
163
182
  }, []);
164
183
 
@@ -170,6 +189,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
170
189
  const handleDragStart = useCallback((event: DragStartEvent) => {
171
190
  const item = event.active.data.current?.item as WorkItem | undefined;
172
191
  if (item) {
192
+ dragGenRef.current += 1;
173
193
  setDraggedItem(item);
174
194
  draggedItemRef.current = item;
175
195
  }
@@ -204,44 +224,53 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
204
224
  }
205
225
 
206
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;
207
231
 
208
- // Check if it's an epic zone
209
232
  if (overId.startsWith('epic-')) {
210
- setActiveEpicZone(overId);
211
- // Also check if there's an underlying status zone
212
- let foundStatusZone: string | null = null;
213
- 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) {
214
236
  const rect = info.element.getBoundingClientRect();
215
- const x = pointerPositionRef.current.x;
216
- const y = pointerPositionRef.current.y;
217
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
218
- foundStatusZone = id;
237
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
238
+ newDropZone = id;
239
+ break;
219
240
  }
220
- });
221
- setActiveDropZone(foundStatusZone);
241
+ }
222
242
  } else if (dropZonesRef.current.has(overId)) {
223
- setActiveDropZone(overId);
224
- // Epic zones are nested inside status zones, so collision detection
225
- // frequently returns the status zone instead. Check if pointer is
226
- // also within an epic zone (mirror of status zone check above).
227
- let foundEpicZone: string | null = null;
228
- 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) {
229
246
  const rect = info.element.getBoundingClientRect();
230
- const x = pointerPositionRef.current.x;
231
- const y = pointerPositionRef.current.y;
232
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
233
- foundEpicZone = id;
247
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
248
+ newEpicZone = id;
249
+ break;
234
250
  }
235
- });
236
- setActiveEpicZone(foundEpicZone);
237
- } else {
238
- setActiveDropZone(null);
239
- 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);
240
263
  }
241
264
  }, []);
242
265
 
243
266
  const handleDragEnd = useCallback(async (event: DragEndEvent) => {
244
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;
245
274
 
246
275
  // Get final pointer position (both x and y needed for epic zone bounds check)
247
276
  const pointerEvent = activatorEvent as PointerEvent;
@@ -269,44 +298,47 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
269
298
  const overId = over ? String(over.id) : null;
270
299
  const isEpicZoneDrop = overId?.startsWith('epic-') ?? false;
271
300
 
272
- // Check for epic zone drop first (higher precedence)
273
- if (isEpicZoneDrop && overId) {
274
- const epicZoneInfo = epicDropZonesRef.current.get(overId);
275
- if (epicZoneInfo) {
276
- // Same epic - reorder within epic
277
- if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
278
- await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
279
- } else if (currentEpicId !== epicZoneInfo.epicId) {
280
- // Different epic - assign to new epic
281
- await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
282
- }
283
- }
284
- } else if (overId) {
285
- // Collision detection returned a status zone, but epic zones are nested
286
- // inside status zones. Check if pointer is actually within an epic zone.
287
- 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;
288
311
  epicDropZonesRef.current.forEach((info, id) => {
289
312
  const rect = info.element.getBoundingClientRect();
290
- const x = pointerPositionRef.current.x;
291
- const y = pointerPositionRef.current.y;
292
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
293
- resolvedEpicZone = id;
313
+ if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
314
+ epicZone = id;
294
315
  }
295
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
+ };
296
326
 
297
- if (resolvedEpicZone) {
298
- // Pointer is within an epic zone - route to epic handler
299
- 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);
300
331
  if (epicZoneInfo) {
301
332
  if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
302
333
  await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
303
334
  } else if (currentEpicId !== epicZoneInfo.epicId) {
304
335
  await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
305
336
  }
337
+ return;
306
338
  }
307
- } else {
308
- // Truly a status zone drop
309
- const zoneInfo = dropZonesRef.current.get(overId);
339
+ }
340
+ if (dropZone) {
341
+ const zoneInfo = dropZonesRef.current.get(dropZone);
310
342
  if (zoneInfo) {
311
343
  if (item.status !== zoneInfo.targetStatus) {
312
344
  await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
@@ -315,18 +347,50 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
315
347
  }
316
348
  }
317
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
+ }
318
377
  }
319
- // over: null means collision detection missed - treat as no-op.
320
- // Same gap issue we handle in handleDragOver. Don't remove from epic
321
- // just because the collision detection had a gap at the moment of drop.
322
378
  } catch (error) {
323
379
  const errorMessage = error instanceof Error ? error.message : 'Failed to complete drop operation';
324
380
  onError?.(errorMessage);
325
381
  } finally {
326
- setDraggedItem(null);
327
- draggedItemRef.current = null;
328
- setActiveDropZone(null);
329
- 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
+ }
330
394
  }
331
395
  }, [onRemoveFromEpic, onError]);
332
396
 
@@ -335,6 +399,9 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
335
399
  draggedItemRef.current = null;
336
400
  setActiveDropZone(null);
337
401
  setActiveEpicZone(null);
402
+ activeDropZoneRef.current = null;
403
+ activeEpicZoneRef.current = null;
404
+ positionCacheRef.current = null;
338
405
  }, []);
339
406
 
340
407
  // Cancel drag on Escape key
@@ -364,6 +431,7 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
364
431
  registerEpicDropZone,
365
432
  unregisterEpicDropZone,
366
433
  getCardPositions,
434
+ pointerPositionRef,
367
435
  }}
368
436
  >
369
437
  <DndContext
@@ -392,6 +460,8 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
392
460
  boxShadow: shadow.overlay,
393
461
  borderRadius: 8,
394
462
  overflow: 'hidden',
463
+ WebkitBackfaceVisibility: 'hidden',
464
+ backfaceVisibility: 'hidden',
395
465
  }}
396
466
  >
397
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,7 +15,7 @@ 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,
@@ -24,7 +23,7 @@ export function DropZone({
24
23
  children,
25
24
  className = '',
26
25
  highlightClassName = 'ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20',
27
- reorderHighlightClassName = 'ring-2 ring-amber-400 bg-amber-50/50 dark:bg-amber-900/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-[color,background-color,box-shadow] duration-200 ease-out`}
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 ?? '');
@@ -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
 
@@ -1,8 +1,9 @@
1
- 'use client';
2
1
 
3
2
  import { useState, useEffect } from 'react';
4
3
 
5
- // Persist timer start timestamps outside the component so they survive remounts (e.g., tab switches)
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;
6
7
  const timerStartTimes = new Map<string, number>();
7
8
 
8
9
  export function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean; timerKey: string }) {
@@ -16,6 +17,11 @@ export function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean;
16
17
  // Start or continue timing — reuse persisted start time if available
17
18
  if (!timerStartTimes.has(timerKey)) {
18
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
+ }
19
25
  }
20
26
  // Immediately sync elapsed value for this timerKey (prevents stale value on tab switch)
21
27
  const startTime = timerStartTimes.get(timerKey);
@@ -28,7 +34,13 @@ export function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean;
28
34
  setElapsed(Math.floor((Date.now() - startTime) / 1000));
29
35
  }
30
36
  }, 1000);
31
- return () => clearInterval(interval);
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
+ };
32
44
  } else {
33
45
  // Reset when not streaming
34
46
  timerStartTimes.delete(timerKey);