jettypod 4.4.116 → 4.4.120

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 (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -17,6 +17,7 @@ import {
17
17
  } from '@dnd-kit/core';
18
18
  import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
19
19
  import type { WorkItem } from '@/lib/db';
20
+ import { shadow } from '@/lib/shadows';
20
21
 
21
22
  type DropHandler = (itemId: number, newStatus: string) => Promise<void>;
22
23
  type ReorderHandler = (itemId: number, pointerY: number) => Promise<void>;
@@ -47,15 +48,11 @@ interface DragContextType {
47
48
  draggedItem: WorkItem | null;
48
49
  activeDropZone: string | null;
49
50
  activeEpicZone: string | null;
50
- dragPosition: { x: number; y: number };
51
- draggedCardHeight: number;
52
51
  setDraggedItem: (item: WorkItem | null) => void;
53
52
  registerDropZone: (id: string, info: DropZoneInfo) => void;
54
53
  unregisterDropZone: (id: string) => void;
55
54
  registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
56
55
  unregisterEpicDropZone: (id: string) => void;
57
- registerCardPosition: (id: number, rect: DOMRect) => void;
58
- unregisterCard: (id: number) => void;
59
56
  getCardPositions: () => CardPosition[];
60
57
  }
61
58
 
@@ -64,15 +61,11 @@ const DragContext = createContext<DragContextType>({
64
61
  draggedItem: null,
65
62
  activeDropZone: null,
66
63
  activeEpicZone: null,
67
- dragPosition: { x: 0, y: 0 },
68
- draggedCardHeight: 0,
69
64
  setDraggedItem: () => {},
70
65
  registerDropZone: () => {},
71
66
  unregisterDropZone: () => {},
72
67
  registerEpicDropZone: () => {},
73
68
  unregisterEpicDropZone: () => {},
74
- registerCardPosition: () => {},
75
- unregisterCard: () => {},
76
69
  getCardPositions: () => [],
77
70
  });
78
71
 
@@ -118,12 +111,8 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
118
111
  const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
119
112
  const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
120
113
  const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
121
- const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
122
- const [draggedCardHeight, setDraggedCardHeight] = useState(0);
123
-
124
114
  const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
125
115
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
126
- const cardPositionsRef = useRef<Map<number, DOMRect>>(new Map());
127
116
  const draggedItemRef = useRef<WorkItem | null>(null);
128
117
  const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
129
118
 
@@ -159,22 +148,16 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
159
148
  epicDropZonesRef.current.delete(id);
160
149
  }, []);
161
150
 
162
- const registerCardPosition = useCallback((id: number, rect: DOMRect) => {
163
- cardPositionsRef.current.set(id, rect);
164
- }, []);
165
-
166
- const unregisterCard = useCallback((id: number) => {
167
- // Don't unregister if this is the dragged card
168
- if (draggedItemRef.current?.id === id) {
169
- return;
170
- }
171
- cardPositionsRef.current.delete(id);
172
- }, []);
173
-
151
+ // Read fresh card positions from DOM - avoids stale cached positions
152
+ // that cause jank when cards shift during drag (collapsed dragged card, placeholders)
174
153
  const getCardPositions = useCallback((): CardPosition[] => {
175
154
  const positions: CardPosition[] = [];
176
- cardPositionsRef.current.forEach((rect, id) => {
177
- positions.push({ id, rect });
155
+ const elements = document.querySelectorAll<HTMLElement>('[data-item-id]');
156
+ elements.forEach((el) => {
157
+ const id = Number(el.getAttribute('data-item-id'));
158
+ if (!isNaN(id) && el.offsetHeight > 0) {
159
+ positions.push({ id, rect: el.getBoundingClientRect() });
160
+ }
178
161
  });
179
162
  return positions;
180
163
  }, []);
@@ -189,40 +172,34 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
189
172
  if (item) {
190
173
  setDraggedItem(item);
191
174
  draggedItemRef.current = item;
192
- // Get the card height from the dragging element
193
- const rect = event.active.rect.current.initial;
194
- if (rect) {
195
- setDraggedCardHeight(rect.height);
196
- }
197
175
  }
198
176
  }, []);
