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.
- package/.env +7 -0
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
- package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
- package/apps/dashboard/app/api/usage/route.ts +17 -0
- package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
- package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
- package/apps/dashboard/app/connect-claude/page.tsx +24 -0
- package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
- package/apps/dashboard/app/demo/gates/page.tsx +42 -42
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +6 -2
- package/apps/dashboard/app/install-claude/page.tsx +9 -7
- package/apps/dashboard/app/layout.tsx +17 -5
- package/apps/dashboard/app/login/page.tsx +250 -0
- package/apps/dashboard/app/page.tsx +11 -9
- package/apps/dashboard/app/settings/page.tsx +4 -2
- package/apps/dashboard/app/signup/page.tsx +245 -0
- package/apps/dashboard/app/subscribe/page.tsx +11 -0
- package/apps/dashboard/app/welcome/page.tsx +24 -1
- package/apps/dashboard/app/work/[id]/page.tsx +34 -50
- package/apps/dashboard/components/AppShell.tsx +95 -55
- package/apps/dashboard/components/CardMenu.tsx +56 -13
- package/apps/dashboard/components/ClaudePanel.tsx +301 -582
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
- package/apps/dashboard/components/CopyableId.tsx +3 -3
- package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
- package/apps/dashboard/components/DragContext.tsx +75 -65
- package/apps/dashboard/components/DraggableCard.tsx +6 -46
- package/apps/dashboard/components/DropZone.tsx +2 -2
- package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
- package/apps/dashboard/components/EditableTitle.tsx +26 -6
- package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
- package/apps/dashboard/components/EpicGroup.tsx +329 -0
- package/apps/dashboard/components/GateCard.tsx +100 -16
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
- package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
- package/apps/dashboard/components/JettyLoader.tsx +38 -0
- package/apps/dashboard/components/KanbanBoard.tsx +147 -766
- package/apps/dashboard/components/KanbanCard.tsx +506 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
- package/apps/dashboard/components/MainNav.tsx +20 -54
- package/apps/dashboard/components/MessageBlock.tsx +391 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -15
- package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
- package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
- package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
- package/apps/dashboard/components/ReviewFooter.tsx +141 -0
- package/apps/dashboard/components/SessionList.tsx +19 -18
- package/apps/dashboard/components/SubscribeContent.tsx +206 -0
- package/apps/dashboard/components/TestTree.tsx +15 -14
- package/apps/dashboard/components/TipCard.tsx +177 -0
- package/apps/dashboard/components/Toast.tsx +5 -5
- package/apps/dashboard/components/TypeIcon.tsx +56 -0
- package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
- package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
- package/apps/dashboard/components/WorkItemTree.tsx +9 -28
- package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
- package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
- package/apps/dashboard/contexts/UsageContext.tsx +155 -0
- package/apps/dashboard/contexts/usageHelpers.js +9 -0
- package/apps/dashboard/electron/ipc-handlers.js +281 -88
- package/apps/dashboard/electron/main.js +691 -131
- package/apps/dashboard/electron/preload.js +25 -4
- package/apps/dashboard/electron/session-manager.js +163 -0
- package/apps/dashboard/electron-builder.config.js +3 -5
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/lib/backlog-parser.ts +50 -0
- package/apps/dashboard/lib/claude-process-manager.ts +50 -11
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/db-bridge.ts +33 -0
- package/apps/dashboard/lib/db.ts +136 -20
- package/apps/dashboard/lib/kanban-utils.ts +70 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/session-state-machine.ts +3 -0
- package/apps/dashboard/lib/session-stream-manager.ts +144 -38
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/tests.ts +3 -1
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next.config.js +35 -14
- package/apps/dashboard/package.json +6 -3
- package/apps/dashboard/public/bug-icon.svg +9 -0
- package/apps/dashboard/public/buoy-icon.svg +9 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.svg +9 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.svg +14 -0
- package/apps/dashboard/public/star-icon.svg +9 -0
- package/apps/dashboard/public/wrench-icon.svg +9 -0
- package/apps/dashboard/scripts/upload-to-r2.js +89 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/apps/update-server/package.json +16 -0
- package/apps/update-server/schema.sql +31 -0
- package/apps/update-server/src/index.ts +1085 -0
- package/apps/update-server/tsconfig.json +16 -0
- package/apps/update-server/wrangler.toml +35 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +54 -116
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/migrations/027-plan-at-creation-column.js +33 -0
- package/lib/migrations/028-ready-for-review-column.js +27 -0
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +13 -6
- package/lib/seed-onboarding.js +101 -69
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +129 -16
- package/lib/work-tracking/index.js +86 -46
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +39 -28
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +131 -68
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/epic-planning/SKILL.md +68 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +83 -73
- package/skills-templates/production-mode/SKILL.md +49 -49
- package/skills-templates/request-routing/SKILL.md +27 -14
- package/skills-templates/simple-improvement/SKILL.md +68 -44
- package/skills-templates/speed-mode/SKILL.md +209 -128
- package/skills-templates/stable-mode/SKILL.md +105 -94
- package/templates/bdd-guidance.md +139 -0
- package/templates/bdd-scaffolding/wait.js +18 -0
- package/templates/bdd-scaffolding/world.js +19 -0
- package/.jettypod-backup/work.db +0 -0
- package/apps/dashboard/app/access-code/page.tsx +0 -110
- package/lib/discovery-checkpoint.js +0 -123
- 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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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={
|
|
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:
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
83
|
-
|
|
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={
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
15
|
-
input: 'text-
|
|
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 [
|
|
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-
|
|
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={
|
|
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
|
+
}
|