diff --git a/frontend/src/components/workspace/chats/use-thread-chat.ts b/frontend/src/components/workspace/chats/use-thread-chat.ts index edda9d93b..fded8dfbe 100644 --- a/frontend/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend/src/components/workspace/chats/use-thread-chat.ts @@ -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) => { diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts index 95b6e94c7..61ee29805 100644 --- a/frontend/tests/e2e/thread-history.spec.ts +++ b/frontend/tests/e2e/thread-history.spec.ts @@ -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, }) => {