199
177
 
200
178
  const handleDragMove = useCallback((event: { activatorEvent: Event; delta: { x: number; y: number } }) => {
201
- // Track pointer position for insertion preview calculations
179
+ // Update ref for reorder calculations (state update handled by native pointermove)
202
180
  const pointerEvent = event.activatorEvent as PointerEvent;
203
181
  if (pointerEvent) {
204
182
  const x = pointerEvent.clientX + event.delta.x;
205
183
  const y = pointerEvent.clientY + event.delta.y;
206
184
  pointerPositionRef.current = { x, y };
207
- setDragPosition({ x, y });
208
185
  }
209
186
  }, []);
210
187
 
211
188
  const handleDragOver = useCallback((event: DragOverEvent) => {
212
189
  const { over, activatorEvent, delta } = event;
213
190
 
214
- // Update pointer position
191
+ // Update pointer ref (state update handled by native pointermove)
215
192
  const pointerEvent = activatorEvent as PointerEvent;
216
193
  if (pointerEvent) {
217
194
  const x = pointerEvent.clientX + delta.x;
218
195
  const y = pointerEvent.clientY + delta.y;
219
196
  pointerPositionRef.current = { x, y };
220
- setDragPosition({ x, y });
221
197
  }
222
198
 
223
199
  if (!over) {
224
- setActiveDropZone(null);
225
- setActiveEpicZone(null);
200
+ // Don't clear zone state on null - collision detection has gaps during drag
201
+ // that cause the insertion preview to flicker/disappear. Zones are properly
202
+ // cleared when drag ends or is cancelled.
226
203
  return;
227
204
  }
228
205
 
@@ -244,7 +221,19 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
244
221
  setActiveDropZone(foundStatusZone);
245
222
  } else if (dropZonesRef.current.has(overId)) {
246
223
  setActiveDropZone(overId);
247
- setActiveEpicZone(null);
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) => {
229
+ 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;
234
+ }
235
+ });
236
+ setActiveEpicZone(foundEpicZone);
248
237
  } else {
249
238
  setActiveDropZone(null);
250
239
  setActiveEpicZone(null);
@@ -254,11 +243,13 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
254
243
  const handleDragEnd = useCallback(async (event: DragEndEvent) => {
255
244
  const { over, activatorEvent, delta } = event;
256
245
 
257
- // Get final pointer position
246
+ // Get final pointer position (both x and y needed for epic zone bounds check)
258
247
  const pointerEvent = activatorEvent as PointerEvent;
259
248
  if (pointerEvent) {
260
- const y = pointerEvent.clientY + delta.y;
261
- pointerPositionRef.current.y = y;
249
+ pointerPositionRef.current = {
250
+ x: pointerEvent.clientX + delta.x,
251
+ y: pointerEvent.clientY + delta.y,
252
+ };
262
253
  }
263
254
 
264
255
  const item = draggedItemRef.current;
@@ -291,27 +282,43 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
291
282
  }
292
283
  }
293
284
  } else if (overId) {
294
- // Dropped on a status zone
295
- const zoneInfo = dropZonesRef.current.get(overId);
296
- if (zoneInfo) {
297
- // Check if this is a status change or a reorder
298
- if (item.status !== zoneInfo.targetStatus) {
299
- // Status change
300
- await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
301
- } else if (zoneInfo.onReorder) {
302
- // Reorder within same status
303
- await zoneInfo.onReorder(item.id, pointerPositionRef.current.y);
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;
288
+ epicDropZonesRef.current.forEach((info, id) => {
289
+ 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;
294
+ }
295
+ });
296
+
297
+ if (resolvedEpicZone) {
298
+ // Pointer is within an epic zone - route to epic handler
299
+ const epicZoneInfo = epicDropZonesRef.current.get(resolvedEpicZone);
300
+ if (epicZoneInfo) {
301
+ if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
302
+ await epicZoneInfo.onReorder(item.id, pointerPositionRef.current.y);
303
+ } else if (currentEpicId !== epicZoneInfo.epicId) {
304
+ await epicZoneInfo.onEpicAssign(item.id, epicZoneInfo.epicId);
305
+ }
306
+ }
307
+ } else {
308
+ // Truly a status zone drop
309
+ const zoneInfo = dropZonesRef.current.get(overId);
310
+ if (zoneInfo) {
311
+ if (item.status !== zoneInfo.targetStatus) {
312
+ await zoneInfo.onDrop(item.id, zoneInfo.targetStatus);
313
+ } else if (zoneInfo.onReorder) {
314
+ await zoneInfo.onReorder(item.id, pointerPositionRef.current.y);
315
+ }
304
316
  }
305
- } else if (currentEpicId && onRemoveFromEpic) {
306
- // Dropped on unknown zone - remove from epic if applicable
307
- await onRemoveFromEpic(item.id, null);
308
- }
309
- } else {
310
- // Dropped outside all zones
311
- if (currentEpicId && onRemoveFromEpic) {
312
- await onRemoveFromEpic(item.id, null);
313
317
  }
314
318
  }
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.
315
322
  } catch (error) {
316
323
  const errorMessage = error instanceof Error ? error.message : 'Failed to complete drop operation';
317
324
  onError?.(errorMessage);
@@ -351,15 +358,11 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
351
358
  draggedItem,
352
359
  activeDropZone,
353
360
  activeEpicZone,
354
- dragPosition,
355
- draggedCardHeight,
356
361
  setDraggedItem: handleSetDraggedItem,
357
362
  registerDropZone,
358
363
  unregisterDropZone,
359
364
  registerEpicDropZone,
360
365
  unregisterEpicDropZone,
361
- registerCardPosition,
362
- unregisterCard,
363
366
  getCardPositions,
364
367
  }}
