Compare commits

..

3 Commits

4 changed files with 119 additions and 36 deletions

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
@ -44,6 +44,7 @@ export default function ChatPage() {
useSpecificChatMode(); useSpecificChatMode();
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const router = useRouter();
const { const {
artifacts, artifacts,
open: artifactsOpen, open: artifactsOpen,
@ -51,9 +52,12 @@ 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 } = useThreadChat(); const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// History render rules: // History render rules:
// - /workspace/chats/{thread_id}: always render history // - /workspace/chats/{thread_id}: always render history
@ -62,9 +66,13 @@ export default function ChatPage() {
!isNewThread || !isNewThread ||
searchParams.get("xclaw_used")?.trim().toLowerCase() === "true"; searchParams.get("xclaw_used")?.trim().toLowerCase() === "true";
// Submission strategy: // Submission strategy: always reuse the thread id from query; never create new.
const createNewSession = false;
/*
// Original strategy:
// - isnew=false + thread_id: reuse existing thread (explicit request from URL)
// - xclaw_used=true: follow `isnew` (isnew=false => reuse existing thread) // - xclaw_used=true: follow `isnew` (isnew=false => reuse existing thread)
// - xclaw_used!=true: always create/start a new session (no history) // - otherwise: create/start a new session (no history)
const createNewSession = useMemo(() => { const createNewSession = useMemo(() => {
if (!isNewThread) { if (!isNewThread) {
return false; return false;
@ -81,6 +89,7 @@ export default function ChatPage() {
} }
return searchParams.get("isnew")?.trim().toLowerCase() !== "false"; return searchParams.get("isnew")?.trim().toLowerCase() !== "false";
}, [isNewThread, searchParams]); }, [isNewThread, searchParams]);
*/
const streamThreadId = useMemo(() => { const streamThreadId = useMemo(() => {
return isNewThread && createNewSession ? undefined : threadId; return isNewThread && createNewSession ? undefined : threadId;
}, [createNewSession, isNewThread, threadId]); }, [createNewSession, isNewThread, threadId]);
@ -99,7 +108,9 @@ export default function ChatPage() {
isMock, isMock,
onStart: (currentThreadId) => { onStart: (currentThreadId) => {
setIsNewThread(false); setIsNewThread(false);
history.replaceState(null, "", pathOfThread(currentThreadId)); // Keep /new in history so router.back() can return to it.
router.replace(`/workspace/chats/${currentThreadId}`);
// history.pushState(null, "", pathOfThread(currentThreadId));
}, },
onFinish: (state) => { onFinish: (state) => {
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
@ -205,6 +216,25 @@ 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,
]);
// shouldRenderHistory || historyCutoff === null
// console.log('shouldRenderHistory', shouldRenderHistory, 'historyCutoff', historyCutoff);
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
<div <div
@ -474,8 +504,15 @@ export default function ChatPage() {
type: POST_MESSAGE_TYPES.XCLAW_USED, type: POST_MESSAGE_TYPES.XCLAW_USED,
XClawUsed: false, XClawUsed: false,
}); });
// 使用完整页面刷新确保组件重新挂载isNewThread 为 true resetNewSessionState();
window.location.reload(); // 始终复用 query 中的 thread_id。
const nextQuery = new URLSearchParams();
nextQuery.set("isnew", "false");
nextQuery.set("xclaw_used", "false");
if (threadId && threadId !== "new") {
nextQuery.set("thread_id", threadId);
}
router.replace(`/workspace/chats/new?${nextQuery.toString()}`);
}} }}
> >
@ -513,7 +550,7 @@ export default function ChatPage() {
</DevDialog> </DevDialog>
{/* MARK: 开发测试iframe 通信功能测试面板 */} {/* MARK: 开发测试iframe 通信功能测试面板 */}
{process.env.NODE_ENV !== "production" && <IframeTestPanel />} {/* {process.env.NODE_ENV !== "production" && <IframeTestPanel />} */}
</div> </div>
</ThreadContext.Provider> </ThreadContext.Provider>
); );

View File

