fix(frontend): keep renamed thread titles in sync (#5045)

* fix(frontend): 同步会话重命名后的标题状态

* chore: 重新触发 PR 自动分流检查

* fix(frontend): address thread title sync review feedback

Signed-off-by: 橘猫 <2622045569@qq.com>

* fix(frontend): fall back when canonical thread title is empty

Signed-off-by: 橘猫 <2622045569@qq.com>

* fix(frontend): fence stale metadata after thread rename

Signed-off-by: 橘猫 <2622045569@qq.com>

* fix(frontend): fence stale thread list responses after rename

Signed-off-by: 橘猫 <2622045569@qq.com>

---------

Signed-off-by: 橘猫 <2622045569@qq.com>
This commit is contained in:
jiaqiang0000 2026-09-01 23:10:54 +08:00 committed by GitHub
parent ddd9aec558
commit 91c7ed4cf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 577 additions and 118 deletions

View File

@ -275,7 +275,11 @@ export default function AgentChatPage() {
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
<ThreadTitle
threadId={threadId}
thread={thread}
canonicalTitle={threadMetadata.data?.values?.title}
/>
</div>
<div className="flex shrink-0 items-center sm:mr-4">
{!isNewThread &&

View File

@ -288,7 +288,11 @@ export default function ChatPage() {
>
{!isMock && <SidebarTrigger className="md:hidden" />}
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
<ThreadTitle
threadId={threadId}
thread={thread}
canonicalTitle={threadMetadata.data?.values?.title}
/>
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread &&

View File

@ -7,21 +7,27 @@ import type { AgentThreadState } from "@/core/threads";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
threadId,
thread,
}: {
export type ThreadTitleProps = {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
}) {
canonicalTitle?: string;
};
export function ThreadTitle({
threadId,
thread,
canonicalTitle,
}: ThreadTitleProps) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const title = canonicalTitle?.length ? canonicalTitle : thread.values?.title;
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = thread.values.title;
if (title) {
_title = title;
} else if (isNewThread) {
_title = t.pages.newChat;
}
@ -36,15 +42,11 @@ export function ThreadTitle({
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
title,
]);
if (!thread.values?.title) {
if (!title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{thread.values.title ?? "Untitled"}
</FlipDisplay>
);
return <FlipDisplay uniqueKey={threadId}>{title}</FlipDisplay>;
}

View File

@ -4,6 +4,7 @@ import { useStream } from "@langchain/langgraph-sdk/react";
import {
type QueryClient,
type InfiniteData,
type QueryFilters,
useInfiniteQuery,
useMutation,
useQuery,
@ -1809,47 +1810,10 @@ export function useThreadStream({
);
for (const update of updates) {
if (update && "title" in update && update.title) {
void queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
return oldData?.map((t) => {
if (t.thread_id === threadIdRef.current) {
return {
...t,
values: {
...t.values,
title: update.title,
},
};
}
return t;
});
},
);
const nextTitle: string = update.title;
void queryClient.setQueriesData(
{
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
exact: false,
},
(oldData: InfiniteData<AgentThread[]> | undefined) =>
mapInfiniteThreadsCache(
oldData,
(t): AgentThread =>
t.thread_id === threadIdRef.current
? {
...t,
values: {
...t.values,
title: nextTitle,
},
}
: t,
),
);
const currentThreadId = threadIdRef.current;
if (currentThreadId) {
setThreadTitleInCaches(queryClient, currentThreadId, update.title);
}
}
}
},
@ -2922,46 +2886,82 @@ function mergeThreadMetadata(
};
}
function setThreadMetadataInCaches(
queryClient: QueryClient,
threadId: string,
metadata: ThreadMetadataPatch,
) {
queryClient.setQueriesData(
{
function mergeThreadTitle(thread: AgentThread, title: string): AgentThread {
return {
...thread,
values: {
...thread.values,
title,
},
};
}
function getThreadSnapshotCacheFilters(threadId: string) {
return {
search: {
queryKey: ["threads", "search"],
exact: false,
},
infiniteSearch: {
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
exact: false,
},
metadata: {
queryKey: ["thread", "metadata", threadId],
exact: false,
},
} as const;
}
function setThreadInCaches(
queryClient: QueryClient,
threadId: string,
mapper: (thread: AgentThread) => AgentThread,
): void {
const filters = getThreadSnapshotCacheFilters(threadId);
queryClient.setQueriesData(
filters.search,
(oldData: Array<AgentThread> | undefined) => {
if (!oldData) {
return oldData;
}
return oldData.map((thread) =>
thread.thread_id === threadId
? mergeThreadMetadata(thread, metadata)
: thread,
thread.thread_id === threadId ? mapper(thread) : thread,
);
},
);
queryClient.setQueriesData(
{
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
exact: false,
},
filters.infiniteSearch,
(oldData: InfiniteData<AgentThread[]> | undefined) =>
mapInfiniteThreadsCache(oldData, (thread) =>
thread.thread_id === threadId
? mergeThreadMetadata(thread, metadata)
: thread,
thread.thread_id === threadId ? mapper(thread) : thread,
),
);
queryClient.setQueriesData(
{
queryKey: ["thread", "metadata", threadId],
exact: false,
},
filters.metadata,
(oldData: AgentThread | null | undefined) =>
oldData ? mergeThreadMetadata(oldData, metadata) : oldData,
oldData ? mapper(oldData) : oldData,
);
}
function setThreadMetadataInCaches(
queryClient: QueryClient,
threadId: string,
metadata: ThreadMetadataPatch,
) {
setThreadInCaches(queryClient, threadId, (thread) =>
mergeThreadMetadata(thread, metadata),
);
}
export function setThreadTitleInCaches(
queryClient: QueryClient,
threadId: string,
title: string,
): void {
setThreadInCaches(queryClient, threadId, (thread) =>
mergeThreadTitle(thread, title),
);
}
@ -3320,45 +3320,19 @@ export function useRenameThread() {
values: { title },
});
},
onSuccess(_, { threadId, title }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread>) => {
return oldData.map((t) => {
if (t.thread_id === threadId) {
return {
...t,
values: {
...t.values,
title,
},
};
}
return t;
});
},
async onSuccess(_, { threadId, title }) {
const filters: QueryFilters[] = Object.values(
getThreadSnapshotCacheFilters(threadId),
);
queryClient.setQueriesData(
{
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
exact: false,
},
(oldData: InfiniteData<AgentThread[]> | undefined) =>
mapInfiniteThreadsCache(oldData, (t) =>
t.thread_id === threadId
? {
...t,
values: {
...t.values,
title,
},
}
: t,
),
// Prevent pre-rename snapshot requests from restoring the stale title.
await Promise.all(
filters.map((filter) => queryClient.cancelQueries(filter)),
);
setThreadTitleInCaches(queryClient, threadId, title);
for (const filter of filters) {
void queryClient.invalidateQueries(filter);
}
},
});
}

