From 2fa2cdfdf77a512c64f1e4971d311d3227f8093d Mon Sep 17 00:00:00 2001 From: Lily Shen Date: Mon, 4 May 2026 13:22:50 -0700 Subject: [PATCH] =?UTF-8?q?fix(v2-chat):=20dedupe=20optimistic=20add=20?= =?UTF-8?q?=E2=80=94=20pairs=20with=20#304=20user-message=20broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend #304 added a Socket.io `newMessage` emit on user-message POST to fix the "human messages don't appear live for other users" bug. That fix introduced a sender-tab race: the WS broadcast and the POST response both deliver the same message row to the sender's tab, and only the WS handler had a dedupe-by-id check (V2 hook line 194). Symptom: sending a message renders it twice in the sender's own tab — once from the WS event (which arrives async), once from the POST response (which appends without dedupe). Fix: mirror the same dedupe pattern in the optimistic-add path of sendMessage(). Whichever path arrives first wins; the second is a no-op. Order doesn't matter, both are idempotent. Other connected users still see one message (their tab only gets the WS event, no optimistic local copy), which is the correct behavior. Surfaced during YC demo recording — Sam's @nova trigger message rendered twice in his tab the first time we hit the new broadcast path. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/v2/hooks/useV2PodDetail.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 302e7eb8..b40331e8 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -212,7 +212,15 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { { timeout: SEND_TIMEOUT_MS }, ); const normalized = normalizeMessage(created); - setMessages((prev) => chronologicalMessages([...prev, normalized])); + // Dedupe by id — the Socket.io `newMessage` broadcast and this + // optimistic add both come from the same DB row and race after + // backend PR #304 (which added the user-message broadcast). Whichever + // arrives first wins; the second is a no-op. Without this, sending + // a message renders it twice in the sender's tab. + setMessages((prev) => { + if (prev.some((m) => m.id && m.id === normalized.id)) return prev; + return chronologicalMessages([...prev, normalized]); + }); return normalized; } catch (err) { const e = err as { response?: { data?: { error?: string; msg?: string } }; message?: string };