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:
zgenu
2026-06-11 22:12:15 +08:00
committed by GitHub
parent b6fbf0d105
commit c733d3c917
5 changed files with 253 additions and 36 deletions
@@ -72,20 +72,21 @@ export default function AgentChatPage() {
loadMoreHistory, loadMoreHistory,
} = useThreadStream({ } = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
displayThreadId: threadId,
context: { ...settings.context, agent_name: agent_name }, context: { ...settings.context, agent_name: agent_name },
isMock, isMock,
onSend: () => { onSend: () => {
setIsWelcomeMode(false); setIsWelcomeMode(false);
}, },
onStart: (createdThreadId) => { 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. // ! 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/${createdThreadId}`, `/workspace/agents/${agent_name}/chats/${createdThreadId}`,
); );
setThreadId(createdThreadId);
setIsNewThread(false);
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
@@ -75,6 +75,7 @@ export default function ChatPage() {
loadMoreHistory, loadMoreHistory,
} = useThreadStream({ } = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
displayThreadId: threadId,
context: settings.context, context: settings.context,
isMock, isMock,
// onSend only animates the UI; do NOT flip `isNewThread` here — the // onSend only animates the UI; do NOT flip `isNewThread` here — the
@@ -84,10 +85,10 @@ export default function ChatPage() {
setIsWelcomeMode(false); setIsWelcomeMode(false);
}, },
onStart: (createdThreadId) => { 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. // ! 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}`); history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
setThreadId(createdThreadId);
setIsNewThread(false);
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
@@ -1,29 +1,44 @@
"use client"; "use client";
import { useParams, usePathname, useSearchParams } from "next/navigation"; 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"; import { uuid } from "@/core/utils/uuid";
export function useThreadChat() { export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname(); 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 searchParams = useSearchParams();
const [threadId, setThreadId] = useState(() => { const [threadId, setThreadIdState] = useState(() => {
return threadIdFromPath === "new" ? uuid() : threadIdFromPath; return threadIdFromPath === "new"
? (newThreadIdRef.current ?? uuid())
: threadIdFromPath;
}); });
const [isNewThread, setIsNewThread] = useState( const [isNewThreadState, setIsNewThreadState] = useState(
() => threadIdFromPath === "new", () => threadIdFromPath === "new",
); );
useEffect(() => { useEffect(() => {
if (pathname.endsWith("/new")) { if (isNewPath) {
setIsNewThread(true); const nextThreadId = newThreadIdRef.current ?? uuid();
setThreadId(uuid()); newThreadIdRef.current = nextThreadId;
setIsNewThreadState(true);
setThreadIdState(nextThreadId);
return; return;
} }
newThreadIdRef.current = null;
// Guard: after history.replaceState updates the URL from /chats/new to // Guard: after history.replaceState updates the URL from /chats/new to
// /chats/{UUID}, Next.js useParams may still return the stale "new" value // /chats/{UUID}, Next.js useParams may still return the stale "new" value
// because replaceState does not trigger router updates. Avoid propagating // because replaceState does not trigger router updates. Avoid propagating
@@ -32,9 +47,28 @@ export function useThreadChat() {
if (threadIdFromPath === "new") { if (threadIdFromPath === "new") {
return; return;
} }
setIsNewThread(false); setIsNewThreadState(false);
setThreadId(threadIdFromPath); setThreadIdState(threadIdFromPath);
}, [pathname, 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"; 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,
};
} }
+95 -20
View File
@@ -9,7 +9,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -41,6 +41,7 @@ export type ToolEndEvent = {
export type ThreadStreamOptions = { export type ThreadStreamOptions = {
threadId?: string | null | undefined; threadId?: string | null | undefined;
displayThreadId?: string | null | undefined;
context: LocalSettings["context"]; context: LocalSettings["context"];
isMock?: boolean; isMock?: boolean;
onSend?: (threadId: string) => void; onSend?: (threadId: string) => void;
@@ -53,6 +54,13 @@ type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>; additionalKwargs?: Record<string, unknown>;
}; };
const EMPTY_THREAD_VALUES: AgentThreadState = {
title: "",
messages: [],
artifacts: [],
todos: [],
};
function isNonEmptyString(value: string | undefined): value is string { function isNonEmptyString(value: string | undefined): value is string {
return typeof value === "string" && value.length > 0; return typeof value === "string" && value.length > 0;
} }
@@ -388,6 +396,7 @@ function getStreamErrorMessage(error: unknown): string {
export function useThreadStream({ export function useThreadStream({
threadId, threadId,
displayThreadId,
context, context,
isMock, isMock,
onSend, onSend,
@@ -396,6 +405,18 @@ export function useThreadStream({
onToolEnd, onToolEnd,
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); 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 // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // 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) => { const handleStreamStart = useCallback((_threadId: string, _runId: string) => {
threadIdRef.current = _threadId; 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) { if (!startedRef.current) {
listeners.current.onStart?.(_threadId, _runId); listeners.current.onStart?.(_threadId, _runId);
startedRef.current = true; startedRef.current = true;
@@ -608,6 +651,8 @@ export function useThreadStream({
}, },
onError(error) { onError(error) {
setOptimisticMessages([]); setOptimisticMessages([]);
setOptimisticThreadId(null);
setLiveMessagesThreadId(null);
toast.error(getStreamErrorMessage(error)); toast.error(getStreamErrorMessage(error));
pendingUsageBaselineMessageIdsRef.current = new Set( pendingUsageBaselineMessageIdsRef.current = new Set(
messagesRef.current messagesRef.current
@@ -639,10 +684,17 @@ export function useThreadStream({
}, },
}); });
// Optimistic messages shown before the server stream responds const hasVisibleStreamState =
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]); Boolean(threadId) || liveMessagesThreadId === currentViewThreadId;
const [isUploading, setIsUploading] = useState(false); const persistedMessages = useMemo(
const humanMessageCount = thread.messages.filter( () => (hasVisibleStreamState ? thread.messages : []),
[hasVisibleStreamState, thread.messages],
);
const visibleHistory = useMemo(
() => (threadId ? history : []),
[history, threadId],
);
const humanMessageCount = persistedMessages.filter(
(m) => m.type === "human", (m) => m.type === "human",
).length; ).length;
const latestMessageCountsRef = useRef({ humanMessageCount }); const latestMessageCountsRef = useRef({ humanMessageCount });
@@ -663,15 +715,23 @@ export function useThreadStream({
useEffect(() => { useEffect(() => {
startedRef.current = false; startedRef.current = false;
sendInFlightRef.current = false; sendInFlightRef.current = false;
pendingUsageBaselineMessageIdsRef.current = new Set( messagesRef.current = [];
messagesRef.current summarizedRef.current = new Set<string>();
.map(messageIdentity) pendingUsageBaselineMessageIdsRef.current = new Set();
.filter((id): id is string => Boolean(id)),
);
prevHumanMsgCountRef.current = prevHumanMsgCountRef.current =
latestMessageCountsRef.current.humanMessageCount; latestMessageCountsRef.current.humanMessageCount;
}, [threadId]); }, [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 // When streaming starts without a baseline (e.g. reconnection, run started
// from another client, or page reload mid-stream), snapshot the current // from another client, or page reload mid-stream), snapshot the current
// messages so only *new* messages are treated as "pending" for token usage. // 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.size === 0
) { ) {
pendingUsageBaselineMessageIdsRef.current = new Set( pendingUsageBaselineMessageIdsRef.current = new Set(
thread.messages persistedMessages
.map(messageIdentity) .map(messageIdentity)
.filter((id): id is string => Boolean(id)), .filter((id): id is string => Boolean(id)),
); );
} }
}, [thread.isLoading, thread.messages]); }, [persistedMessages, thread.isLoading]);
// Clear optimistic when server messages arrive. // Clear optimistic when server messages arrive.
// For messages with a human optimistic message, wait until the server's // For messages with a human optimistic message, wait until the server's
@@ -702,6 +762,7 @@ export function useThreadStream({
if (!hasHumanOptimistic || newHumanMsgArrived) { if (!hasHumanOptimistic || newHumanMsgArrived) {
setOptimisticMessages([]); setOptimisticMessages([]);
setOptimisticThreadId(null);
} }
}, [hasHumanOptimistic, humanMessageCount, optimisticMessageCount]); }, [hasHumanOptimistic, humanMessageCount, optimisticMessageCount]);
@@ -723,7 +784,7 @@ export function useThreadStream({
// messages so we can wait for the server's copy of the user input. // messages so we can wait for the server's copy of the user input.
prevHumanMsgCountRef.current = humanMessageCount; prevHumanMsgCountRef.current = humanMessageCount;
pendingUsageBaselineMessageIdsRef.current = new Set( pendingUsageBaselineMessageIdsRef.current = new Set(
thread.messages persistedMessages
.map(messageIdentity) .map(messageIdentity)
.filter((id): id is string => Boolean(id)), .filter((id): id is string => Boolean(id)),
); );
@@ -762,6 +823,8 @@ export function useThreadStream({
additional_kwargs: { element: "task" }, additional_kwargs: { element: "task" },
}); });
} }
setOptimisticThreadId(threadId);
setLiveMessagesThreadId(threadId);
setOptimisticMessages(newOptimistic); setOptimisticMessages(newOptimistic);
listeners.current.onSend?.(threadId); listeners.current.onSend?.(threadId);
@@ -827,6 +890,8 @@ export function useThreadStream({
: "Failed to upload files."; : "Failed to upload files.";
toast.error(errorMessage); toast.error(errorMessage);
setOptimisticMessages([]); setOptimisticMessages([]);
setOptimisticThreadId(null);
setLiveMessagesThreadId(null);
throw error; throw error;
} finally { } finally {
setIsUploading(false); setIsUploading(false);
@@ -895,35 +960,44 @@ export function useThreadStream({
}); });
} catch (error) { } catch (error) {
setOptimisticMessages([]); setOptimisticMessages([]);
setOptimisticThreadId(null);
setLiveMessagesThreadId(null);
setIsUploading(false); setIsUploading(false);
throw error; throw error;
} finally { } finally {
sendInFlightRef.current = false; 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, // 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. // and to allow access to the full message list in onUpdateEvent without causing re-renders.
if (thread.messages.length >= messagesRef.current.length) { if (persistedMessages.length >= messagesRef.current.length) {
messagesRef.current = thread.messages; messagesRef.current = persistedMessages;
} }
const visibleOptimisticMessages = getVisibleOptimisticMessages( const visibleOptimisticMessages = getVisibleOptimisticMessages(
optimisticMessages, optimisticThreadId === currentViewThreadId ? optimisticMessages : [],
prevHumanMsgCountRef.current, prevHumanMsgCountRef.current,
humanMessageCount, humanMessageCount,
); );
const mergedMessages = mergeMessages( const mergedMessages = mergeMessages(
history, visibleHistory,
thread.messages, persistedMessages,
visibleOptimisticMessages, visibleOptimisticMessages,
); );
const pendingUsageMessages = thread.isLoading const pendingUsageMessages = thread.isLoading
? getMessagesAfterBaseline( ? getMessagesAfterBaseline(
thread.messages, persistedMessages,
pendingUsageBaselineMessageIdsRef.current, pendingUsageBaselineMessageIdsRef.current,
) )
: []; : [];
@@ -932,6 +1006,7 @@ export function useThreadStream({
// History messages may overlap with thread.messages; thread.messages take precedence // History messages may overlap with thread.messages; thread.messages take precedence
const mergedThread = { const mergedThread = {
...thread, ...thread,
values: hasVisibleStreamState ? thread.values : EMPTY_THREAD_VALUES,
messages: mergedMessages, messages: mergedMessages,
} as typeof thread; } as typeof thread;
+107 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Route } from "@playwright/test";
import { import {
mockLangGraphAPI, mockLangGraphAPI,
@@ -19,6 +19,9 @@ const THREADS = [
}, },
]; ];
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990"; 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.describe("Thread history", () => {
test("sidebar shows existing threads", async ({ page }) => { test("sidebar shows existing threads", async ({ page }) => {
@@ -62,6 +65,109 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 }); ).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 ({ test("mock thread does not load real backend run history", async ({
page, page,
}) => { }) => {