@ -468,7 +468,7 @@ export function ArtifactFileDetail({
{previewable && {previewable &&
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<div className="min-h-full mb-[150px] rounded-b-[10px] bg-white p-0 mb-0"> <div className="min-h-full mb-[180px] rounded-b-[10px] bg-white p-0 mb-0">
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={displayContent}
language={language ?? "text"} language={language ?? "text"}
@ -477,7 +477,7 @@ export function ArtifactFileDetail({
</div> </div>
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<div className="min-h-full mb-[150px] rounded-b-[10px] bg-white p-0 mb-0"> <div className="min-h-full mb-[180px] rounded-b-[10px] bg-white p-0 mb-0">
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none py-[20px]" className="size-full resize-none rounded-none border-none py-[20px]"
value={displayContent ?? ""} value={displayContent ?? ""}
@ -487,7 +487,7 @@ export function ArtifactFileDetail({
</div> </div>
)} )}
{!isCodeFile && ( {!isCodeFile && (
<div className="h-full mb-[150px] "> <div className="h-full mb-[180px] ">
<iframe <iframe
className="size-full border-0" className="size-full border-0"
srcDoc={artifactViewerSrcDoc} srcDoc={artifactViewerSrcDoc}

View File

@ -4,50 +4,96 @@ import { useParams, usePathname, useRouter, useSearchParams } from "next/navigat
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function useThreadChat() { export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const params = useParams<{ thread_id?: string }>();
// 兜底:当 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;
})();
// 优先使用 params如果是 "new",则回退到 pathname 解析出的 id。
const threadIdFromPath =
params?.thread_id !== "new" ? params?.thread_id : threadIdFromPathname;
// console.log("[useThreadChat] pathname", pathname);
// console.log("[useThreadChat] params.thread_id", params?.thread_id);
// console.log("[useThreadChat] threadIdFromPathname", threadIdFromPathname);
// console.log("[useThreadChat] threadIdFromPath", threadIdFromPath);
// 持久化兜底:用于处理首屏水合或 params 时序问题。
const readStoredThreadId = () => {
if (typeof window === "undefined") {
return undefined;
}
const stored = window.sessionStorage.getItem("workspace.thread_id");
return stored && stored !== "new" ? stored : undefined;
};
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// 读取 query 的 thread_id先用 hook必要时用 window 兜底)。
const readQueryThreadId = () => {
const fromHook = searchParams.get("thread_id")?.trim();
if (fromHook && fromHook !== "new") {
return fromHook;
}
if (typeof window === "undefined") {
return undefined;
}
const fromLocation = new URLSearchParams(window.location.search).get(
"thread_id",
);
if (fromLocation && fromLocation !== "new") {
return fromLocation.trim();
}
return undefined;
};
const queryThreadIdFromParams = readQueryThreadId();
// console.log("[useThreadChat] query.thread_id", queryThreadIdFromParams);
// 归一化:当值为 "new" 时,替换为 query 中的 thread_id如果存在
const normalizeThreadId = (value?: string | null) => {
if (!value) {
return undefined;
}
return value === "new" ? queryThreadIdFromParams : value;
};
const xClawUsedFromQuery = searchParams.get("xclaw_used"); const xClawUsedFromQuery = searchParams.get("xclaw_used");
const isNewFromQuery = const isNewFromQuery =
searchParams.get("isnew")?.trim().toLowerCase() === "false"; searchParams.get("isnew")?.trim().toLowerCase() === "false";
const queryThreadIdFromParams = searchParams.get("thread_id")?.trim(); const effectiveThreadIdFromPath =
const shouldUseQueryThreadId = normalizeThreadId(threadIdFromPath) ?? readStoredThreadId();
pathname.startsWith("/workspace/chats/") && // console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
!!queryThreadIdFromParams &&
(xClawUsedFromQuery === "true" || isNewFromQuery);
const [threadId, setThreadId] = useState(() => { const [threadId, setThreadId] = useState(() => {
if (threadIdFromPath === "new") { return effectiveThreadIdFromPath ?? undefined;
return shouldUseQueryThreadId ? queryThreadIdFromParams : undefined;
}
return threadIdFromPath;
}); });
// /new 或缺少 query 的 thread_id 时,视为新会话状态。 但是这个并不是新会话的意思,而是说当前处在对话状态。
const [isNewThread, setIsNewThread] = useState( const [isNewThread, setIsNewThread] = useState(
() => threadIdFromPath === "new", () => threadIdFromPath === "new" || !queryThreadIdFromParams,
); );
useEffect(() => { useEffect(() => {
// 记住最近一次有效的 thread_id供下次加载兜底使用。
if (threadId && threadId !== "new" && typeof window !== "undefined") {
window.sessionStorage.setItem("workspace.thread_id", threadId);
}
if (pathname.endsWith("/new")) { if (pathname.endsWith("/new")) {
setIsNewThread(true); setIsNewThread(true);
const nextQueryThreadId = searchParams.get("thread_id")?.trim(); const nextQueryThreadId = readQueryThreadId();
const nextIsNewFromQuery = const nextIsNewFromQuery =
searchParams.get("isnew")?.trim().toLowerCase() === "false"; searchParams.get("isnew")?.trim().toLowerCase() === "false";
const nextXClawUsed = searchParams.get("xclaw_used"); const nextXClawUsed = searchParams.get("xclaw_used");
const nextShouldUseQueryThreadId = setThreadId(nextQueryThreadId ?? undefined);
pathname.startsWith("/workspace/chats/") &&
!!nextQueryThreadId &&
(nextXClawUsed === "true" || nextIsNewFromQuery);
if (nextShouldUseQueryThreadId && nextQueryThreadId) {
router.replace(`/workspace/chats/${nextQueryThreadId}`);
return;
}
setThreadId(nextShouldUseQueryThreadId ? nextQueryThreadId : undefined);
return; return;
} }
setIsNewThread(false); setIsNewThread(false);
setThreadId(threadIdFromPath); // console.log("threadIdFromPath", threadIdFromPath, "normalized", normalizeThreadId(threadIdFromPath));
setThreadId(normalizeThreadId(threadIdFromPath));
}, [pathname, router, searchParams, threadIdFromPath]); }, [pathname, router, searchParams, threadIdFromPath]);
const isMock = searchParams.get("mock") === "true"; const isMock = searchParams.get("mock") === "true";
return { threadId, isNewThread, setIsNewThread, isMock }; return { threadId, isNewThread, setIsNewThread, isMock };

View File

@ -399,10 +399,10 @@
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
} }
/* Chrome, Safari, Opera */
*::-webkit-scrollbar { /* *::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */ display: none;
} } */
:root { :root {
--container-width-xs: calc(var(--spacing) * 72); --container-width-xs: calc(var(--spacing) * 72);