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.
- package/.env +2 -1
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
- package/apps/dashboard/app/demo/gates/page.tsx +3 -5
- package/apps/dashboard/app/design-system/page.tsx +1 -1
- package/apps/dashboard/app/globals.css +74 -2
- package/apps/dashboard/app/install-claude/page.tsx +3 -5
- package/apps/dashboard/app/login/page.tsx +17 -20
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +60 -12
- package/apps/dashboard/app/signup/page.tsx +14 -17
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +12 -15
- package/apps/dashboard/app/work/[id]/page.tsx +90 -75
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +70 -61
- package/apps/dashboard/components/CardMenu.tsx +0 -1
- package/apps/dashboard/components/ClaudePanel.tsx +541 -283
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/CopyableId.tsx +1 -2
- package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
- package/apps/dashboard/components/DragContext.tsx +132 -62
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +5 -6
- package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +0 -1
- package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
- package/apps/dashboard/components/EpicGroup.tsx +100 -70
- package/apps/dashboard/components/GateCard.tsx +0 -1
- package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
- package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/JettyLoader.tsx +0 -1
- package/apps/dashboard/components/KanbanBoard.tsx +319 -173
- package/apps/dashboard/components/KanbanCard.tsx +341 -107
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
- package/apps/dashboard/components/MainNav.tsx +24 -25
- package/apps/dashboard/components/MessageBlock.tsx +93 -16
- package/apps/dashboard/components/ModeStartCard.tsx +0 -1
- package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
- package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
- package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
- package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
- package/apps/dashboard/components/ReviewFooter.tsx +12 -14
- package/apps/dashboard/components/SessionList.tsx +0 -1
- package/apps/dashboard/components/SubscribeContent.tsx +40 -11
- package/apps/dashboard/components/TestTree.tsx +1 -2
- package/apps/dashboard/components/TipCard.tsx +2 -4
- package/apps/dashboard/components/Toast.tsx +0 -1
- package/apps/dashboard/components/TypeIcon.tsx +7 -8
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
- package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
- package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
- package/apps/dashboard/components/WorkItemTree.tsx +2 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
- package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
- package/apps/dashboard/components/ui/Button.tsx +1 -1
- package/apps/dashboard/components/ui/Input.tsx +1 -1
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
- package/apps/dashboard/contexts/UsageContext.tsx +62 -31
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1302
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +226 -26
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +253 -122
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +3 -3
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -33
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +167 -30
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/jettypod.js +96 -4
- package/lib/bdd-preflight.js +96 -0
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/work-commands/index.js +58 -16
- package/lib/work-tracking/index.js +108 -8
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +43 -1
- package/skills-templates/chore-mode/SKILL.md +40 -1
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +14 -0
- package/skills-templates/feature-planning/SKILL.md +90 -1
- package/skills-templates/production-mode/SKILL.md +20 -0
- package/skills-templates/simple-improvement/SKILL.md +39 -2
- package/skills-templates/speed-mode/SKILL.md +10 -15
- package/skills-templates/stable-mode/SKILL.md +47 -0
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -55
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
- package/apps/dashboard/electron/ipc-handlers.js +0 -1026
- package/apps/dashboard/electron/main.js +0 -2306
- package/apps/dashboard/electron/preload.js +0 -125
- package/apps/dashboard/electron/session-manager.js +0 -163
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/backlog-parser.ts +0 -50
- package/apps/dashboard/lib/claude-process-manager.ts +0 -529
- package/apps/dashboard/lib/db-bridge.ts +0 -283
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -66
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/bug-icon.svg +0 -9
- package/apps/dashboard/public/buoy-icon.svg +0 -9
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/in-flight-seagull.svg +0 -9
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/pier-icon.svg +0 -14
- package/apps/dashboard/public/star-icon.svg +0 -9
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/public/wrench-icon.svg +0 -9
- package/apps/dashboard/scripts/download-node.js +0 -104
- 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
|
|
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 (
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
152
|
-
//
|
|
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
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
}
|
|
308
|
-
|
|
309
|
-
const zoneInfo = dropZonesRef.current.get(
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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-
|
|
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
|
|
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 {
|
|
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
|
|
34
|
-
|
|
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
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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,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 () =>
|
|
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);
|