View File

@ -0,0 +1,188 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const THREAD_ID = "00000000-0000-0000-0000-000000000321";
const ORIGINAL_TITLE = "Original title";
const RENAMED_TITLE = "Renamed title";
test("renaming a thread updates the sidebar, header, and document title", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: ORIGINAL_TITLE,
updated_at: "2026-07-05T10:00:00Z",
},
],
});
await page.goto(`/workspace/chats/${THREAD_ID}`);
await expect(page.getByText(ORIGINAL_TITLE).first()).toBeVisible({
timeout: 15_000,
});
await expect(page).toHaveTitle(`${ORIGINAL_TITLE} - DeerFlow`);
const threadItem = page
.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`,
)
.locator("xpath=..");
await threadItem.hover();
await threadItem.getByRole("button", { name: "More" }).click();
await page.getByRole("menuitem", { name: "Rename" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox").fill(RENAMED_TITLE);
await dialog.getByRole("button", { name: "Save" }).click();
await expect(dialog).toBeHidden();
await expect(threadItem).toContainText(RENAMED_TITLE);
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
await page.reload();
await expect(threadItem).toContainText(RENAMED_TITLE);
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible({
timeout: 15_000,
});
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
});
test("a stale metadata response cannot restore the old title after rename", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: ORIGINAL_TITLE,
updated_at: "2026-07-05T10:00:00Z",
},
],
});
let releaseStaleMetadataResponse!: () => void;
const staleMetadataResponseGate = new Promise<void>((resolve) => {
releaseStaleMetadataResponse = resolve;
});
let markStaleMetadataRequestStarted!: () => void;
const staleMetadataRequestStarted = new Promise<void>((resolve) => {
markStaleMetadataRequestStarted = resolve;
});
let markStaleMetadataResponseCompleted!: () => void;
const staleMetadataResponseCompleted = new Promise<void>((resolve) => {
markStaleMetadataResponseCompleted = resolve;
});
let releaseFreshMetadataResponse!: () => void;
const freshMetadataResponseGate = new Promise<void>((resolve) => {
releaseFreshMetadataResponse = resolve;
});
let markFreshMetadataRequestStarted!: () => void;
const freshMetadataRequestStarted = new Promise<void>((resolve) => {
markFreshMetadataRequestStarted = resolve;
});
let markFreshMetadataResponseCompleted!: () => void;
const freshMetadataResponseCompleted = new Promise<void>((resolve) => {
markFreshMetadataResponseCompleted = resolve;
});
let delayMetadataResponses = false;
let metadataRequestCount = 0;
await page.route("**/api/langgraph/threads/*", async (route) => {
const url = new URL(route.request().url());
if (
route.request().method() !== "GET" ||
url.pathname !== `/api/langgraph/threads/${THREAD_ID}`
) {
return route.fallback();
}
if (!delayMetadataResponses) {
return route.fallback();
}
metadataRequestCount += 1;
if (metadataRequestCount > 1) {
markFreshMetadataRequestStarted();
await freshMetadataResponseGate;
await route.fallback();
markFreshMetadataResponseCompleted();
return;
}
markStaleMetadataRequestStarted();
await staleMetadataResponseGate;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
thread_id: THREAD_ID,
created_at: "2026-07-05T10:00:00Z",
updated_at: "2026-07-05T10:00:00Z",
metadata: {},
status: "idle",
values: { title: ORIGINAL_TITLE, goal: null },
}),
});
markStaleMetadataResponseCompleted();
});
await page.goto(`/workspace/chats/${THREAD_ID}`);
await expect(page.locator("header").getByText(ORIGINAL_TITLE)).toBeVisible({
timeout: 15_000,
});
delayMetadataResponses = true;
await page.getByRole("link", { name: "Chats", exact: true }).click();
await expect(page).toHaveURL(/\/workspace\/chats$/);
await page
.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`,
)
.click();
await staleMetadataRequestStarted;
const threadItem = page
.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREAD_ID}"]`,
)
.locator("xpath=..");
await expect(threadItem).toContainText(ORIGINAL_TITLE);
await threadItem.hover();
await threadItem.getByRole("button", { name: "More" }).click();
await page.getByRole("menuitem", { name: "Rename" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox").fill(RENAMED_TITLE);
await dialog.getByRole("button", { name: "Save" }).click();
await expect(dialog).toBeHidden();
await expect(threadItem).toContainText(RENAMED_TITLE);
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
releaseStaleMetadataResponse();
await staleMetadataResponseCompleted;
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
// The stale response has completed while the fresh refetch is still blocked.
// The title must remain renamed without relying on the refetch to repair it.
await expect(threadItem).toContainText(RENAMED_TITLE);
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
await freshMetadataRequestStarted;
releaseFreshMetadataResponse();
await freshMetadataResponseCompleted;
await expect(threadItem).toContainText(RENAMED_TITLE);
await expect(page.locator("header").getByText(RENAMED_TITLE)).toBeVisible();
await expect(page).toHaveTitle(`${RENAMED_TITLE} - DeerFlow`);
});