365
368
  >
@@ -374,12 +377,19 @@ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic, on
374
377
  >
375
378
  {children}
376
379
  {/* Drag overlay - uses @dnd-kit's built-in DragOverlay */}
377
- <DragOverlay dropAnimation={null}>
380
+ <DragOverlay dropAnimation={{
381
+ duration: 150,
382
+ easing: 'ease',
383
+ keyframes: ({ transform }) => [
384
+ { opacity: 1, transform: transform.initial ? `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)` : undefined },
385
+ { opacity: 0, transform: transform.initial ? `translate3d(${transform.initial.x}px, ${transform.initial.y}px, 0)` : undefined },
386
+ ],
387
+ }}>
378
388
  {draggedItem && renderDragOverlay ? (
379
389
  <div
380
390
  style={{
381
391
  transform: 'scale(1.02)',
382
- boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
392
+ boxShadow: shadow.overlay,
383
393
  borderRadius: 8,
384
394
  overflow: 'hidden',
385
395
  }}
@@ -12,8 +12,7 @@ interface DraggableCardProps {
12
12
  }
13
13
 
14
14
  export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
15
- const { registerCardPosition, unregisterCard, draggedItem, setDraggedItem } = useDragContext();
16
- const cardRef = useRef<HTMLDivElement>(null);
15
+ const { draggedItem, setDraggedItem } = useDragContext();
17
16
  const prevDisabledRef = useRef(disabled);
18
17
 
19
18
  const {
@@ -33,65 +32,26 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
33
32
  const isNowDisabled = disabled;
34
33
  prevDisabledRef.current = disabled;
35
34
 
36
- // If this card was being dragged and just became disabled, cancel the drag
37
35
  if (wasEnabled && isNowDisabled && draggedItem?.id === item.id) {
38
36
  setDraggedItem(null);
39
37
  }
40
38
  }, [disabled, draggedItem, item.id, setDraggedItem]);
41
39
 
42
- // Register card position for optimized reorder calculations
43
- useEffect(() => {
44
- if (disabled || !cardRef.current) return;
45
-
46
- const updatePosition = () => {
47
- if (cardRef.current) {
48
- registerCardPosition(item.id, cardRef.current.getBoundingClientRect());
49
- }
50
- };
51
-
52
- // Initial registration
53
- updatePosition();
54
-
55
- // Update on resize/layout changes
56
- const resizeObserver = new ResizeObserver(updatePosition);
57
- resizeObserver.observe(cardRef.current);
58
-
59
- // Update on scroll (positions are viewport-relative)
60
- const scrollHandler = () => updatePosition();
61
- window.addEventListener('scroll', scrollHandler, true);
62
-
63
- return () => {
64
- unregisterCard(item.id);
65
- resizeObserver.disconnect();
66
- window.removeEventListener('scroll', scrollHandler, true);
67
- };
68
- }, [item.id, disabled, registerCardPosition, unregisterCard]);
69
-
70
- // Combine refs
71
- const setRefs = (node: HTMLDivElement | null) => {
72
- setNodeRef(node);
73
- (cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
74
- };
75
-
76
40
  if (disabled) {
77
41
  return <>{children}</>;
78
42
  }
79
43
 
80
- // When dragging, hide the original card (overlay shows the dragged version)
44
+ // When dragging, fade the original card but keep its space to prevent layout jumping.
45
+ // The DragOverlay shows the card at the cursor; the thin insertion line shows where it'll land.
81
46
  const style = {
82
- // Only apply transform when not using drag overlay (which we are using)
83
- // So we keep the original position and hide it instead
84
- opacity: isDragging ? 0 : 1,
85
- height: isDragging ? 0 : 'auto',
86
- overflow: isDragging ? 'hidden' : 'visible',
87
- padding: isDragging ? 0 : undefined,
88
- marginBottom: isDragging ? 0 : undefined,
47
+ opacity: isDragging ? 0.2 : 1,
48
+ transition: 'opacity 150ms ease',
89
49
  touchAction: 'none' as const,
90
50
  };
91
51
 
92
52
  return (
93
53
  <div
94
- ref={setRefs}
54
+ ref={setNodeRef}
95
55
  style={style}
96
56
  className="cursor-grab active:cursor-grabbing"
97
57
  data-draggable="true"
@@ -23,7 +23,7 @@ export function DropZone({
23
23
  allowReorder = false,
24
24
  children,
25
25
  className = '',
26
- highlightClassName = 'ring-2 ring-blue-400 bg-blue-50/50 dark:bg-blue-900/20',
26
+ highlightClassName = 'ring-2 ring-[#819D9F] bg-[#819D9F]/10 dark:bg-[#819D9F]/20',
27
27
  reorderHighlightClassName = 'ring-2 ring-amber-400 bg-amber-50/50 dark:bg-amber-900/20',
28
28
  'data-testid': testId,
29
29
  }: DropZoneProps) {
@@ -72,7 +72,7 @@ export function DropZone({
72
72
  return (
73
73
  <div
74
74
  ref={setRefs}
75
- className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-all duration-200`}
75
+ className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-[color,background-color,box-shadow] duration-200 ease-out`}
76
76
  data-testid={testId}
77
77
  data-drop-zone={targetStatus}
78
78
  data-is-active={isActive}
@@ -81,7 +81,7 @@ export function EditableDetailDescription({ description, itemId }: EditableDetai
81
81
  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
82
  error
83
83
  ? 'border-red-500 focus:ring-red-500'
84
- : 'border-zinc-300 dark:border-zinc-600 focus:ring-blue-500'
84
+ : 'border-zinc-300 dark:border-zinc-600 focus:ring-[#819D9F]'
85
85
  }`}
86
86
  />
87
87
  {error && (
@@ -7,12 +7,15 @@ interface EditableTitleProps {
7
7
  itemId: number;
8
8
  onSave: (id: number, newTitle: string) => Promise<void>;
9
9
  variant?: 'card' | 'page';
10
+ isEditing?: boolean;
11
+ onEditingChange?: (editing: boolean) => void;
12
+ clickToEdit?: boolean;
10
13
  }
11
14
 
12
15
  const variantStyles = {
13
16
  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',
17
+ 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',
18
+ 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
19
  },
17
20
  page: {
18
21
  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 +23,13 @@ const variantStyles = {
20
23
  },
21
24
  };
22
25
 
23
- export function EditableTitle({ title, itemId, onSave, variant = 'card' }: EditableTitleProps) {
24
- const [isEditing, setIsEditing] = useState(false);
26
+ export function EditableTitle({ title, itemId, onSave, variant = 'card', isEditing: externalIsEditing, onEditingChange, clickToEdit = true }: EditableTitleProps) {
27
+ const [internalIsEditing, setInternalIsEditing] = useState(false);
28
+ const isEditing = externalIsEditing ?? internalIsEditing;
29
+ const setIsEditing = (value: boolean) => {
30
+ setInternalIsEditing(value);
31
+ onEditingChange?.(value);
32
+ };
25
33
  const [editValue, setEditValue] = useState(title);
26
34
  const [error, setError] = useState<string | null>(null);
27
35
  const inputRef = useRef<HTMLInputElement>(null);
@@ -33,7 +41,15 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
33
41
  }
34
42
  }, [isEditing]);
35
43
 
44
+ useEffect(() => {
45
+ if (isEditing) {
46
+ setEditValue(title);
47
+ setError(null);
48
+ }
49
+ }, [isEditing, title]);
50
+
36
51
  const handleClick = (e: React.MouseEvent) => {
52
+ if (!clickToEdit) return;
37
53
  e.preventDefault();
38
54
  e.stopPropagation();
39
55
  setEditValue(title);
@@ -93,7 +109,7 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
93
109
  className={`${variantStyles[variant].input} ${
94
110
  error
95
111
  ? 'border-red-500 focus:ring-red-500'
96
- : 'border-zinc-300 dark:border-zinc-600 focus:ring-blue-500'
112
+ : 'border-zinc-300 dark:border-zinc-600 focus:ring-[#819D9F]'
97
113
  }`}
98
114
  />
99
115
  {error && (
@@ -103,10 +119,14 @@ export function EditableTitle({ title, itemId, onSave, variant = 'card' }: Edita
103
119
  );
104
120
  }
105
121
 
122
+ const displayClass = clickToEdit
123
+ ? variantStyles[variant].display
124
+ : 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', '');
125
+
106
126
  return (
107
127
  <p
108
128
  onClick={handleClick}
109
- className={variantStyles[variant].display}
129
+ className={displayClass}
110
130
  >
111
131
  {title}
112
132
  </p>
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+
5
+ // Persist timer start timestamps outside the component so they survive remounts (e.g., tab switches)
6
+ const timerStartTimes = new Map<string, number>();
7
+
8
+ export function ElapsedTimer({ isStreaming, timerKey }: { isStreaming: boolean; timerKey: string }) {
9
+ const [elapsed, setElapsed] = useState(() => {
10
+ const existing = timerStartTimes.get(timerKey);
11
+ return existing ? Math.floor((Date.now() - existing) / 1000) : 0;
12
+ });
13
+
14
+ useEffect(() => {
15
+ if (isStreaming) {
16
+ // Start or continue timing — reuse persisted start time if available
17
+ if (!timerStartTimes.has(timerKey)) {
18
+ timerStartTimes.set(timerKey, Date.now());
19
+ }
20
+ // Immediately sync elapsed value for this timerKey (prevents stale value on tab switch)
21
+ const startTime = timerStartTimes.get(timerKey);
22
+ if (startTime != null) {
23
+ setElapsed(Math.floor((Date.now() - startTime) / 1000));
24
+ }
25
+ const interval = setInterval(() => {
26
+ const startTime = timerStartTimes.get(timerKey);
27
+ if (startTime != null) {
28
+ setElapsed(Math.floor((Date.now() - startTime) / 1000));
29
+ }
30
+ }, 1000);
31
+ return () => clearInterval(interval);
32
+ } else {
33
+ // Reset when not streaming
34
+ timerStartTimes.delete(timerKey);
35
+ setElapsed(0);
36
+ }
37
+ }, [isStreaming, timerKey]);
38
+
39
+ if (!isStreaming) return null;
40
+
41
+ const minutes = Math.floor(elapsed / 60);
42
+ const seconds = elapsed % 60;
43
+ const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
44
+
45
+ return (
46
+ <div
47
+ className="text-xs text-zinc-500"
48
+ style={{ width: '3rem', flexShrink: 0, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
49
+ data-testid="elapsed-timer"
50
+ >
51
+ {display}
52
+ </div>
53
+ );
54
+ }