fix(frontend): reset new chat on client-side navigation (#3673)

* fix(frontend): reset new chat on client-side navigation

Drive the chat reset effect with Next.js's reactive pathname instead of the render-time window.location-derived flag.

During App Router transitions, window.location may still point to the previous thread until commit, leaving chat state stale until another UI interaction triggers a render. Preserve the stale 'new' param guard so created thread UUIDs are not overwritten.

* test(frontend): add e2e to cover history-only new chat reset

* docs(frontend): clarify new chat pathname synchronization
This commit is contained in:
AnoobFeng 2026-06-21 11:44:36 +08:00 committed by GitHub
parent 4572217038
commit 5ddf698895
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 45 additions and 7 deletions

View File

@ -27,6 +27,9 @@ export function resetThreadChatAfterDelete(detail: ThreadChatResetDetail) {
export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname();
// Render-time values use the committed browser URL. The sync effect below
// intentionally watches the reactive pathname so client navigation still
// schedules a reset when window.location is stale during render.
const actualPathname =
typeof window === "undefined" ? pathname : window.location.pathname;
const isNewPath = actualPathname.endsWith("/new");
@ -57,7 +60,7 @@ export function useThreadChat() {
}, []);
useEffect(() => {
if (isNewPath) {
if (pathname.endsWith("/new")) {
const nextThreadId = newThreadIdRef.current ?? uuid();
newThreadIdRef.current = nextThreadId;
setIsNewThreadState(true);
@ -65,17 +68,15 @@ export function useThreadChat() {
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
// this invalid thread ID to downstream hooks (e.g. useStream), which would
// cause a 422 from LangGraph Server.
// Native history updates the canonical pathname but preserves the route
// tree, so useParams may still return the stale "new" value. Avoid passing
// it to downstream hooks (e.g. useStream), which would cause a 422.
if (threadIdFromPath === "new") {
return;
}
setIsNewThreadState(false);
setThreadIdState(threadIdFromPath);
}, [isNewPath, threadIdFromPath]);
}, [pathname, threadIdFromPath]);
useEffect(() => {
const handleReset = (event: Event) => {

View File

@ -202,6 +202,43 @@ test.describe("Thread history", () => {
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
});
test("new chat resets immediately after a history-only thread URL update", async ({
page,
}) => {
mockLangGraphAPI(page);
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("Message that must disappear in the next new chat");
await textarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 15_000,
});
// A newly created chat changes the URL with history.replaceState so the
// active stream is not remounted. Reproduce that history-only transition:
// the canonical pathname becomes the UUID while useParams can stay "new".
await page.evaluate((threadId) => {
history.replaceState(null, "", `/workspace/chats/${threadId}`);
}, MOCK_THREAD_ID);
const newChatLink = page.locator(
"[data-sidebar='sidebar'] a[href='/workspace/chats/new']",
);
await expect(page).toHaveURL(
new RegExp(`/workspace/chats/${MOCK_THREAD_ID}$`),
);
await expect(newChatLink).toHaveAttribute("data-active", "false");
// One click must reset the chat without a second click or unrelated UI
// interaction forcing another render.
await newChatLink.click();
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
await expect(textarea).toBeVisible();
});
test("deleting the active newly created chat returns to the new chat screen", async ({
page,
}) => {