fix(frontend):keep DeerFlow chat thread ids in sync (#1931)

* fix: replay thread sync changes on top of main

* fix: avoid stale thread ids during stream startup
This commit is contained in:
Admire 2026-04-07 17:15:46 +08:00 committed by GitHub
parent 3b3e8e1b0b
commit ab41de2961
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 34 additions and 10 deletions

View File

@ -41,20 +41,22 @@ export default function AgentChatPage() {
const { agent } = useAgent(agent_name); const { agent } = useAgent(agent_name);
const { threadId, isNewThread, setIsNewThread } = useThreadChat(); const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId); const [settings, setSettings] = useThreadSettings(threadId);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
context: { ...settings.context, agent_name: agent_name }, context: { ...settings.context, agent_name: agent_name },
onStart: () => { onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false); setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead. // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState( history.replaceState(
null, null,
"", "",
`/workspace/agents/${agent_name}/chats/${threadId}`, `/workspace/agents/${agent_name}/chats/${createdThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {

View File

@ -32,7 +32,8 @@ import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId); const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useSpecificChatMode(); useSpecificChatMode();
@ -47,10 +48,11 @@ export default function ChatPage() {
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
context: settings.context, context: settings.context,
isMock, isMock,
onStart: () => { onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false); setIsNewThread(false);
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead. // ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
history.replaceState(null, "", `/workspace/chats/${threadId}`); history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {

View File

@ -22,8 +22,11 @@ export function useThreadChat() {
if (pathname.endsWith("/new")) { if (pathname.endsWith("/new")) {
setIsNewThread(true); setIsNewThread(true);
setThreadId(uuid()); setThreadId(uuid());
return;
} }
}, [pathname]); setIsNewThread(false);
setThreadId(threadIdFromPath);
}, [pathname, threadIdFromPath]);
const isMock = searchParams.get("mock") === "true"; const isMock = searchParams.get("mock") === "true";
return { threadId, isNewThread, setIsNewThread, isMock }; return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
} }

View File

@ -164,9 +164,11 @@ export function useThreadStream({
useEffect(() => { useEffect(() => {
const normalizedThreadId = threadId ?? null; const normalizedThreadId = threadId ?? null;
if (!normalizedThreadId) { if (!normalizedThreadId) {
// Just reset for new thread creation when threadId becomes null/undefined // Reset when the UI moves back to a brand new unsaved thread.
startedRef.current = false; startedRef.current = false;
setOnStreamThreadId(normalizedThreadId); setOnStreamThreadId(normalizedThreadId);
} else {
setOnStreamThreadId(normalizedThreadId);
} }
threadIdRef.current = normalizedThreadId; threadIdRef.current = normalizedThreadId;
}, [threadId]); }, [threadId]);
@ -294,6 +296,16 @@ export function useThreadStream({
// Track message count before sending so we know when server has responded // Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length); const prevMsgCountRef = useRef(thread.messages.length);
// Reset thread-local pending UI state when switching between threads so
// optimistic messages and in-flight guards do not leak across chat views.
useEffect(() => {
startedRef.current = false;
sendInFlightRef.current = false;
prevMsgCountRef.current = 0;
setOptimisticMessages([]);
setIsUploading(false);
}, [threadId]);
// Clear optimistic when server messages arrive (count increases) // Clear optimistic when server messages arrive (count increases)
useEffect(() => { useEffect(() => {
if ( if (
@ -357,7 +369,12 @@ export function useThreadStream({
} }
setOptimisticMessages(newOptimistic); setOptimisticMessages(newOptimistic);
_handleOnStart(threadId); // Only fire onStart immediately for an existing persisted thread.
// Brand-new chats should wait for onCreated(meta.thread_id) so URL sync
// uses the real server-generated thread id.
if (threadIdRef.current) {
_handleOnStart(threadId);
}
let uploadedFileInfo: UploadedFileInfo[] = []; let uploadedFileInfo: UploadedFileInfo[] = [];