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

View File

@ -1,7 +1,7 @@
"use client";
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@ -23,11 +23,9 @@ import {
} from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats";
// import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome";
@ -60,42 +58,25 @@ export default function ChatPage() {
setArtifacts,
select: selectArtifact,
selectedArtifact,
deselect: deselectArtifact,
setFullscreen: setArtifactsFullscreen,
fullscreen,
} = useArtifacts();
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } =
const { threadId, routeKind, viewMode, lifecycleMode, isMock } =
useThreadChat();
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
const shouldRenderHistory = !showWelcomeStyle;
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 isWelcomeView = viewMode === "welcome";
const isNewRoute = routeKind === "new";
const shouldRenderHistory = viewMode === "chat";
const [isThreadInitReady, setIsThreadInitReady] = useState(false);
const streamThreadId = useMemo(() => {
if (!safeThreadId) {
if (!threadId) {
return undefined;
}
// In /new flow, defer history loading until thread init is finished:
// delete -> create -> history.
if (isNewThread && !isThreadInitReady) {
if (lifecycleMode === "reset_on_entry" && !isThreadInitReady) {
return undefined;
}
return safeThreadId;
}, [isNewThread, isThreadInitReady, safeThreadId]);
return threadId;
}, [isThreadInitReady, lifecycleMode, threadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null);
const threadInitPromiseRef = useRef<Promise<void> | null>(null);
@ -135,30 +116,30 @@ export default function ChatPage() {
}, []);
useEffect(() => {
if (!isNewThread) {
warnedMissingThreadIdRef.current = false;
if (lifecycleMode === "preserve") {
initializedThreadRef.current = null;
threadInitPromiseRef.current = null;
setIsThreadInitReady(true);
return;
}
if (!safeThreadId) {
if (!warnedMissingThreadIdRef.current) {
warnedMissingThreadIdRef.current = true;
toast.error(t.chatPage.missingThreadIdForCreate);
}
if (lifecycleMode !== "reset_on_entry" || !threadId) {
initializedThreadRef.current = null;
threadInitPromiseRef.current = null;
setIsThreadInitReady(false);
return;
}
warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return;
initializedThreadRef.current = safeThreadId;
if (initializedThreadRef.current === threadId) return;
initializedThreadRef.current = threadId;
setIsThreadInitReady(false);
const initPromise = apiClient.threads
.delete(safeThreadId)
.delete(threadId)
.catch(() => undefined)
.then(() =>
apiClient.threads.create({
threadId: safeThreadId,
threadId,
ifExists: "do_nothing",
}),
)
@ -179,10 +160,9 @@ export default function ChatPage() {
});
}, [
apiClient,
isNewThread,
safeThreadId,
lifecycleMode,
threadId,
t.chatPage.createSessionFailed,
t.chatPage.missingThreadIdForCreate,
]);
// 监听宿主页 selectedSkill 消息
@ -190,21 +170,16 @@ export default function ChatPage() {
skillError: selectedSkillError,
clearSkillError: clearSelectedSkillError,
isBootstrapping: isSelectedSkillBootstrapping,
} = useSelectedSkillListener({ threadId: safeThreadId ?? null });
} = useSelectedSkillListener({ threadId: threadId ?? null });
// 对话行为控制器
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: streamThreadId,
context: settings.context,
createNewSession,
isMock,
// 发送消息后跳转的逻辑
onStart: (currentThreadId) => {
setIsNewThread(false);
// if (!shouldStayOnNewRoute) {
// Keep /new in history so router.back() can return to it.
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
// }
// history.pushState(null, "", pathOfThread(currentThreadId));
if (isNewRoute || viewMode === "welcome") {
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
}
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
@ -258,7 +233,7 @@ export default function ChatPage() {
]);
useEffect(() => {
const pageTitle = isNewThread
const pageTitle = isNewRoute
? t.pages.newChat
: thread.values?.title && thread.values.title !== "Untitled"
? thread.values.title
@ -269,7 +244,7 @@ export default function ChatPage() {
document.title = `${pageTitle} - ${t.pages.appName}`;
}
}, [
isNewThread,
isNewRoute,
t.common.loading,
t.pages.newChat,
t.pages.untitled,
@ -312,30 +287,30 @@ export default function ChatPage() {
if (isSelectedSkillBootstrapping) {
return;
}
if (isNewThread && !safeThreadId) {
if (lifecycleMode === "missing_thread_id") {
toast.error(t.chatPage.missingThreadIdForSend);
return;
}
if (isNewThread && safeThreadId) {
if (lifecycleMode === "reset_on_entry") {
await threadInitPromiseRef.current;
}
if (isNewThread && safeThreadId && !isThreadInitReady) {
if (lifecycleMode === "reset_on_entry" && !isThreadInitReady) {
return;
}
setHasSubmitted(true);
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
if (threadId && viewMode === "welcome") {
router.replace(`/workspace/chats/${threadId}?is_chatting=true`);
}
void sendMessage(safeThreadId, message);
void sendMessage(threadId, message);
},
[
isNewThread,
isThreadInitReady,
isSelectedSkillBootstrapping,
lifecycleMode,
router,
safeThreadId,
sendMessage,
showWelcomeStyle,
threadId,
viewMode,
t.chatPage.missingThreadIdForSend,
],
);
@ -343,24 +318,8 @@ export default function ChatPage() {
await thread.stop();
}, [thread]);
const resetNewSessionState = useCallback(() => {
setIsNewThread(true);
setHasSubmitted(false);
setHistoryCutoff(null);
setArtifacts([]);
deselectArtifact();
setArtifactsOpen(false);
setArtifactsFullscreen(false);
}, [
deselectArtifact,
setArtifacts,
setArtifactsFullscreen,
setArtifactsOpen,
setIsNewThread,
]);
return (
<ThreadContext.Provider value={{ threadId, thread }}>
<ThreadContext.Provider value={{ threadId: threadId ?? "", thread }}>
<div
className={cn(
"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
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",
showWelcomeStyle && !hasSubmitted ? "hidden" : "",
isWelcomeView && !hasSubmitted ? "hidden" : "",
)}
>
<div className="flex items-center justify-start overflow-hidden text-sm font-medium">
@ -466,7 +425,7 @@ export default function ChatPage() {
<main
className={cn(
"flex min-h-0 max-w-full grow flex-col",
showWelcomeStyle && !hasSubmitted
isWelcomeView && !hasSubmitted
? "bg-ws-surface-base"
: "bg-background",
)}
@ -475,9 +434,9 @@ export default function ChatPage() {
<MessageList
className={cn(
"size-full",
(!showWelcomeStyle || hasSubmitted) && "pt-[58px]",
(!isWelcomeView || hasSubmitted) && "pt-[58px]",
)}
threadId={threadId}
threadId={threadId ?? ""}
thread={thread}
messagesOverride={
shouldRenderHistory
@ -487,7 +446,7 @@ export default function ChatPage() {
: thread.messages.slice(historyCutoff)
}
paddingBottom={todoListCollapsed ? 160 : 280}
showScrollToBottomButton={!showWelcomeStyle}
showScrollToBottomButton={!isWelcomeView}
scrollButtonClassName="bottom-[112px]"
/>
</div>
@ -508,7 +467,7 @@ export default function ChatPage() {
<div
className={cn(
"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",
)}
>
@ -516,7 +475,7 @@ export default function ChatPage() {
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
threadId={threadId ?? ""}
/>
) : (
<div className="relative flex size-full justify-center px-[20px]">
@ -548,7 +507,7 @@ export default function ChatPage() {
<ArtifactFileList
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
files={sanitizedArtifacts}
threadId={threadId}
threadId={threadId ?? ""}
/>
</main>
</div>
@ -570,19 +529,19 @@ export default function ChatPage() {
<div
className={cn(
"pointer-events-auto relative w-full max-w-[720px]",
showWelcomeStyle &&
isWelcomeView &&
!hasSubmitted &&
"-translate-y-[calc(50vh-96px)]",
)}
>
{!(showWelcomeStyle && thread.isThreadLoading) ? (
{!(isWelcomeView && thread.isThreadLoading) ? (
<>
<InputBox
className={cn("w-full rounded-[20px] bg-ws-surface-elevated")}
threadId={threadId}
showWelcomeStyle={showWelcomeStyle}
threadId={threadId ?? ""}
isWelcomeView={isWelcomeView}
hasSubmitted={hasSubmitted}
autoFocus={showWelcomeStyle}
autoFocus={isWelcomeView}
status={
thread.error
? "error"
@ -593,7 +552,7 @@ export default function ChatPage() {
context={settings.context}
extraHeader={
<div className="flex flex-col gap-4">
{showWelcomeStyle && !hasSubmitted && (
{isWelcomeView && !hasSubmitted && (
<Welcome mode={settings.context.mode} />
)}
</div>
@ -602,7 +561,7 @@ export default function ChatPage() {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping ||
isUploading ||
(isNewThread && !safeThreadId)
lifecycleMode === "missing_thread_id"
}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
@ -657,14 +616,13 @@ export default function ChatPage() {
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: false,
});
// 始终复用 query 中的 thread_id。
const nextQuery = new URLSearchParams();
if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId);
}
// /workspace/chats/${threadId}?is_chatting=false
// NOTE: `/workspace/chats/new?thread_id=...` is the
// historical "reset welcome" route. Keep it until the chat
// URL contract is redesigned.
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 { Button } from "@/components/ui/button";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { env } from "@/env";
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";
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() {
const pathname = usePathname();
const params = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const threadIdFromSearchParams = searchParams.get("thread_id")?.trim();
// showWelcomeStyle的子判断
const isChattingFromQuery = (() => {
const isChatting = searchParams.get("is_chatting");
return isChatting === "true";
})();
// 兜底:当 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,
// NOTE: The current chat URLs are a historical compatibility layer.
// `/workspace/chats/new?thread_id=...` and `?is_chatting=` should eventually
// be redesigned, but this hook remains the single source of truth for the
// current contract until that URL cleanup happens.
const routeState = resolveThreadChatRouteState({
pathname,
searchParams,
isChattingFromQuery,
threadId,
threadIdFromPathOrParams,
]);
const isMock = searchParams.get("mock") === "true";
paramsThreadId: params?.thread_id,
queryThreadId: searchParams.get("thread_id"),
isChatting: searchParams.get("is_chatting"),
});
return {
threadId,
isNewThread,
setIsNewThread,
isMock,
showWelcomeStyle,
...routeState,
isMock: searchParams.get("mock") === "true",
};
}
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,
context,
extraHeader,
showWelcomeStyle,
isWelcomeView,
hasSubmitted,
initialValue,
onContextChange,
@ -237,7 +237,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
};
extraHeader?: React.ReactNode;
showWelcomeStyle: boolean;
isWelcomeView: boolean;
hasSubmitted?: boolean;
initialValue?: string;
onContextChange?: (
@ -294,14 +294,14 @@ export function InputBox({
const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false);
const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
// Welcome view 时禁用收缩,始终保持展开(除非已提交消息)
const effectiveIsFocused =
((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused;
((isWelcomeView ?? false) && !hasSubmitted) || isFocused;
const shouldShowSuggestionList =
showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill";
isWelcomeView && !hasSubmitted && searchParams.get("mode") !== "skill";
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted) {
if (!isWelcomeView || hasSubmitted) {
setIsInputToolsTourReady(false);
return;
}
@ -317,7 +317,7 @@ export function InputBox({
});
return () => window.cancelAnimationFrame(frameId);
}, [
showWelcomeStyle,
isWelcomeView,
hasSubmitted,
shouldShowSuggestionList,
iframeSkill.isBootstrapping,
@ -325,7 +325,7 @@ export function InputBox({
]);
useEffect(() => {
if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) {
if (!isWelcomeView || hasSubmitted || !isInputToolsTourReady) {
setIsInputToolsTourOpen(false);
return;
}
@ -337,7 +337,7 @@ export function InputBox({
if (!hasSeenTourForCurrentThread) {
setIsInputToolsTourOpen(true);
}
}, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady, threadId]);
}, [isWelcomeView, hasSubmitted, isInputToolsTourReady, threadId]);
const finishInputToolsTour = useCallback(() => {
const seenState = parseInputToolsTourSeenState(
@ -515,7 +515,7 @@ export function InputBox({
return;
}
setIsFocused(false);
if (showWelcomeStyle) {
if (isWelcomeView) {
sendToParent({
type: POST_MESSAGE_TYPES.IS_CHATTING,
isChatting: true,
@ -529,7 +529,7 @@ export function InputBox({
setReferences([]);
},
[
showWelcomeStyle,
isWelcomeView,
onSubmit,
onStop,
references,
@ -760,7 +760,7 @@ export function InputBox({
return () => controller.abort();
*/
}, [disabled, showWelcomeStyle, threadId]);
}, [disabled, isWelcomeView, threadId]);
return (
<div
@ -816,7 +816,7 @@ export function InputBox({
inputGroupClassName={cn(
"border-0 rounded-[20px] backdrop-blur-sm",
"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)]!",
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)}
@ -839,7 +839,7 @@ export function InputBox({
)}
disabled={isInputDisabled}
placeholder={
showWelcomeStyle
isWelcomeView
? t.inputBox.welcomePlaceholder
: t.inputBox.chatPlaceholder
}
@ -962,7 +962,7 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{showWelcomeStyle && (
{isWelcomeView && (
<div ref={historyButtonTourRef} className="shrink-0 h-full">
<HistoryButton
router={router}
@ -970,7 +970,7 @@ export function InputBox({
/>
</div>
)}
{!showWelcomeStyle && (
{!isWelcomeView && (
<div className="shrink-0 h-full">
<ExitChattingButton
router={router}
@ -1047,7 +1047,7 @@ export function InputBox({
)}
{!disabled &&
!showWelcomeStyle &&
!isWelcomeView &&
!followupsHidden &&
(followupsLoading || followups.length > 0) && (
<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;
}) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const { routeKind } = useThreadChat();
const isNewRoute = routeKind === "new";
useEffect(() => {
if (!thread) {
return;
@ -27,7 +28,7 @@ export function ThreadTitle({
if (thread.values?.title) {
_title = thread.values.title;
} else if (isNewThread) {
} else if (isNewRoute) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
@ -36,7 +37,7 @@ export function ThreadTitle({
document.title = `${_title} - ${t.pages.appName}`;
}
}, [
isNewThread,
isNewRoute,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,

View File

@ -2,33 +2,10 @@ import type { Message } from "@langchain/langgraph-sdk";
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) {
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) {
if (typeof message.content === "string") {
return message.content;