View File

@ -375,6 +375,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return updated;
};
const patchThreadTitle = (threadId: string, title: string) => {
let updated: MockThread | undefined;
threads = threads.map((thread) => {
if (thread.thread_id !== threadId) {
return thread;
}
updated = { ...thread, title };
return updated;
});
return updated;
};
// Auth — keep workspace tests independent from a real gateway session.
void page.route("**/api/v1/auth/me", (route) => {
if (route.request().method() === "GET") {
@ -1054,9 +1066,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
// Thread state — getState for individual thread
void page.route("**/api/langgraph/threads/*/state", (route) => {
const url = new URL(route.request().url());
const threadId = decodeURIComponent(url.pathname.split("/").at(-2) ?? "");
const matchingThread = threads.find(
(thread) => thread.thread_id === threadId,
);
if (route.request().method() === "GET") {
const url = route.request().url();
const matchingThread = threads.find((t) => url.includes(t.thread_id));
return route.fulfill({
status: 200,
contentType: "application/json",
@ -1086,6 +1102,33 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
}),
});
}
if (route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
values?: { title?: unknown };
};
const updated =
typeof body.values?.title === "string"
? patchThreadTitle(threadId, body.values.title)
: matchingThread;
if (!updated) {
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "Thread not found" }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
configurable: {
thread_id: threadId,
checkpoint_ns: "",
checkpoint_id: "mock-checkpoint",
},
}),
});
}
return route.fallback();
});

View File

