diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts index 8da11448..0a117d3b 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts @@ -52,10 +52,23 @@ test("conversation panel does not consume replay opportunity before timer fires" const effectStart = source.indexOf("const replayRequestedTraceRef = useRef(null);"); const timerStart = source.indexOf("const timer = window.setTimeout", effectStart); const markRequested = source.indexOf("replayRequestedTraceRef.current = traceId", effectStart); - const replayCall = source.indexOf("onReplayTrace();", effectStart); + const replayCall = source.indexOf("replayHandlerRef.current();", effectStart); assert.ok(effectStart > 0, "replay guard ref exists"); assert.ok(timerStart > effectStart, "auto replay uses delayed timer"); assert.ok(markRequested > timerStart, "route changes that cancel the timer must not mark replay as already requested"); assert.ok(replayCall > markRequested, "replay is marked requested only immediately before dispatching replay"); }); + +test("conversation panel keeps replay timer stable across parent rerenders", async () => { + const source = await Bun.file(new URL("./ConversationPanel.tsx", import.meta.url)).text(); + const handlerRef = source.indexOf("const replayHandlerRef = useRef(onReplayTrace);"); + const effectStart = source.indexOf("const timer = window.setTimeout", handlerRef); + const effectEnd = source.indexOf("if (message.role === \"agent\")", effectStart); + const effectSource = source.slice(effectStart, effectEnd); + + assert.ok(handlerRef > 0, "onReplayTrace is kept behind a stable ref"); + assert.match(effectSource, /replayHandlerRef\.current\(\);/u); + assert.match(effectSource, /\}, \[shouldReplayTrace, traceId\]\);/u); + assert.doesNotMatch(effectSource, /\bonReplayTrace\b/u); +}); diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index 746d8fa7..9257e672 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -131,16 +131,21 @@ const HISTORICAL_TRACE_REPLAY_DELAY_MS = 4500; function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; firstScreenHistory: boolean; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; const replayRequestedTraceRef = useRef(null); + const replayHandlerRef = useRef(onReplayTrace); + useEffect(() => { + replayHandlerRef.current = onReplayTrace; + }, [onReplayTrace]); + const traceId = message.traceId ?? message.runnerTrace?.traceId ?? null; + const shouldReplayTrace = shouldAutoReplayTrace(message); useEffect(() => { - const traceId = message.traceId ?? message.runnerTrace?.traceId ?? null; if (traceId && replayRequestedTraceRef.current === traceId) return; - if (!shouldAutoReplayTrace(message)) return; + if (!shouldReplayTrace) return; const timer = window.setTimeout(() => { replayRequestedTraceRef.current = traceId; - onReplayTrace(); + replayHandlerRef.current(); }, HISTORICAL_TRACE_REPLAY_DELAY_MS); return () => window.clearTimeout(timer); - }, [message, onReplayTrace]); + }, [shouldReplayTrace, traceId]); if (message.role === "agent") { const hasFinalResponse = message.status !== "running" && message.text.trim().length > 0; return (