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,
} = 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,
};
}
+95 -20
View File
@@ -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;
+107 -1
View File
@@ -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,
}) => {