diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index c16af882a..ed777bb24 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -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()) { diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index ce3912b91..f20c1c228 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -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()) { diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index 6913e3b76..06773d0e1 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -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( + 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, + }; } diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 6c91881aa..4cee4999a 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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; }; +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([]); + const [optimisticThreadId, setOptimisticThreadId] = useState( + 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([]); - 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(); + 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; diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 9476ca4ab..509468839 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -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: "![shared artifact](/mnt/user-data/outputs/shared.svg)", + }, + ], + }, + ], + }); + + 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, }) => {