mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
fix(frontend): isolate new chat thread messages (#3508)
* fix(frontend): isolate new chat thread messages * fix(frontend): keep live messages visible in new chat * fix(frontend): reset thread-local message refs
This commit is contained in:
@@ -72,20 +72,21 @@ export default function AgentChatPage() {
|
||||
loadMoreHistory,
|
||||
} = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
displayThreadId: threadId,
|
||||
context: { ...settings.context, agent_name: agent_name },
|
||||
isMock,
|
||||
onSend: () => {
|
||||
setIsWelcomeMode(false);
|
||||
},
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
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.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/workspace/agents/${agent_name}/chats/${createdThreadId}`,
|
||||
);
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function ChatPage() {
|
||||
loadMoreHistory,
|
||||
} = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
displayThreadId: threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
// onSend only animates the UI; do NOT flip `isNewThread` here — the
|
||||
@@ -84,10 +85,10 @@ export default function ChatPage() {
|
||||
setIsWelcomeMode(false);
|
||||
},
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
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.
|
||||
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
export function useThreadChat() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const pathname = usePathname();
|
||||
const actualPathname =
|
||||
typeof window === "undefined" ? pathname : window.location.pathname;
|
||||
const isNewPath = actualPathname.endsWith("/new");
|
||||
const newThreadIdRef = useRef<string | null>(
|
||||
threadIdFromPath === "new" ? uuid() : null,
|
||||
);
|
||||
|
||||
if (isNewPath && !newThreadIdRef.current) {
|
||||
newThreadIdRef.current = uuid();
|
||||
}
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const [threadId, setThreadId] = useState(() => {
|
||||
return threadIdFromPath === "new" ? uuid() : threadIdFromPath;
|
||||
const [threadId, setThreadIdState] = useState(() => {
|
||||
return threadIdFromPath === "new"
|
||||
? (newThreadIdRef.current ?? uuid())
|
||||
: threadIdFromPath;
|
||||
});
|
||||
|
||||
const [isNewThread, setIsNewThread] = useState(
|
||||
const [isNewThreadState, setIsNewThreadState] = useState(
|
||||
() => threadIdFromPath === "new",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.endsWith("/new")) {
|
||||
setIsNewThread(true);
|
||||
setThreadId(uuid());
|
||||
if (isNewPath) {
|
||||
const nextThreadId = newThreadIdRef.current ?? uuid();
|
||||
newThreadIdRef.current = nextThreadId;
|
||||
setIsNewThreadState(true);
|
||||
setThreadIdState(nextThreadId);
|
||||
return;
|
||||
}
|
||||
newThreadIdRef.current = null;
|
||||
// Guard: after history.replaceState updates the URL from /chats/new to
|
||||
// /chats/{UUID}, Next.js useParams may still return the stale "new" value
|
||||
// because replaceState does not trigger router updates. Avoid propagating
|
||||
@@ -32,9 +47,28 @@ export function useThreadChat() {
|
||||
if (threadIdFromPath === "new") {
|
||||
return;
|
||||
}
|
||||
setIsNewThread(false);
|
||||
setThreadId(threadIdFromPath);
|
||||
}, [pathname, threadIdFromPath]);
|
||||
setIsNewThreadState(false);
|
||||
setThreadIdState(threadIdFromPath);
|
||||
}, [isNewPath, threadIdFromPath]);
|
||||
|
||||
const setThreadId = useCallback((nextThreadId: string) => {
|
||||
newThreadIdRef.current = null;
|
||||
setThreadIdState(nextThreadId);
|
||||
}, []);
|
||||
|
||||
const setIsNewThread = useCallback((nextIsNewThread: boolean) => {
|
||||
if (!nextIsNewThread) {
|
||||
newThreadIdRef.current = null;
|
||||
}
|
||||
setIsNewThreadState(nextIsNewThread);
|
||||
}, []);
|
||||
|
||||
const isMock = searchParams.get("mock") === "true";
|
||||
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
|
||||
return {
|
||||
threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
|
||||
setThreadId,
|
||||
isNewThread: isNewPath ? true : isNewThreadState,
|
||||
setIsNewThread,
|
||||
isMock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
@@ -41,6 +41,7 @@ export type ToolEndEvent = {
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
displayThreadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
isMock?: boolean;
|
||||
onSend?: (threadId: string) => void;
|
||||
@@ -53,6 +54,13 @@ type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const EMPTY_THREAD_VALUES: AgentThreadState = {
|
||||
title: "",
|
||||
messages: [],
|
||||
artifacts: [],
|
||||
todos: [],
|
||||
};
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
@@ -388,6 +396,7 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
displayThreadId,
|
||||
context,
|
||||
isMock,
|
||||
onSend,
|
||||
@@ -396,6 +405,18 @@ export function useThreadStream({
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const currentViewThreadId = displayThreadId ?? threadId ?? null;
|
||||
const currentViewThreadIdRef = useRef(currentViewThreadId);
|
||||
currentViewThreadIdRef.current = currentViewThreadId;
|
||||
// Optimistic messages shown before the server stream responds.
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [optimisticThreadId, setOptimisticThreadId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [liveMessagesThreadId, setLiveMessagesThreadId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -437,6 +458,28 @@ export function useThreadStream({
|
||||
|
||||
const handleStreamStart = useCallback((_threadId: string, _runId: string) => {
|
||||
threadIdRef.current = _threadId;
|
||||
setOptimisticThreadId((currentOptimisticThreadId) => {
|
||||
const currentView = currentViewThreadIdRef.current;
|
||||
if (
|
||||
currentOptimisticThreadId &&
|
||||
(currentOptimisticThreadId === currentView ||
|
||||
currentOptimisticThreadId === _threadId)
|
||||
) {
|
||||
return _threadId;
|
||||
}
|
||||
return currentOptimisticThreadId;
|
||||
});
|
||||
setLiveMessagesThreadId((currentLiveMessagesThreadId) => {
|
||||
const currentView = currentViewThreadIdRef.current;
|
||||
if (
|
||||
currentLiveMessagesThreadId &&
|
||||
(currentLiveMessagesThreadId === currentView ||
|
||||
currentLiveMessagesThreadId === _threadId)
|
||||
) {
|
||||
return _threadId;
|
||||
}
|
||||
return currentLiveMessagesThreadId;
|
||||
});
|
||||
if (!startedRef.current) {
|
||||
listeners.current.onStart?.(_threadId, _runId);
|
||||
startedRef.current = true;
|
||||
@@ -608,6 +651,8 @@ export function useThreadStream({
|
||||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
setOptimisticThreadId(null);
|
||||
setLiveMessagesThreadId(null);
|
||||
toast.error(getStreamErrorMessage(error));
|
||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||
messagesRef.current
|
||||
@@ -639,10 +684,17 @@ export function useThreadStream({
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic messages shown before the server stream responds
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const humanMessageCount = thread.messages.filter(
|
||||
const hasVisibleStreamState =
|
||||
Boolean(threadId) || liveMessagesThreadId === currentViewThreadId;
|
||||
const persistedMessages = useMemo(
|
||||
() => (hasVisibleStreamState ? thread.messages : []),
|
||||
[hasVisibleStreamState, thread.messages],
|
||||
);
|
||||
const visibleHistory = useMemo(
|
||||
() => (threadId ? history : []),
|
||||
[history, threadId],
|
||||
);
|
||||
const humanMessageCount = persistedMessages.filter(
|
||||
(m) => m.type === "human",
|
||||
).length;
|
||||
const latestMessageCountsRef = useRef({ humanMessageCount });
|
||||
@@ -663,15 +715,23 @@ export function useThreadStream({
|
||||
useEffect(() => {
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||
messagesRef.current
|
||||
.map(messageIdentity)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
messagesRef.current = [];
|
||||
summarizedRef.current = new Set<string>();
|
||||
pendingUsageBaselineMessageIdsRef.current = new Set();
|
||||
prevHumanMsgCountRef.current =
|
||||
latestMessageCountsRef.current.humanMessageCount;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticThreadId && optimisticThreadId !== currentViewThreadId) {
|
||||
setOptimisticMessages([]);
|
||||
setOptimisticThreadId(null);
|
||||
}
|
||||
if (liveMessagesThreadId && liveMessagesThreadId !== currentViewThreadId) {
|
||||
setLiveMessagesThreadId(null);
|
||||
}
|
||||
}, [currentViewThreadId, liveMessagesThreadId, optimisticThreadId]);
|
||||
|
||||
// When streaming starts without a baseline (e.g. reconnection, run started
|
||||
// from another client, or page reload mid-stream), snapshot the current
|
||||
// messages so only *new* messages are treated as "pending" for token usage.
|
||||
@@ -681,12 +741,12 @@ export function useThreadStream({
|
||||
pendingUsageBaselineMessageIdsRef.current.size === 0
|
||||
) {
|
||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||
thread.messages
|
||||
persistedMessages
|
||||
.map(messageIdentity)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
}
|
||||
}, [thread.isLoading, thread.messages]);
|
||||
}, [persistedMessages, thread.isLoading]);
|
||||
|
||||
// Clear optimistic when server messages arrive.
|
||||
// For messages with a human optimistic message, wait until the server's
|
||||
@@ -702,6 +762,7 @@ export function useThreadStream({
|
||||
|
||||
if (!hasHumanOptimistic || newHumanMsgArrived) {
|
||||
setOptimisticMessages([]);
|
||||
setOptimisticThreadId(null);
|
||||
}
|
||||
}, [hasHumanOptimistic, humanMessageCount, optimisticMessageCount]);
|
||||
|
||||
@@ -723,7 +784,7 @@ export function useThreadStream({
|
||||
// messages so we can wait for the server's copy of the user input.
|
||||
prevHumanMsgCountRef.current = humanMessageCount;
|
||||
pendingUsageBaselineMessageIdsRef.current = new Set(
|
||||
thread.messages
|
||||
persistedMessages
|
||||
.map(messageIdentity)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
@@ -762,6 +823,8 @@ export function useThreadStream({
|
||||
additional_kwargs: { element: "task" },
|
||||
});
|
||||
}
|
||||
setOptimisticThreadId(threadId);
|
||||
setLiveMessagesThreadId(threadId);
|
||||
setOptimisticMessages(newOptimistic);
|
||||
|
||||
listeners.current.onSend?.(threadId);
|
||||
@@ -827,6 +890,8 @@ export function useThreadStream({
|
||||
: "Failed to upload files.";
|
||||
toast.error(errorMessage);
|
||||
setOptimisticMessages([]);
|
||||
setOptimisticThreadId(null);
|
||||
setLiveMessagesThreadId(null);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
@@ -895,35 +960,44 @@ export function useThreadStream({
|
||||
});
|
||||
} catch (error) {
|
||||
setOptimisticMessages([]);
|
||||
setOptimisticThreadId(null);
|
||||
setLiveMessagesThreadId(null);
|
||||
setIsUploading(false);
|
||||
throw error;
|
||||
} finally {
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, t.uploads.uploadingFiles, context, queryClient, humanMessageCount],
|
||||
[
|
||||
thread,
|
||||
t.uploads.uploadingFiles,
|
||||
context,
|
||||
queryClient,
|
||||
humanMessageCount,
|
||||
persistedMessages,
|
||||
],
|
||||
);
|
||||
|
||||
// Cache the latest thread messages in a ref to compare against incoming history messages for deduplication,
|
||||
// and to allow access to the full message list in onUpdateEvent without causing re-renders.
|
||||
if (thread.messages.length >= messagesRef.current.length) {
|
||||
messagesRef.current = thread.messages;
|
||||
if (persistedMessages.length >= messagesRef.current.length) {
|
||||
messagesRef.current = persistedMessages;
|
||||
}
|
||||
|
||||
const visibleOptimisticMessages = getVisibleOptimisticMessages(
|
||||
optimisticMessages,
|
||||
optimisticThreadId === currentViewThreadId ? optimisticMessages : [],
|
||||
prevHumanMsgCountRef.current,
|
||||
humanMessageCount,
|
||||
);
|
||||
|
||||
const mergedMessages = mergeMessages(
|
||||
history,
|
||||
thread.messages,
|
||||
visibleHistory,
|
||||
persistedMessages,
|
||||
visibleOptimisticMessages,
|
||||
);
|
||||
const pendingUsageMessages = thread.isLoading
|
||||
? getMessagesAfterBaseline(
|
||||
thread.messages,
|
||||
persistedMessages,
|
||||
pendingUsageBaselineMessageIdsRef.current,
|
||||
)
|
||||
: [];
|
||||
@@ -932,6 +1006,7 @@ export function useThreadStream({
|
||||
// History messages may overlap with thread.messages; thread.messages take precedence
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
values: hasVisibleStreamState ? thread.values : EMPTY_THREAD_VALUES,
|
||||
messages: mergedMessages,
|
||||
} as typeof thread;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
import {
|
||||
mockLangGraphAPI,
|
||||
@@ -19,6 +19,9 @@ const THREADS = [
|
||||
},
|
||||
];
|
||||
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
||||
const SVG_PROMPT_THREAD_ID = "00000000-0000-0000-0000-000000000777";
|
||||
const SVG_PROMPT_MARKER = "LEAK-STRICT-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||||
const OPTIMISTIC_PROMPT_MARKER = "LEAK-OPTIMISTIC-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||||
|
||||
test.describe("Thread history", () => {
|
||||
test("sidebar shows existing threads", async ({ page }) => {
|
||||
@@ -62,6 +65,109 @@ test.describe("Thread history", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("new chat does not show previous thread messages after client-side navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: SVG_PROMPT_THREAD_ID,
|
||||
title: "SVG artifact prompt",
|
||||
updated_at: "2025-06-03T12:00:00Z",
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-svg-prompt",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `请严格执行:\n1. 使用 write_file 创建 /mnt/user-data/outputs/shared.svg,内容包含 ${SVG_PROMPT_MARKER}\n2. 最终回复只输出 Markdown 图片。`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-svg-prompt",
|
||||
content: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${SVG_PROMPT_THREAD_ID}`);
|
||||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await page.getByRole("link", { name: /new chat/i }).click();
|
||||
await page.waitForURL("**/workspace/chats/new");
|
||||
|
||||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
|
||||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("new chat does not show previous optimistic user message after client-side navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID_2,
|
||||
title: "Destination conversation",
|
||||
updated_at: "2025-06-04T12:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const metadataOnlyStream = async (route: Route) => {
|
||||
const body = [
|
||||
{
|
||||
event: "metadata",
|
||||
data: {
|
||||
run_id: "00000000-0000-0000-0000-000000000778",
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
},
|
||||
},
|
||||
{ event: "end", data: {} },
|
||||
]
|
||||
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
||||
.join("");
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
await page.route("**/api/langgraph/runs/stream", metadataOnlyStream);
|
||||
await page.route(
|
||||
"**/api/langgraph/threads/*/runs/stream",
|
||||
metadataOnlyStream,
|
||||
);
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
await textarea.fill(
|
||||
`请严格执行:使用 write_file 创建 shared.svg,内容包含 ${OPTIMISTIC_PROMPT_MARKER}。`,
|
||||
);
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toBeVisible();
|
||||
|
||||
await page.getByText("Destination conversation").click();
|
||||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
|
||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||
|
||||
await page.getByRole("link", { name: /new chat/i }).click();
|
||||
await page.waitForURL("**/workspace/chats/new");
|
||||
|
||||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("mock thread does not load real backend run history", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user