fix(frontend): preserve agent context in thread history routes (#1771)

* fix(frontend): preserve agent context in thread history routes

* fix(frontend): preserve agent thread fallback context

* style(frontend): format thread route utils test

---------

Co-authored-by: luoxiao6645 <luoxiao6645@gmail.com>
This commit is contained in:
2026-04-09 15:11:57 +08:00 committed by GitHub
parent 616caa92b1
commit 60e0abfdb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 78 additions and 21 deletions

View File

@ -48,10 +48,7 @@ export default function ChatsPage() {
<ScrollArea className="size-full py-4"> <ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col"> <div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => ( {filteredThreads?.map((thread) => (
<Link <Link key={thread.thread_id} href={pathOfThread(thread)}>
key={thread.thread_id}
href={pathOfThread(thread.thread_id)}
>
<div className="flex flex-col gap-2 border-b p-4"> <div className="flex flex-col gap-2 border-b p-4">
<div> <div>
<div>{titleOfThread(thread)}</div> <div>{titleOfThread(thread)}</div>

View File

@ -62,7 +62,11 @@ export function RecentChatList() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
useParams<{
thread_id: string;
agent_name?: string;
}>();
const { data: threads = [] } = useThreads(); const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread(); const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread(); const { mutate: renameThread } = useRenameThread();
@ -77,18 +81,20 @@ export function RecentChatList() {
deleteThread({ threadId }); deleteThread({ threadId });
if (threadId === threadIdFromPath) { if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((t) => t.thread_id === threadId); const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
let nextThreadId = "new"; let nextThreadPath = pathOfThread("new", {
agent_name: agentNameFromPath,
});
if (threadIndex > -1) { if (threadIndex > -1) {
if (threads[threadIndex + 1]) { if (threads[threadIndex + 1]) {
nextThreadId = threads[threadIndex + 1]!.thread_id; nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) { } else if (threads[threadIndex - 1]) {
nextThreadId = threads[threadIndex - 1]!.thread_id; nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
} }
} }
void router.push(`/workspace/chats/${nextThreadId}`); void router.push(nextThreadPath);
} }
}, },
[deleteThread, router, threadIdFromPath, threads], [agentNameFromPath, deleteThread, router, threadIdFromPath, threads],
); );
const handleRenameClick = useCallback( const handleRenameClick = useCallback(
@ -110,7 +116,7 @@ export function RecentChatList() {
}, [renameThread, renameThreadId, renameValue]); }, [renameThread, renameThreadId, renameValue]);
const handleShare = useCallback( const handleShare = useCallback(
async (threadId: string) => { async (thread: AgentThread) => {
// Always use Vercel URL for sharing so others can access // Always use Vercel URL for sharing so others can access
const VERCEL_URL = "https://deer-flow-v2.vercel.app"; const VERCEL_URL = "https://deer-flow-v2.vercel.app";
const isLocalhost = const isLocalhost =
@ -118,7 +124,7 @@ export function RecentChatList() {
window.location.hostname === "127.0.0.1"; window.location.hostname === "127.0.0.1";
// On localhost: use Vercel URL; On production: use current origin // On localhost: use Vercel URL; On production: use current origin
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin; const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`; const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
toast.success(t.clipboard.linkCopied); toast.success(t.clipboard.linkCopied);
@ -169,7 +175,7 @@ export function RecentChatList() {
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
{threads.map((thread) => { {threads.map((thread) => {
const isActive = pathOfThread(thread.thread_id) === pathname; const isActive = pathOfThread(thread) === pathname;
return ( return (
<SidebarMenuItem <SidebarMenuItem
key={thread.thread_id} key={thread.thread_id}
@ -179,7 +185,7 @@ export function RecentChatList() {
<div> <div>
<Link <Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden" className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread.thread_id)} href={pathOfThread(thread)}
> >
{titleOfThread(thread)} {titleOfThread(thread)}
</Link> </Link>
@ -211,7 +217,7 @@ export function RecentChatList() {
<span>{t.common.rename}</span> <span>{t.common.rename}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => handleShare(thread.thread_id)} onSelect={() => handleShare(thread)}
> >
<Share2 className="text-muted-foreground" /> <Share2 className="text-muted-foreground" />
<span>{t.common.share}</span> <span>{t.common.share}</span>

View File

@ -528,7 +528,7 @@ export function useThreads(
limit: 50, limit: 50,
sortBy: "updated_at", sortBy: "updated_at",
sortOrder: "desc", sortOrder: "desc",
select: ["thread_id", "updated_at", "values"], select: ["thread_id", "updated_at", "values", "context"],
}, },
) { ) {
const apiClient = getAPIClient(); const apiClient = getAPIClient();

View File

@ -9,8 +9,6 @@ export interface AgentThreadState extends Record<string, unknown> {
todos?: Todo[]; todos?: Todo[];
} }
export interface AgentThread extends Thread<AgentThreadState> {}
export interface AgentThreadContext extends Record<string, unknown> { export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string; thread_id: string;
model_name: string | undefined; model_name: string | undefined;
@ -20,3 +18,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
agent_name?: string; agent_name?: string;
} }
export interface AgentThread extends Thread<AgentThreadState> {
context?: AgentThreadContext;
}

View File

@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
const { pathOfThread } = await import(
new URL("./utils.ts", import.meta.url).href
);
void test("uses standard chat route when thread has no agent context", () => {
assert.equal(pathOfThread("thread-123"), "/workspace/chats/thread-123");
assert.equal(
pathOfThread({
thread_id: "thread-123",
}),
"/workspace/chats/thread-123",
);
});
void test("uses agent chat route when thread context has agent_name", () => {
assert.equal(
pathOfThread({
thread_id: "thread-123",
context: { agent_name: "researcher" },
}),
"/workspace/agents/researcher/chats/thread-123",
);
});
void test("uses provided context when pathOfThread is called with a thread id", () => {
assert.equal(
pathOfThread("thread-123", { agent_name: "ops agent" }),
"/workspace/agents/ops%20agent/chats/thread-123",
);
});

View File

@ -1,9 +1,28 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread } from "./types"; import type { AgentThread, AgentThreadContext } from "./types";
export function pathOfThread(threadId: string) { type ThreadRouteTarget =
return `/workspace/chats/${threadId}`; | string
| Pick<AgentThread, "thread_id" | "context">
| {
thread_id: string;
context?: Pick<AgentThreadContext, "agent_name"> | null;
};
export function pathOfThread(
thread: ThreadRouteTarget,
context?: Pick<AgentThreadContext, "agent_name"> | null,
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const agentName =
typeof thread === "string"
? context?.agent_name
: thread.context?.agent_name;
return agentName
? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${threadId}`
: `/workspace/chats/${threadId}`;
} }
export function textOfMessage(message: Message) { export function textOfMessage(message: Message) {