fix: prevent duplicate artifact creation

This commit is contained in:
talksik
2026-03-21 16:38:37 -07:00
parent e488cc79d0
commit 8168b7d8f5
+35 -14
View File
@@ -19,6 +19,9 @@ interface ComposeOverlayProps {
onActiveChange?: (active: boolean) => void; onActiveChange?: (active: boolean) => void;
} }
const HOLD_THRESHOLD_MS = 250;
/** /**
* Self-contained compose overlay. Each consumer renders its own instance * Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply). * with props that determine the mode (new stream vs. reply).
@@ -41,9 +44,15 @@ export function ComposeOverlay({
const createParticle = useCreateParticle(); const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle(); const createStream = useCreateStreamParticle();
// Refs to avoid stale closures in keyboard handler // Refs for synchronous reads in keyboard handlers
const stepRef = useRef(step); 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 // Notify parent when active state changes
useEffect(() => { useEffect(() => {
@@ -51,21 +60,22 @@ export function ComposeOverlay({
}, [step, onActiveChange]); }, [step, onActiveChange]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
setStep("idle"); submittingRef.current = false;
setStepSync("idle");
setError(null); setError(null);
setTextContent(""); setTextContent("");
setMediaStream(null); setMediaStream(null);
setReviewBlob(null); setReviewBlob(null);
setReviewDurationMs(0); setReviewDurationMs(0);
setReviewMimeType(null); setReviewMimeType(null);
}, []); }, [setStepSync]);
const { startRecording, stopRecording, cancelRecording } = useRecorder({ const { startRecording, stopRecording, cancelRecording } = useRecorder({
mode: recordingMode, mode: recordingMode,
onStreamReady: (stream) => setMediaStream(stream), onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null), onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs, mimeType) => { onFinish: (blob, durationMs, mimeType) => {
setStep("reviewing"); setStepSync("reviewing");
setReviewBlob(blob); setReviewBlob(blob);
setReviewDurationMs(durationMs); setReviewDurationMs(durationMs);
setReviewMimeType(mimeType); setReviewMimeType(mimeType);
@@ -144,7 +154,8 @@ export function ComposeOverlay({
// Reply mode: create particle directly under targetPath // Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => { const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userEmail) return; if (!targetPath || !userEmail || submittingRef.current) return;
submittingRef.current = true;
await createChildParticle(targetPath); await createChildParticle(targetPath);
cancel(); cancel();
}); });
@@ -152,7 +163,8 @@ export function ComposeOverlay({
// New stream mode: create stream + first child // New stream mode: create stream + first child
const handleStreamSubmit = useCallback( const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => { async (streamName: string, visibleTo: string[]) => {
if (!userEmail) return; if (!userEmail || submittingRef.current) return;
submittingRef.current = true;
const streamId = await createStream.mutateAsync({ const streamId = await createStream.mutateAsync({
networkId, networkId,
@@ -199,17 +211,22 @@ export function ComposeOverlay({
case "idle": { case "idle": {
if (e.key === "`" && !e.repeat) { if (e.key === "`" && !e.repeat) {
e.preventDefault(); e.preventDefault();
setStep("recording"); recordStartRef.current = Date.now();
setStepSync("recording");
startRecording(); startRecording();
} else if (e.key === "t" || e.key === "T") { } else if (e.key === "t" || e.key === "T") {
e.preventDefault(); e.preventDefault();
setStep("typing"); setStepSync("typing");
} }
break; break;
} }
case "recording": { 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(); e.preventDefault();
cancelRecording(); cancelRecording();
cancel(); cancel();
@@ -227,7 +244,7 @@ export function ComposeOverlay({
if (targetPath) { if (targetPath) {
onSubmitReply(); onSubmitReply();
} else { } else {
setStep("configuring"); setStepSync("configuring");
} }
} }
break; break;
@@ -238,7 +255,11 @@ export function ComposeOverlay({
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`") { if (stepRef.current === "recording" && e.key === "`") {
e.preventDefault(); 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("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
}; };
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel]); }, [targetPath, startRecording, stopRecording, cancelRecording, cancel, setStepSync]);
// --- Render --- // --- Render ---
@@ -256,7 +277,7 @@ export function ComposeOverlay({
const handleTextAdvance = targetPath const handleTextAdvance = targetPath
? onSubmitReply ? onSubmitReply
: () => setStep("configuring"); : () => setStepSync("configuring");
return ( return (
<> <>