From 05b72683aad6c9fb2f87e2cc440db77097c86a9f Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Tue, 27 Oct 2026 19:42:19 +0800 Subject: [PATCH] fix: finding agent chat drawer races scroll and mock --- .../components/finding-agent-chat-drawer.tsx | 283 +++++++++++++----- 1 file changed, 210 insertions(+), 73 deletions(-) diff --git a/web/src/components/finding-agent-chat-drawer.tsx b/web/src/components/finding-agent-chat-drawer.tsx index fb5a883..e263ca5 100644 --- a/web/src/components/finding-agent-chat-drawer.tsx +++ b/web/src/components/finding-agent-chat-drawer.tsx @@ -15,11 +15,11 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT import { Textarea } from "@/components/ui/textarea"; import { api, openSSE } from "@/lib/api"; import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode"; +import { MOCK } from "@/lib/mock/enabled"; import type { Activity, Finding } from "@/lib/types"; import { buildFindingAgentContextMessage, - canAskFindingAgent, findingAgentUnavailableReason, findingDisplayTitle, } from "./finding-agent-chat"; @@ -76,130 +76,267 @@ export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) const eventSourceRef = React.useRef(null); const snapshotRef = React.useRef(0); const aliveRef = React.useRef(false); + const requestGenerationRef = React.useRef(0); const sentContextRef = React.useRef(false); + const currentSegmentRef = React.useRef(0); + const contentRef = React.useRef(null); + const atBottomRef = React.useRef(true); + const findingIdRef = React.useRef(finding.id); + findingIdRef.current = finding.id; + + const findingId = finding.id; + const inherited = Boolean(finding.inherited); + const canAsk = Boolean(taskId && !inherited); const closeStream = React.useCallback(() => { eventSourceRef.current?.close(); eventSourceRef.current = null; }, []); - const refreshStatus = React.useCallback(() => { - if (!taskId || !canAskFindingAgent(finding)) return Promise.resolve(); - return api - .chatStatus(taskId) - .then(({ running: nextRunning }) => setRunning(nextRunning)) - .catch(() => undefined); - }, [finding, taskId]); + const refreshStatus = React.useCallback( + (generation = requestGenerationRef.current) => { + if (!taskId || !canAsk) return Promise.resolve(); + return api + .chatStatus(taskId) + .then(({ running: nextRunning }) => { + if (aliveRef.current && requestGenerationRef.current === generation) setRunning(nextRunning); + }) + .catch(() => undefined); + }, + [canAsk, taskId], + ); React.useEffect(() => { - if (!open) { - aliveRef.current = false; - closeStream(); - return; - } + const generation = ++requestGenerationRef.current; + const isCurrent = () => + aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId; + + closeStream(); + aliveRef.current = open; + if (!open) return; - aliveRef.current = true; sentContextRef.current = false; setInput(""); setActivities([]); setTaskAvailable(null); - setUnavailableReason(findingAgentUnavailableReason(finding)); + setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited })); setRunning(false); setLoading(false); + setSending(false); + setStopping(false); snapshotRef.current = 0; + currentSegmentRef.current = 0; + atBottomRef.current = true; - if (!canAskFindingAgent(finding)) + if (!canAsk) { return () => { aliveRef.current = false; + requestGenerationRef.current += 1; closeStream(); }; + } setLoading(true); - api - .task(taskId) - .then(() => api.mainSessions(taskId)) - .then((sessions) => { - if (!aliveRef.current) return null; - const currentSegment = sessions.current ?? 0; - return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({ - currentSegment, - history, - })); - }) - .then((result) => { - if (!result || !aliveRef.current) return; - const { currentSegment, history } = result; - setTaskAvailable(true); - setActivities((current) => mergeActivities(current, history.items, currentSegment)); - snapshotRef.current = history.snapshotCursor; - const stream = openSSE( - `/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`, - ); - eventSourceRef.current = stream; - stream.onmessage = (event) => { - if (!aliveRef.current) return; - try { - const activity = JSON.parse(event.data) as Activity; - setActivities((current) => mergeActivities(current, [activity], currentSegment)); - } catch { - // Ignore malformed frames; the next history refresh still repairs the view. - } - }; - stream.onerror = () => undefined; // EventSource reconnects automatically. - }) - .catch(() => { - if (!aliveRef.current) return; - setTaskAvailable(false); - setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。"); - }) - .finally(() => { - if (aliveRef.current) setLoading(false); - }); - void refreshStatus(); - const statusTimer = window.setInterval(() => void refreshStatus(), 2000); + if (MOCK) { + api + .activity(taskId) + .then(({ items, cursor }) => { + if (!isCurrent()) return; + setTaskAvailable(true); + setActivities(mergeActivities([], items, currentSegmentRef.current)); + snapshotRef.current = cursor; + }) + .catch(() => { + if (!isCurrent()) return; + setTaskAvailable(false); + setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。"); + }) + .finally(() => { + if (isCurrent()) setLoading(false); + }); + } else { + api + .task(taskId) + .then(() => api.mainSessions(taskId)) + .then((sessions) => { + if (!isCurrent()) return null; + const currentSegment = sessions.current ?? 0; + currentSegmentRef.current = currentSegment; + return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({ + currentSegment, + history, + })); + }) + .then((result) => { + if (!result || !isCurrent()) return; + const { currentSegment, history } = result; + setTaskAvailable(true); + setActivities((current) => mergeActivities(current, history.items, currentSegment)); + snapshotRef.current = history.snapshotCursor; + + closeStream(); + const stream = openSSE( + `/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`, + ); + if (!isCurrent()) { + stream.close(); + return; + } + eventSourceRef.current = stream; + stream.onmessage = (event) => { + if (!isCurrent()) return; + try { + const activity = JSON.parse(event.data) as Activity; + setActivities((current) => mergeActivities(current, [activity], currentSegment)); + } catch { + // Ignore malformed frames; the next history refresh still repairs the view. + } + }; + stream.onerror = () => undefined; // EventSource reconnects automatically. + }) + .catch(() => { + if (!isCurrent()) return; + setTaskAvailable(false); + setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。"); + }) + .finally(() => { + if (isCurrent()) setLoading(false); + }); + } + + void refreshStatus(generation); + const statusTimer = window.setInterval(() => void refreshStatus(generation), 2000); return () => { aliveRef.current = false; + requestGenerationRef.current += 1; window.clearInterval(statusTimer); closeStream(); }; - }, [closeStream, finding, open, refreshStatus, taskId]); + }, [canAsk, closeStream, findingId, inherited, open, refreshStatus, taskId]); + + const viewport = React.useCallback( + () => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null, + [], + ); + + React.useEffect(() => { + if (!open) return; + const vp = viewport(); + if (!vp) return; + const onScroll = () => { + atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60; + }; + vp.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + return () => vp.removeEventListener("scroll", onScroll); + }, [open, viewport]); + + React.useLayoutEffect(() => { + if (!open) return; + const vp = viewport(); + if (vp) { + vp.scrollTop = vp.scrollHeight; + atBottomRef.current = true; + } + }, [open, viewport]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling. + React.useLayoutEffect(() => { + if (!open || !atBottomRef.current) return; + const vp = viewport(); + if (vp) vp.scrollTop = vp.scrollHeight; + }, [activities, open, running, viewport]); + + React.useEffect(() => { + if (!open || typeof ResizeObserver === "undefined") return; + const el = contentRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + if (!atBottomRef.current) return; + const vp = viewport(); + if (vp) vp.scrollTop = vp.scrollHeight; + }); + ro.observe(el); + return () => ro.disconnect(); + }, [open, viewport]); const send = React.useCallback(async () => { const message = input.trim(); - if (!message || sending || running || !taskId || taskAvailable !== true || !canAskFindingAgent(finding)) return; + if (!message || sending || running || !taskId || taskAvailable !== true || !canAsk) return; + const generation = requestGenerationRef.current; setSending(true); const payload = sentContextRef.current ? message : `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`; try { - await api.chat(payload, taskId); + const result = await api.chat(payload, taskId); + if (!aliveRef.current || requestGenerationRef.current !== generation || findingIdRef.current !== findingId) + return; + if (MOCK) { + const ts = new Date().toISOString(); + const reply = result.reply?.trim() || "(demo)主 Agent 暂无回复。"; + setActivities((current) => + mergeActivities( + current, + [ + { + seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 1, + worker: "mainagent", + ts, + kind: "user", + summary: message, + main_seg: currentSegmentRef.current, + }, + { + seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 2, + worker: "mainagent", + ts, + kind: "text", + summary: reply, + main_seg: currentSegmentRef.current, + }, + ], + currentSegmentRef.current, + ), + ); + } sentContextRef.current = true; setInput(""); - void refreshStatus(); + if (!MOCK) void refreshStatus(generation); } catch (error) { - toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`); + if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) { + toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`); + } } finally { - setSending(false); + if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) { + setSending(false); + } } - }, [finding, input, refreshStatus, running, sending, taskAvailable, taskId]); + }, [canAsk, finding, findingId, input, refreshStatus, running, sending, taskAvailable, taskId]); const stop = React.useCallback(async () => { if (!taskId || stopping) return; + const generation = requestGenerationRef.current; setStopping(true); try { await api.stopChat(taskId); - await refreshStatus(); + if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) { + await refreshStatus(generation); + } } catch (error) { - toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`); + if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) { + toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`); + } } finally { - setStopping(false); + if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) { + setStopping(false); + } } - }, [refreshStatus, stopping, taskId]); + }, [findingId, refreshStatus, stopping, taskId]); const title = findingDisplayTitle(finding); - const canSend = - canAskFindingAgent(finding) && taskAvailable === true && !running && !sending && Boolean(input.trim()); + const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim()); let footerStatus = "Enter 发送,Shift+Enter 换行。"; if (sending) footerStatus = "正在发送…"; else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。"; @@ -231,7 +368,7 @@ export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) ) : null} -
+
{loading ? : null} {!loading && activities.length ? (