refactor(chat): 收敛线程会话路由状态

This commit is contained in:
肖应宇 2026-05-02 19:40:21 +08:00
parent f584c3e53b
commit 7853a6a97d
9 changed files with 292 additions and 240 deletions

View File

@ -39,20 +39,20 @@ export default function AgentChatPage() {
const { agent } = useAgent(agent_name); const { agent } = useAgent(agent_name);
const { threadId, isNewThread, setIsNewThread } = useThreadChat(); const { threadId, routeKind } = useThreadChat();
const isNewRoute = routeKind === "new";
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewRoute ? undefined : threadId,
context: { ...settings.context, agent_name: agent_name }, context: { ...settings.context, agent_name: agent_name },
onStart: () => { onStart: (currentThreadId) => {
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. // ! 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( history.replaceState(
null, null,
"", "",
`/workspace/agents/${agent_name}/chats/${threadId}`, `/workspace/agents/${agent_name}/chats/${currentThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {
@ -89,13 +89,13 @@ export default function AgentChatPage() {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM; MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM;
return ( return (
<ThreadContext.Provider value={{ thread, threadId }}> <ThreadContext.Provider value={{ thread, threadId: threadId ?? "" }}>
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between"> <div className="relative flex size-full min-h-0 justify-between">
<header <header
className={cn( className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4", "absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
isNewThread isNewRoute
? "bg-background/0 backdrop-blur-none" ? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur", : "bg-background/80 shadow-xs backdrop-blur",
)} )}
@ -109,7 +109,7 @@ export default function AgentChatPage() {
</div> </div>
<div className="flex w-full items-center text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} /> <ThreadTitle threadId={threadId ?? ""} thread={thread} />
</div> </div>
<div className="mr-4 flex items-center"> <div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}> <Tooltip content={t.agents.newChat}>
@ -124,7 +124,7 @@ export default function AgentChatPage() {
</Button> </Button>
</Tooltip> </Tooltip>
<TokenUsageIndicator messages={thread.messages} /> <TokenUsageIndicator messages={thread.messages} />
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId ?? ""} />
<ArtifactTrigger /> <ArtifactTrigger />
</div> </div>
</header> </header>
@ -132,8 +132,8 @@ export default function AgentChatPage() {
<main className="flex min-h-0 max-w-full grow flex-col"> <main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
<MessageList <MessageList
className={cn("size-full", !isNewThread && "pt-10")} className={cn("size-full", !isNewRoute && "pt-10")}
threadId={threadId} threadId={threadId ?? ""}
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
/> />
@ -143,8 +143,8 @@ export default function AgentChatPage() {
<div <div
className={cn( className={cn(
"relative w-full", "relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]", isNewRoute && "-translate-y-[calc(50vh-96px)]",
isNewThread isNewRoute
? "max-w-(--container-width-sm)" ? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)", : "max-w-(--container-width-md)",
)} )}
@ -163,10 +163,10 @@ export default function AgentChatPage() {
<InputBox <InputBox
className={cn("bg-background/5 w-full -translate-y-4")} className={cn("bg-background/5 w-full -translate-y-4")}
threadId={threadId} threadId={threadId ?? ""}
autoFocus={isNewThread} autoFocus={isNewRoute}
showWelcomeStyle={isNewThread} isWelcomeView={isNewRoute}
hasSubmitted={!isNewThread} hasSubmitted={!isNewRoute}
status={ status={
thread.error thread.error
? "error" ? "error"
@ -176,7 +176,7 @@ export default function AgentChatPage() {
} }
context={settings.context} context={settings.context}
extraHeader={ extraHeader={
isNewThread && ( isNewRoute && (
<AgentWelcome agent={agent} agentName={agent_name} /> <AgentWelcome agent={agent} agentName={agent_name} />
) )
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Ticker } from "@tombcato/smart-ticker"; import { Ticker } from "@tombcato/smart-ticker";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { FilesIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -23,11 +23,9 @@ import {
} from "@/components/workspace/artifacts"; } from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats"; import { useThreadChat } from "@/components/workspace/chats";
// import { DevTodoList } from "@/components/workspace/dev-todo-list"; // import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
@ -60,42 +58,25 @@ export default function ChatPage() {
setArtifacts, setArtifacts,
select: selectArtifact, select: selectArtifact,
selectedArtifact, selectedArtifact,
deselect: deselectArtifact,
setFullscreen: setArtifactsFullscreen,
fullscreen, fullscreen,
} = useArtifacts(); } = useArtifacts();
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } = const { threadId, routeKind, viewMode, lifecycleMode, isMock } =
useThreadChat(); useThreadChat();
const isWelcomeView = viewMode === "welcome";
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。 const isNewRoute = routeKind === "new";
const shouldRenderHistory = !showWelcomeStyle; const shouldRenderHistory = viewMode === "chat";
const safeThreadId = useMemo(() => {
if (!threadId || threadId === "new") {
return undefined;
}
return threadId;
}, [threadId]);
// `/new` + `thread_id` now reuses the pre-created thread, instead of creating
// a new session on first submit.
const createNewSession = useMemo(
() => isNewThread && !safeThreadId,
[isNewThread, safeThreadId],
);
const [isThreadInitReady, setIsThreadInitReady] = useState(false); const [isThreadInitReady, setIsThreadInitReady] = useState(false);
const streamThreadId = useMemo(() => { const streamThreadId = useMemo(() => {
if (!safeThreadId) { if (!threadId) {
return undefined; return undefined;
} }
// In /new flow, defer history loading until thread init is finished: if (lifecycleMode === "reset_on_entry" && !isThreadInitReady) {
// delete -> create -> history.
if (isNewThread && !isThreadInitReady) {
return undefined; return undefined;
} }
return safeThreadId; return threadId;
}, [isNewThread, isThreadInitReady, safeThreadId]); }, [isThreadInitReady, lifecycleMode, threadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]); const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null); const initializedThreadRef = useRef<string | null>(null);
const threadInitPromiseRef = useRef<Promise<void> | null>(null); const threadInitPromiseRef = useRef<Promise<void> | null>(null);
@ -135,30 +116,30 @@ export default function ChatPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isNewThread) { if (lifecycleMode === "preserve") {
warnedMissingThreadIdRef.current = false; initializedThreadRef.current = null;
threadInitPromiseRef.current = null;
setIsThreadInitReady(true); setIsThreadInitReady(true);
return; return;
} }
if (!safeThreadId) {
if (!warnedMissingThreadIdRef.current) { if (lifecycleMode !== "reset_on_entry" || !threadId) {
warnedMissingThreadIdRef.current = true; initializedThreadRef.current = null;
toast.error(t.chatPage.missingThreadIdForCreate); threadInitPromiseRef.current = null;
}
setIsThreadInitReady(false); setIsThreadInitReady(false);
return; return;
} }
warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return; if (initializedThreadRef.current === threadId) return;
initializedThreadRef.current = safeThreadId; initializedThreadRef.current = threadId;
setIsThreadInitReady(false); setIsThreadInitReady(false);
const initPromise = apiClient.threads const initPromise = apiClient.threads
.delete(safeThreadId) .delete(threadId)
.catch(() => undefined) .catch(() => undefined)
.then(() => .then(() =>
apiClient.threads.create({ apiClient.threads.create({
threadId: safeThreadId, threadId,
ifExists: "do_nothing", ifExists: "do_nothing",
}), }),
) )
@ -179,10 +160,9 @@ export default function ChatPage() {
}); });
}, [ }, [
apiClient, apiClient,
isNewThread, lifecycleMode,
safeThreadId, threadId,
t.chatPage.createSessionFailed, t.chatPage.createSessionFailed,
t.chatPage.missingThreadIdForCreate,
]); ]);
// 监听宿主页 selectedSkill 消息 // 监听宿主页 selectedSkill 消息
@ -190,21 +170,16 @@ export default function ChatPage() {
skillError: selectedSkillError, skillError: selectedSkillError,
clearSkillError: clearSelectedSkillError, clearSkillError: clearSelectedSkillError,
isBootstrapping: isSelectedSkillBootstrapping, isBootstrapping: isSelectedSkillBootstrapping,
} = useSelectedSkillListener({ threadId: safeThreadId ?? null }); } = useSelectedSkillListener({ threadId: threadId ?? null });
// 对话行为控制器 // 对话行为控制器
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: streamThreadId, threadId: streamThreadId,
context: settings.context, context: settings.context,
createNewSession,
isMock, isMock,
// 发送消息后跳转的逻辑
onStart: (currentThreadId) => { onStart: (currentThreadId) => {
setIsNewThread(false); if (isNewRoute || viewMode === "welcome") {
// if (!shouldStayOnNewRoute) { router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
// Keep /new in history so router.back() can return to it. }
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
// }
// history.pushState(null, "", pathOfThread(currentThreadId));
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
@ -258,7 +233,7 @@ export default function ChatPage() {
]); ]);
useEffect(() => { useEffect(() => {
const pageTitle = isNewThread const pageTitle = isNewRoute
? t.pages.newChat ? t.pages.newChat
: thread.values?.title && thread.values.title !== "Untitled" : thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title ? thread.values.title
@ -269,7 +244,7 @@ export default function ChatPage() {
document.title = `${pageTitle} - ${t.pages.appName}`; document.title = `${pageTitle} - ${t.pages.appName}`;
} }
}, [ }, [
isNewThread, isNewRoute,
t.common.loading, t.common.loading,
t.pages.newChat, t.pages.newChat,
t.pages.untitled, t.pages.untitled,
@ -312,30 +287,30 @@ export default function ChatPage() {
if (isSelectedSkillBootstrapping) { if (isSelectedSkillBootstrapping) {
return; return;
} }
if (isNewThread && !safeThreadId) { if (lifecycleMode === "missing_thread_id") {
toast.error(t.chatPage.missingThreadIdForSend); toast.error(t.chatPage.missingThreadIdForSend);
return; return;
} }
if (isNewThread && safeThreadId) { if (lifecycleMode === "reset_on_entry") {
await threadInitPromiseRef.current; await threadInitPromiseRef.current;
} }
if (isNewThread && safeThreadId && !isThreadInitReady) { if (lifecycleMode === "reset_on_entry" && !isThreadInitReady) {
return; return;
} }
setHasSubmitted(true); setHasSubmitted(true);
if (safeThreadId && (isNewThread || showWelcomeStyle)) { if (threadId && viewMode === "welcome") {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`); router.replace(`/workspace/chats/${threadId}?is_chatting=true`);
} }
void sendMessage(safeThreadId, message); void sendMessage(threadId, message);
}, },
[ [
isNewThread,
isThreadInitReady, isThreadInitReady,
isSelectedSkillBootstrapping, isSelectedSkillBootstrapping,
lifecycleMode,
router, router,
safeThreadId,
sendMessage, sendMessage,
showWelcomeStyle, threadId,
viewMode,
t.chatPage.missingThreadIdForSend, t.chatPage.missingThreadIdForSend,
], ],
); );
@ -343,24 +318,8 @@ export default function ChatPage() {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
const resetNewSessionState = useCallback(() => {
setIsNewThread(true);
setHasSubmitted(false);
setHistoryCutoff(null);
setArtifacts([]);
deselectArtifact();
setArtifactsOpen(false);
setArtifactsFullscreen(false);
}, [
deselectArtifact,
setArtifacts,
setArtifactsFullscreen,
setArtifactsOpen,
setIsNewThread,
]);
return ( return (
<ThreadContext.Provider value={{ threadId, thread }}> <ThreadContext.Provider value={{ threadId: threadId ?? "", thread }}>
<div <div
className={cn( className={cn(
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out", "m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
@ -379,7 +338,7 @@ export default function ChatPage() {
<header <header
className={cn( className={cn(
"bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out", "bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out",
showWelcomeStyle && !hasSubmitted ? "hidden" : "", isWelcomeView && !hasSubmitted ? "hidden" : "",
)} )}
> >
<div className="flex items-center justify-start overflow-hidden text-sm font-medium"> <div className="flex items-center justify-start overflow-hidden text-sm font-medium">
@ -466,7 +425,7 @@ export default function ChatPage() {
<main <main
className={cn( className={cn(
"flex min-h-0 max-w-full grow flex-col", "flex min-h-0 max-w-full grow flex-col",
showWelcomeStyle && !hasSubmitted isWelcomeView && !hasSubmitted
? "bg-ws-surface-base" ? "bg-ws-surface-base"
: "bg-background", : "bg-background",
)} )}
@ -475,9 +434,9 @@ export default function ChatPage() {
<MessageList <MessageList
className={cn( className={cn(
"size-full", "size-full",
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]", (!isWelcomeView || hasSubmitted) && "pt-[58px]",
)} )}
threadId={threadId} threadId={threadId ?? ""}
thread={thread} thread={thread}
messagesOverride={ messagesOverride={
shouldRenderHistory shouldRenderHistory
@ -487,7 +446,7 @@ export default function ChatPage() {
: thread.messages.slice(historyCutoff) : thread.messages.slice(historyCutoff)
} }
paddingBottom={todoListCollapsed ? 160 : 280} paddingBottom={todoListCollapsed ? 160 : 280}
showScrollToBottomButton={!showWelcomeStyle} showScrollToBottomButton={!isWelcomeView}
scrollButtonClassName="bottom-[112px]" scrollButtonClassName="bottom-[112px]"
/> />
</div> </div>
@ -508,7 +467,7 @@ export default function ChatPage() {
<div <div
className={cn( className={cn(
"h-full w-full transition-transform duration-300 ease-in-out", "h-full w-full transition-transform duration-300 ease-in-out",
showWelcomeStyle && !hasSubmitted ? "translate-x-0" : "", isWelcomeView && !hasSubmitted ? "translate-x-0" : "",
artifactPanelOpen ? "translate-x-0" : "translate-x-full", artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)} )}
> >
@ -516,7 +475,7 @@ export default function ChatPage() {
<ArtifactFileDetail <ArtifactFileDetail
className="size-full" className="size-full"
filepath={selectedArtifact} filepath={selectedArtifact}
threadId={threadId} threadId={threadId ?? ""}
/> />
) : ( ) : (
<div className="relative flex size-full justify-center px-[20px]"> <div className="relative flex size-full justify-center px-[20px]">
@ -548,7 +507,7 @@ export default function ChatPage() {
<ArtifactFileList <ArtifactFileList
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]" className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
files={sanitizedArtifacts} files={sanitizedArtifacts}
threadId={threadId} threadId={threadId ?? ""}
/> />
</main> </main>
</div> </div>
@ -570,19 +529,19 @@ export default function ChatPage() {
<div <div
className={cn( className={cn(
"pointer-events-auto relative w-full max-w-[720px]", "pointer-events-auto relative w-full max-w-[720px]",
showWelcomeStyle && isWelcomeView &&
!hasSubmitted && !hasSubmitted &&
"-translate-y-[calc(50vh-96px)]", "-translate-y-[calc(50vh-96px)]",
)} )}
> >
{!(showWelcomeStyle && thread.isThreadLoading) ? ( {!(isWelcomeView && thread.isThreadLoading) ? (
<> <>
<InputBox <InputBox
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")} className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
threadId={threadId} threadId={threadId ?? ""}
showWelcomeStyle={showWelcomeStyle} isWelcomeView={isWelcomeView}
hasSubmitted={hasSubmitted} hasSubmitted={hasSubmitted}
autoFocus={showWelcomeStyle} autoFocus={isWelcomeView}
status={ status={
thread.error thread.error
? "error" ? "error"
@ -593,7 +552,7 @@ export default function ChatPage() {
context={settings.context} context={settings.context}
extraHeader={ extraHeader={
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{showWelcomeStyle && !hasSubmitted && ( {isWelcomeView && !hasSubmitted && (
<Welcome mode={settings.context.mode} /> <Welcome mode={settings.context.mode} />
)} )}
</div> </div>
@ -602,7 +561,7 @@ export default function ChatPage() {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping || isSelectedSkillBootstrapping ||
isUploading || isUploading ||
(isNewThread && !safeThreadId) lifecycleMode === "missing_thread_id"
} }
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -657,14 +616,13 @@ export default function ChatPage() {
type: POST_MESSAGE_TYPES.IS_CHATTING, type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false, isChatting: false,
}); });
// 始终复用 query 中的 thread_id。 // NOTE: `/workspace/chats/new?thread_id=...` is the
const nextQuery = new URLSearchParams(); // historical "reset welcome" route. Keep it until the chat
if (threadId && threadId !== "new") { // URL contract is redesigned.
nextQuery.set("thread_id", threadId);
}
// /workspace/chats/${threadId}?is_chatting=false
router.replace( router.replace(
`/workspace/chats/new?thread_id=${threadId}`, threadId
? `/workspace/chats/new?thread_id=${threadId}`
: "/workspace/chats/new",
); );
}} }}
> >

View File

@ -5,12 +5,12 @@ import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
const { normalizeThreadId, resolveThreadChatRouteState } = await import(
new URL("./thread-chat-route.ts", import.meta.url).href
);
void test("resolveThreadChatRouteState treats /chats/new as invalid new", () => {
const result = resolveThreadChatRouteState({
pathname: "/workspace/chats/new",
paramsThreadId: "new",
queryThreadId: null,
isChatting: null,
});
assert.deepEqual(result, {
threadId: undefined,
routeKind: "new",
viewMode: "welcome",
lifecycleMode: "missing_thread_id",
});
});
void test("resolveThreadChatRouteState treats /chats/new?thread_id=T1 as reset welcome", () => {
const result = resolveThreadChatRouteState({
pathname: "/workspace/chats/new",
paramsThreadId: "new",
queryThreadId: "T1",
isChatting: null,
});
assert.deepEqual(result, {
threadId: "T1",
routeKind: "new",
viewMode: "welcome",
lifecycleMode: "reset_on_entry",
});
});
void test("resolveThreadChatRouteState treats /chats/:id without is_chatting as preserve welcome", () => {
const result = resolveThreadChatRouteState({
pathname: "/workspace/chats/T1",
paramsThreadId: "T1",
queryThreadId: null,
isChatting: null,
});
assert.deepEqual(result, {
threadId: "T1",
routeKind: "thread",
viewMode: "welcome",
lifecycleMode: "preserve",
});
});
void test("resolveThreadChatRouteState treats /chats/:id?is_chatting=false as preserve welcome", () => {
const result = resolveThreadChatRouteState({
pathname: "/workspace/chats/T1",
paramsThreadId: "T1",
queryThreadId: null,
isChatting: "false",
});
assert.deepEqual(result, {
threadId: "T1",
routeKind: "thread",
viewMode: "welcome",
lifecycleMode: "preserve",
});
});
void test("resolveThreadChatRouteState treats /chats/:id?is_chatting=true as chat view", () => {
const result = resolveThreadChatRouteState({
pathname: "/workspace/chats/T1",
paramsThreadId: "T1",
queryThreadId: null,
isChatting: "true",
});
assert.deepEqual(result, {
threadId: "T1",
routeKind: "thread",
viewMode: "chat",
lifecycleMode: "preserve",
});
});
void test("normalizeThreadId drops reserved and empty values", () => {
assert.equal(normalizeThreadId(""), undefined);
assert.equal(normalizeThreadId("new"), undefined);
assert.equal(normalizeThreadId(" undefined "), undefined);
assert.equal(normalizeThreadId(" null "), undefined);
assert.equal(normalizeThreadId(" T1 "), "T1");
});

View File

@ -0,0 +1,77 @@
export type RouteKind = "new" | "thread";
export type ViewMode = "welcome" | "chat";
export type LifecycleMode =
| "missing_thread_id"
| "reset_on_entry"
| "preserve";
export type ThreadChatRouteState = {
threadId?: string;
routeKind: RouteKind;
viewMode: ViewMode;
lifecycleMode: LifecycleMode;
};
export type ResolveThreadChatRouteStateInput = {
pathname: string;
paramsThreadId?: string | null;
queryThreadId?: string | null;
isChatting?: string | null;
};
export function resolveThreadChatRouteState({
pathname,
paramsThreadId,
queryThreadId,
isChatting,
}: ResolveThreadChatRouteStateInput): ThreadChatRouteState {
const pathThreadId =
paramsThreadId?.trim() ?? extractThreadIdFromPathname(pathname);
const routeKind: RouteKind = pathThreadId === "new" ? "new" : "thread";
const threadId =
routeKind === "new"
? normalizeThreadId(queryThreadId)
: normalizeThreadId(pathThreadId);
const viewMode: ViewMode =
routeKind === "new" ? "welcome" : isChatting === "true" ? "chat" : "welcome";
const lifecycleMode: LifecycleMode =
routeKind === "new"
? threadId
? "reset_on_entry"
: "missing_thread_id"
: "preserve";
return {
threadId,
routeKind,
viewMode,
lifecycleMode,
};
}
export function normalizeThreadId(value?: string | null): string | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (
normalized.length === 0 ||
normalized === "new" ||
normalized === "undefined" ||
normalized === "null"
) {
return undefined;
}
return value.trim();
}
export function extractThreadIdFromPathname(
pathname: string,
): string | undefined {
const parts = pathname.split("?")[0]?.split("/") ?? [];
const idx = parts.lastIndexOf("chats");
if (idx >= 0 && parts.length > idx + 1) {
return parts[idx + 1];
}
return undefined;
}

View File

@ -1,88 +1,33 @@
"use client"; "use client";
import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { resolveThreadChatRouteState } from "./thread-chat-route";
export type {
LifecycleMode,
RouteKind,
ThreadChatRouteState,
ViewMode,
} from "./thread-chat-route";
export function useThreadChat() { export function useThreadChat() {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams<{ thread_id: string }>(); const params = useParams<{ thread_id: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const threadIdFromSearchParams = searchParams.get("thread_id")?.trim(); // NOTE: The current chat URLs are a historical compatibility layer.
// showWelcomeStyle的子判断 // `/workspace/chats/new?thread_id=...` and `?is_chatting=` should eventually
const isChattingFromQuery = (() => { // be redesigned, but this hook remains the single source of truth for the
const isChatting = searchParams.get("is_chatting"); // current contract until that URL cleanup happens.
return isChatting === "true"; const routeState = resolveThreadChatRouteState({
})();
// 兜底:当 params 还未就绪时,从 pathname 解析 thread_id。
const threadIdFromPathname = (() => {
const parts = pathname.split("?")[0]?.split("/") ?? [];
const idx = parts.lastIndexOf("chats");
if (idx >= 0 && parts.length > idx + 1) {
return parts[idx + 1];
}
return undefined;
})();
const rawPathThreadId = params?.thread_id ?? threadIdFromPathname;
const isNewRoute = rawPathThreadId === "new";
const threadIdFromPathOrParams = isNewRoute
? normalizeThreadId(threadIdFromSearchParams)
: normalizeThreadId(rawPathThreadId);
// console.log("[useThreadChat] pathname", pathname);
// console.log("[useThreadChat] params.thread_id", params?.thread_id);
// console.log("[useThreadChat] threadIdFromPathname", threadIdFromPathname);
// console.log("[useThreadChat] threadIdFromPath", threadIdFromPath);
// New session is only controlled by `/workspace/chats/new`.
const [isNewThread, setIsNewThread] = useState(() => isNewRoute);
const [showWelcomeStyle, setShowWelcomeStyle] = useState(() => {
return isNewRoute || !isChattingFromQuery;
});
// console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
const [threadId, setThreadId] = useState<string>(() => {
return threadIdFromPathOrParams ?? "";
});
useEffect(() => {
// 记住最近一次有效的 thread_id供下次加载兜底使用。
if (threadId && threadId !== "new" && typeof window !== "undefined") {
window.sessionStorage.setItem("workspace.thread_id", threadId);
}
setIsNewThread(isNewRoute);
// Prefer path thread id, fall back to query thread_id when path is /new.
setThreadId(threadIdFromPathOrParams ?? "");
setShowWelcomeStyle(isNewRoute || !isChattingFromQuery);
}, [
isNewRoute,
pathname, pathname,
searchParams, paramsThreadId: params?.thread_id,
isChattingFromQuery, queryThreadId: searchParams.get("thread_id"),
threadId, isChatting: searchParams.get("is_chatting"),
threadIdFromPathOrParams, });
]);
const isMock = searchParams.get("mock") === "true";
return { return {
threadId, ...routeState,
isNewThread, isMock: searchParams.get("mock") === "true",
setIsNewThread,
isMock,
showWelcomeStyle,
}; };
} }
function normalizeThreadId(value?: string | null): string | undefined {
if (!value) return undefined;
return isValidThreadId(value) ? value.trim() : undefined;
}
function isValidThreadId(value?: string | null): value is string {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return (
normalized.length > 0 &&
normalized !== "new" &&
normalized !== "undefined" &&
normalized !== "null"
);
}

View File

@ -218,7 +218,7 @@ export function InputBox({
status, status,
context, context,
extraHeader, extraHeader,
showWelcomeStyle, isWelcomeView,
hasSubmitted, hasSubmitted,
initialValue, initialValue,
onContextChange, onContextChange,
@ -237,7 +237,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
showWelcomeStyle: boolean; isWelcomeView: boolean;
hasSubmitted?: boolean; hasSubmitted?: boolean;
initialValue?: string; initialValue?: string;
onContextChange?: ( onContextChange?: (
@ -294,14 +294,14 @@ export function InputBox({
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false); const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps); const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息) // Welcome view 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused = const effectiveIsFocused =
((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused; ((isWelcomeView ?? false) && !hasSubmitted) || isFocused;
const shouldShowSuggestionList = const shouldShowSuggestionList =
showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill"; isWelcomeView && !hasSubmitted && searchParams.get("mode") !== "skill";
useEffect(() => { useEffect(() => {
if (!showWelcomeStyle || hasSubmitted) { if (!isWelcomeView || hasSubmitted) {
setIsInputToolsTourReady(false); setIsInputToolsTourReady(false);
return; return;
} }
@ -317,7 +317,7 @@ export function InputBox({
}); });
return () => window.cancelAnimationFrame(frameId); return () => window.cancelAnimationFrame(frameId);
}, [ }, [
showWelcomeStyle, isWelcomeView,
hasSubmitted, hasSubmitted,
shouldShowSuggestionList, shouldShowSuggestionList,
iframeSkill.isBootstrapping, iframeSkill.isBootstrapping,
@ -325,7 +325,7 @@ export function InputBox({
]); ]);
useEffect(() => { useEffect(() => {
if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) { if (!isWelcomeView || hasSubmitted || !isInputToolsTourReady) {
setIsInputToolsTourOpen(false); setIsInputToolsTourOpen(false);
return; return;
} }
@ -337,7 +337,7 @@ export function InputBox({
if (!hasSeenTourForCurrentThread) { if (!hasSeenTourForCurrentThread) {
setIsInputToolsTourOpen(true); setIsInputToolsTourOpen(true);
} }
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady, threadId]); }, [isWelcomeView, hasSubmitted, isInputToolsTourReady, threadId]);
const finishInputToolsTour = useCallback(() => { const finishInputToolsTour = useCallback(() => {
const seenState = parseInputToolsTourSeenState( const seenState = parseInputToolsTourSeenState(
@ -515,7 +515,7 @@ export function InputBox({
return; return;
} }
setIsFocused(false); setIsFocused(false);
if (showWelcomeStyle) { if (isWelcomeView) {
sendToParent({ sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING, type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: true, isChatting: true,
@ -529,7 +529,7 @@ export function InputBox({
setReferences([]); setReferences([]);
}, },
[ [
showWelcomeStyle, isWelcomeView,
onSubmit, onSubmit,
onStop, onStop,
references, references,
@ -760,7 +760,7 @@ export function InputBox({
return () => controller.abort(); return () => controller.abort();
*/ */
}, [disabled, showWelcomeStyle, threadId]); }, [disabled, isWelcomeView, threadId]);
return ( return (
<div <div
@ -816,7 +816,7 @@ export function InputBox({
inputGroupClassName={cn( inputGroupClassName={cn(
"border-0 rounded-[20px] backdrop-blur-sm", "border-0 rounded-[20px] backdrop-blur-sm",
"transition-[height] duration-300 ease-out shadow-none ", "transition-[height] duration-300 ease-out shadow-none ",
!showWelcomeStyle && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]", !isWelcomeView && "h-[200px] shadow-[0_0_20px_0_rgba(0,0,0,0.10)]",
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!", hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
effectiveIsFocused ? "h-[200px]" : "h-[80px]", effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)} )}
@ -839,7 +839,7 @@ export function InputBox({
)} )}
disabled={isInputDisabled} disabled={isInputDisabled}
placeholder={ placeholder={
showWelcomeStyle isWelcomeView
? t.inputBox.welcomePlaceholder ? t.inputBox.welcomePlaceholder
: t.inputBox.chatPlaceholder : t.inputBox.chatPlaceholder
} }
@ -962,7 +962,7 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
{showWelcomeStyle && ( {isWelcomeView && (
<div ref={historyButtonTourRef} className="shrink-0 h-full"> <div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton <HistoryButton
router={router} router={router}
@ -970,7 +970,7 @@ export function InputBox({
/> />
</div> </div>
)} )}
{!showWelcomeStyle && ( {!isWelcomeView && (
<div className="shrink-0 h-full"> <div className="shrink-0 h-full">
<ExitChattingButton <ExitChattingButton
router={router} router={router}
@ -1047,7 +1047,7 @@ export function InputBox({
)} )}
{!disabled && {!disabled &&
!showWelcomeStyle && !isWelcomeView &&
!followupsHidden && !followupsHidden &&
(followupsLoading || followups.length > 0) && ( (followupsLoading || followups.length > 0) && (
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center"> <div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">

View File

@ -18,7 +18,8 @@ export function ThreadTitle({
threadTitle?: string; threadTitle?: string;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { isNewThread } = useThreadChat(); const { routeKind } = useThreadChat();
const isNewRoute = routeKind === "new";
useEffect(() => { useEffect(() => {
if (!thread) { if (!thread) {
return; return;
@ -27,7 +28,7 @@ export function ThreadTitle({
if (thread.values?.title) { if (thread.values?.title) {
_title = thread.values.title; _title = thread.values.title;
} else if (isNewThread) { } else if (isNewRoute) {
_title = t.pages.newChat; _title = t.pages.newChat;
} }
if (thread.isThreadLoading) { if (thread.isThreadLoading) {
@ -36,7 +37,7 @@ export function ThreadTitle({
document.title = `${_title} - ${t.pages.appName}`; document.title = `${_title} - ${t.pages.appName}`;
} }
}, [ }, [
isNewThread, isNewRoute,
t.pages.newChat, t.pages.newChat,
t.pages.untitled, t.pages.untitled,
t.pages.appName, t.pages.appName,

View File

@ -2,33 +2,10 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread } from "./types"; import type { AgentThread } from "./types";
export interface ThreadQueryIntentInput {
pathThreadId?: string | null;
queryThreadId?: string | null;
isNewRoute?: boolean;
}
export interface ThreadQueryIntent {
threadId: string | undefined;
isNewThread: boolean;
showWelcomeStyle: boolean;
}
export function pathOfThread(threadId: string) { export function pathOfThread(threadId: string) {
return `/workspace/chats/${threadId}`; return `/workspace/chats/${threadId}`;
} }
function normalizeThreadId(value?: string | null): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed || trimmed === "new") {
return undefined;
}
return trimmed;
}
export function textOfMessage(message: Message) { export function textOfMessage(message: Message) {
if (typeof message.content === "string") { if (typeof message.content === "string") {
return message.content; return message.content;