From 8168b7d8f5c01a638f5ac0dfaac3d8fcb82854b6 Mon Sep 17 00:00:00 2001 From: talksik Date: Sat, 21 Mar 2026 16:38:37 -0700 Subject: [PATCH] fix: prevent duplicate artifact creation --- js/src/features/compose/compose-overlay.tsx | 49 +++++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index c37b741..5b41027 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -19,6 +19,9 @@ interface ComposeOverlayProps { onActiveChange?: (active: boolean) => void; } + +const HOLD_THRESHOLD_MS = 250; + /** * Self-contained compose overlay. Each consumer renders its own instance * with props that determine the mode (new stream vs. reply). @@ -41,9 +44,15 @@ export function ComposeOverlay({ const createParticle = useCreateParticle(); const createStream = useCreateStreamParticle(); - // Refs to avoid stale closures in keyboard handler + // Refs for synchronous reads in keyboard handlers const stepRef = useRef(step); - stepRef.current = step; + const submittingRef = useRef(false); + const recordStartRef = useRef(0); + + const setStepSync = useCallback((next: ComposeStep) => { + stepRef.current = next; + setStep(next); + }, []); // Notify parent when active state changes useEffect(() => { @@ -51,21 +60,22 @@ export function ComposeOverlay({ }, [step, onActiveChange]); const cancel = useCallback(() => { - setStep("idle"); + submittingRef.current = false; + setStepSync("idle"); setError(null); setTextContent(""); setMediaStream(null); setReviewBlob(null); setReviewDurationMs(0); setReviewMimeType(null); - }, []); + }, [setStepSync]); const { startRecording, stopRecording, cancelRecording } = useRecorder({ mode: recordingMode, onStreamReady: (stream) => setMediaStream(stream), onStreamCleanup: () => setMediaStream(null), onFinish: (blob, durationMs, mimeType) => { - setStep("reviewing"); + setStepSync("reviewing"); setReviewBlob(blob); setReviewDurationMs(durationMs); setReviewMimeType(mimeType); @@ -144,7 +154,8 @@ export function ComposeOverlay({ // Reply mode: create particle directly under targetPath const onSubmitReply = useEffectEvent(async () => { - if (!targetPath || !userEmail) return; + if (!targetPath || !userEmail || submittingRef.current) return; + submittingRef.current = true; await createChildParticle(targetPath); cancel(); }); @@ -152,7 +163,8 @@ export function ComposeOverlay({ // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { - if (!userEmail) return; + if (!userEmail || submittingRef.current) return; + submittingRef.current = true; const streamId = await createStream.mutateAsync({ networkId, @@ -199,17 +211,22 @@ export function ComposeOverlay({ case "idle": { if (e.key === "`" && !e.repeat) { e.preventDefault(); - setStep("recording"); + recordStartRef.current = Date.now(); + setStepSync("recording"); startRecording(); } else if (e.key === "t" || e.key === "T") { e.preventDefault(); - setStep("typing"); + setStepSync("typing"); } break; } case "recording": { - if (e.key === "q" || e.key === "Q" || e.key === "Escape") { + if (e.key === "`" && !e.repeat) { + // Second tap stops recording (toggle mode) + e.preventDefault(); + stopRecording(); + } else if (e.key === "q" || e.key === "Q" || e.key === "Escape") { e.preventDefault(); cancelRecording(); cancel(); @@ -227,7 +244,7 @@ export function ComposeOverlay({ if (targetPath) { onSubmitReply(); } else { - setStep("configuring"); + setStepSync("configuring"); } } break; @@ -238,7 +255,11 @@ export function ComposeOverlay({ const handleKeyUp = (e: KeyboardEvent) => { if (stepRef.current === "recording" && e.key === "`") { e.preventDefault(); - stopRecording(); + // Only stop on release if held long enough (hold-to-record mode). + // Quick taps are handled by the second keydown (toggle mode). + if (Date.now() - recordStartRef.current >= HOLD_THRESHOLD_MS) { + stopRecording(); + } } }; @@ -248,7 +269,7 @@ export function ComposeOverlay({ window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; - }, [targetPath, startRecording, stopRecording, cancelRecording, cancel]); + }, [targetPath, startRecording, stopRecording, cancelRecording, cancel, setStepSync]); // --- Render --- @@ -256,7 +277,7 @@ export function ComposeOverlay({ const handleTextAdvance = targetPath ? onSubmitReply - : () => setStep("configuring"); + : () => setStepSync("configuring"); return ( <>