@ -0,0 +1,74 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { afterEach, expect, rs, test } from "@rstest/core";
import { cleanup, render } from "@testing-library/react";
import type { ReactNode } from "react";
import { ThreadTitle } from "@/components/workspace/thread-title";
import type { AgentThreadState } from "@/core/threads";
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
t: {
pages: {
appName: "DeerFlow",
newChat: "New chat",
untitled: "Untitled",
},
},
}),
}));
rs.mock("@/components/workspace/chats", () => ({
useThreadChat: () => ({ isNewThread: false }),
}));
rs.mock("@/components/workspace/flip-display", () => ({
FlipDisplay: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
function makeThread(title: string): BaseStream<AgentThreadState> {
return {
isThreadLoading: false,
values: { title },
} as unknown as BaseStream<AgentThreadState>;
}
afterEach(() => {
cleanup();
document.title = "";
});
test("prefers the canonical metadata title over a stale stream title", () => {
const { container } = render(
<ThreadTitle
threadId="thread-1"
thread={makeThread("Stream title")}
canonicalTitle="Renamed title"
/>,
);
expect(container.textContent).toBe("Renamed title");
expect(document.title).toBe("Renamed title - DeerFlow");
});
test("falls back to the stream title when metadata is unavailable", () => {
const { container } = render(
<ThreadTitle threadId="thread-1" thread={makeThread("Stream title")} />,
);
expect(container.textContent).toBe("Stream title");
expect(document.title).toBe("Stream title - DeerFlow");
});
test("falls back to the stream title when metadata title is empty", () => {
const { container } = render(
<ThreadTitle
threadId="thread-1"
thread={makeThread("Stream title")}
canonicalTitle=""
/>,
);
expect(container.textContent).toBe("Stream title");
expect(document.title).toBe("Stream title - DeerFlow");
});

View File

@ -0,0 +1,170 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import {
type InfiniteData,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { act, cleanup, renderHook } from "@testing-library/react";
import type { PropsWithChildren } from "react";
const apiMocks = rs.hoisted(() => ({
updateState: rs.fn(),
}));
rs.mock("@/core/api", () => ({
getAPIClient: () => ({
threads: {
updateState: apiMocks.updateState,
},
}),
}));
import {
INFINITE_THREADS_QUERY_KEY_PREFIX,
useRenameThread,
} from "@/core/threads/hooks";
import { DEFAULT_THREAD_SEARCH_PARAMS } from "@/core/threads/thread-search-query";
import type { AgentThread } from "@/core/threads/types";
const THREAD_ID = "thread-1";
const ORIGINAL_TITLE = "Original title";
const RENAMED_TITLE = "Renamed title";
function makeThread(title: string): AgentThread {
return {
thread_id: THREAD_ID,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
metadata: {},
status: "idle",
values: { title },
} as unknown as AgentThread;
}
function createDelayedResult<T>(value: T) {
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let markCompleted!: () => void;
const completed = new Promise<void>((resolve) => {
markCompleted = resolve;
});
return {
completed,
queryFn: async () => {
markStarted();
await gate;
markCompleted();
return value;
},
release,
started,
};
}
function readSearchTitle(
queryClient: QueryClient,
queryKey: readonly unknown[],
) {
return queryClient.getQueryData<AgentThread[]>(queryKey)?.[0]?.values.title;
}
function readInfiniteSearchTitle(
queryClient: QueryClient,
queryKey: readonly unknown[],
) {
return queryClient.getQueryData<InfiniteData<AgentThread[]>>(queryKey)
?.pages[0]?.[0]?.values.title;
}
test("stale list responses cannot restore the old title after rename", async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const infiniteParams = {
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
};
const searchKey = [
"threads",
"search",
DEFAULT_THREAD_SEARCH_PARAMS,
] as const;
const infiniteSearchKey = [
...INFINITE_THREADS_QUERY_KEY_PREFIX,
infiniteParams,
] as const;
const originalThread = makeThread(ORIGINAL_TITLE);
const staleSearch = createDelayedResult([originalThread]);
const staleInfiniteSearch = createDelayedResult([originalThread]);
queryClient.setQueryData(searchKey, [originalThread]);
queryClient.setQueryData<InfiniteData<AgentThread[]>>(infiniteSearchKey, {
pages: [[originalThread]],
pageParams: [0],
});
const searchFetch = queryClient
.fetchQuery({
queryKey: searchKey,
queryFn: staleSearch.queryFn,
})
.catch(() => undefined);
const infiniteSearchFetch = queryClient
.fetchInfiniteQuery({
queryKey: infiniteSearchKey,
initialPageParam: 0,
queryFn: staleInfiniteSearch.queryFn,
getNextPageParam: () => undefined,
})
.catch(() => undefined);
await Promise.all([staleSearch.started, staleInfiniteSearch.started]);
apiMocks.updateState.mockResolvedValue(undefined);
const { result } = renderHook(() => useRenameThread(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
threadId: THREAD_ID,
title: RENAMED_TITLE,
});
});
expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE);
expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe(
RENAMED_TITLE,
);
staleSearch.release();
staleInfiniteSearch.release();
await Promise.all([staleSearch.completed, staleInfiniteSearch.completed]);
await Promise.all([searchFetch, infiniteSearchFetch]);
expect(readSearchTitle(queryClient, searchKey)).toBe(RENAMED_TITLE);
expect(readInfiniteSearchTitle(queryClient, infiniteSearchKey)).toBe(
RENAMED_TITLE,
);
queryClient.clear();
});
beforeEach(() => {
apiMocks.updateState.mockReset();
});
afterEach(() => {
cleanup();
});