From 924f4202029b868cc06c2222bbe8ce9b09f1f122 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 20:42:21 +0800 Subject: [PATCH] fix(chat): keep the current session in the URL as ?c= Refresh, back, and login return were dropping the open conversation because selection lived only in React state. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ frontend/src/app/page.tsx | 85 +++++- frontend/src/lib/chat-notice.ts | 2 +- frontend/src/lib/chat-session-url.ts | 120 ++++++++ .../chat-navigation-a11y-contract.test.ts | 6 +- .../chat-notice-and-scroll-contract.test.ts | 1 + frontend/tests/chat-session-url.test.ts | 266 ++++++++++++++++++ progress.md | 13 + 8 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/chat-session-url.ts create mode 100644 frontend/tests/chat-session-url.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 670e4eb8..c2ac9131 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7134,3 +7134,19 @@ - 相关记录:无 - 复发自:无 - 修复版本:待发布 + +## BUG-465 | 会话选择只活在内存里,刷新、后退和登录回跳都会丢掉当前对话 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:首页 `activeSessionId`、浏览器历史、`redirectToLogin` 后的登录回跳 +- 用户现象:无论当前在哪个对话,刷新后回到最近一条;会话间切换后按后退会直接离开站点;登录过期后再登回来落在空白首页,对不上刚才那条对话。 +- 触发条件:在侧边栏点开非默认会话后刷新;连续切换几个会话后按浏览器后退;带着 `?c=` 会话地址遇到 401 被送去登录。 +- 根因:`activeSessionId` 只存在 React state。地址栏没有会话痕迹,登录页的 `successPath` 也只允许回到 `/` 或 `/admin`。 +- 修复:合法会话 id 写入 `?c=`。用户主动切换、新建、打开校正时 `pushState`;删除/归档当前会话或伪造 id 时 `replaceState`。默认选中和生成恢复不写 URL。`popstate` 复用 `selectSession` 且不再二次 push。401 把当前 `?c=` 暂存 sessionStorage,登录落回 `/` 后启动逻辑读回并清暂存。登录页零改动。 +- 验证:合同测试锁启动读参、默认不写 URL、popstate 不二次 push、删除清死链、401 暂存、首页不用 `useSearchParams`。`./node_modules/.bin/tsc --noEmit`、前端测试与 `next build` 的 `/` `○ Static` 必须通过。 +- 防复发:首页不得为读 query 引入 `useSearchParams` 或服务端 `searchParams`,以免 `/` 从 Static 退化。不得给登录页加开放重定向 `next` 参数。默认选中不得 `pushState`。 +- 相关记录:BUG-464 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1fc09a6f..f1407955 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -132,6 +132,15 @@ import { CONSULTATION_LOADING_METHOD_LABEL, } from "@/lib/consultation-activity-labels"; import { writeChatSession } from "@/lib/chat-session-write-contract"; +import { + SESSION_MISSING_NOTICE, + clearLoginSessionReturn, + parseSessionUrlQuery, + persistLoginSessionReturn, + readLoginSessionReturn, + resolveBootstrapSessionSelection, + writeSessionUrl, +} from "@/lib/chat-session-url"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { OnboardingAuthenticationError, @@ -1140,6 +1149,7 @@ class LoginRedirectError extends Error { } function redirectToLogin(): never { + persistLoginSessionReturn(); window.location.replace("/login"); throw new LoginRedirectError(); } @@ -1347,6 +1357,8 @@ export default function Home() { const modelSyncFailures = useRef(new Set()); const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); + const sessionSelectionSource = useRef<"user" | "history">("user"); + const applySessionPopStateRef = useRef<(search: string) => void>(() => undefined); const chartLibraryLoadedAccount = useRef(""); const accountOverlayRef = useRef(null); const accountOverlayEpochRef = useRef(0); @@ -1436,6 +1448,13 @@ export default function Home() { void ensureSessionMessages(activeSessionId); }, [hydrated, activeSessionId, modelCatalog, pendingSessionId]); + useEffect(() => { + if (!hydrated || uiPreview.current) return; + const onPopState = () => applySessionPopStateRef.current(window.location.search); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, [hydrated]); + useEffect(() => { if (!hydrated || !account @@ -1880,7 +1899,14 @@ export default function Home() { } if (controller.signal.aborted) return; - const activeListed = nextSessions[0]; + const bootstrapSelection = resolveBootstrapSessionSelection({ + listedIds: nextSessions.map((session) => session.id), + defaultSessionId: nextSessions[0].id, + search: window.location.search, + storedReturnId: readLoginSessionReturn(), + }); + const activeListed = nextSessions.find((session) => session.id === bootstrapSelection.sessionId) + ?? nextSessions[0]; if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") { try { const detailed = await fetchSessionDetail(activeListed.id, nextModelCatalog, controller.signal); @@ -1901,7 +1927,10 @@ export default function Home() { setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : ""); setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); - setActiveSessionId(nextSessions[0].id); + setActiveSessionId(bootstrapSelection.sessionId); + if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); + if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); + if (bootstrapSelection.urlAction === "replace-selected") writeSessionUrl(bootstrapSelection.sessionId, "replace"); if (reservedConsultation?.status === "reserved") { const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId); if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); @@ -1910,6 +1939,8 @@ export default function Home() { setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); } else if (parsedSessions.fallbackSessionIds.length > 0) { setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); + } else if (bootstrapSelection.missing) { + setComposerNotice(SESSION_MISSING_NOTICE); } setAccountError(""); @@ -2348,7 +2379,11 @@ export default function Home() { setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); - if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); + if (activeSessionId === session.id) { + const fallbackId = nextSessions[0]?.id ?? ""; + setActiveSessionId(fallbackId); + if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace"); + } try { const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); const payload = await response.json().catch(() => null) as { error?: string } | null; @@ -2367,7 +2402,9 @@ export default function Home() { const restoring = archivedSessionIds.includes(sessionId); setArchivedSessionIds((current) => restoring ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); if (!restoring && activeSessionId === sessionId) { - setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""); + const fallbackId = visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""; + setActiveSessionId(fallbackId); + if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace"); } setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); } @@ -2408,9 +2445,11 @@ export default function Home() { ...chartSnapshotForSession(activeChartId, chartLibrary, profile), }; const previousSessionId = activeSession?.id ?? ""; + const previousHref = `${window.location.pathname}${window.location.search}`; setCreatingSession(true); setSessions((current) => [nextSession, ...current]); setActiveSessionId(nextSession.id); + if (!uiPreview.current) writeSessionUrl(nextSession.id, "push"); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); @@ -2422,6 +2461,7 @@ export default function Home() { } catch (caught) { setSessions((current) => current.filter((session) => session.id !== nextSession.id)); setActiveSessionId(previousSessionId); + if (!uiPreview.current) window.history.replaceState(null, "", previousHref); setRequestError({ sessionId: previousSessionId, message: caught instanceof Error ? caught.message : "新对话未能保存到云端。", @@ -2461,8 +2501,34 @@ export default function Home() { } else { void ensureSessionMessages(sessionId); } + if (!uiPreview.current && sessionSelectionSource.current === "user") { + writeSessionUrl(sessionId, "push"); + } + sessionSelectionSource.current = "user"; } + applySessionPopStateRef.current = (search: string) => { + if (uiPreview.current) return; + const listed = sessionsRef.current; + const query = parseSessionUrlQuery(search); + const fallbackId = listed[0]?.id ?? ""; + const requestedId = query.present ? query.sessionId : fallbackId; + if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) { + writeSessionUrl(null, "replace"); + sessionSelectionSource.current = "history"; + if (fallbackId) selectSession(fallbackId); + else setActiveSessionId(""); + setComposerNotice(SESSION_MISSING_NOTICE); + return; + } + if (!requestedId) { + setActiveSessionId(""); + return; + } + sessionSelectionSource.current = "history"; + selectSession(requestedId); + }; + async function selectSessionModel(modelId: string) { const userId = account?.user.id; if (!activeSession || !modelCatalog || !userId || pendingSessionId || cancellationPending || creatingSession) return; @@ -2957,9 +3023,13 @@ export default function Home() { pendingConsultationQuestion: string | null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current - || rectificationMutationPending) return null; + || rectificationMutationPending) { + sessionSelectionSource.current = "user"; + return null; + } const missingStep = missingProfileStep(profile); if (missingStep) { + sessionSelectionSource.current = "user"; setRectificationSessionId(null); setRectificationCaseId(null); setRectificationPendingQuestion(null); @@ -3034,6 +3104,9 @@ export default function Home() { setRectificationTurns([]); activeSessionIdRef.current = opened.sessionId; setActiveSessionId(opened.sessionId); + if (!uiPreview.current && sessionSelectionSource.current === "user") { + writeSessionUrl(opened.sessionId, "push"); + } void refreshRectificationCase(opened.caseId, opened.sessionId); void refreshRectificationEntrySummary(); return opened; @@ -3041,6 +3114,7 @@ export default function Home() { setRectificationError("生时校正会话暂时无法打开,请稍后重试。"); return null; } finally { + sessionSelectionSource.current = "user"; rectificationOpenInFlight.current = false; setRectificationLoading(false); } @@ -3059,6 +3133,7 @@ export default function Home() { } resumeRectificationSession.current = (session) => { + sessionSelectionSource.current = "history"; void openRectificationSession(session.id); }; diff --git a/frontend/src/lib/chat-notice.ts b/frontend/src/lib/chat-notice.ts index a5215be3..719cadd7 100644 --- a/frontend/src/lib/chat-notice.ts +++ b/frontend/src/lib/chat-notice.ts @@ -10,7 +10,7 @@ export type ChatNoticeAction = Readonly<{ }>; const ongoingNotice = /正在|请稍候|请先|联网后/; -const failedNotice = /失败|无法|不可用|未找到|已写满/; +const failedNotice = /失败|无法|不可用|未找到|已写满|已被删除/; const settledNotice = /^已|^回答已恢复/; export function noticeTone(message: string): NoticeTone { diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts new file mode 100644 index 00000000..a9d56da9 --- /dev/null +++ b/frontend/src/lib/chat-session-url.ts @@ -0,0 +1,120 @@ +export const SESSION_URL_QUERY_KEY = "c"; +export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return"; +export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除"; +export const SESSION_URL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type SessionUrlQuery = { + readonly present: boolean; + readonly sessionId: string | null; +}; + +export type BootstrapSessionSelection = { + readonly sessionId: string; + readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none"; + readonly missing: boolean; + readonly clearStoredReturn: boolean; +}; + +export function parseSessionUrlQuery(search: string): SessionUrlQuery { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + if (!params.has(SESSION_URL_QUERY_KEY)) { + return { present: false, sessionId: null }; + } + const raw = params.get(SESSION_URL_QUERY_KEY) ?? ""; + return { + present: true, + sessionId: SESSION_URL_ID_PATTERN.test(raw) ? raw : null, + }; +} + +export function sessionHref(search: string, sessionId: string | null, pathname = "/"): string { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + if (sessionId) params.set(SESSION_URL_QUERY_KEY, sessionId); + else params.delete(SESSION_URL_QUERY_KEY); + const query = params.toString(); + const path = pathname || "/"; + return query ? `${path}?${query}` : path; +} + +export function writeSessionUrl(sessionId: string | null, mode: "push" | "replace") { + const { location, history } = globalThis.window; + const next = sessionHref(location.search, sessionId, location.pathname); + const current = `${location.pathname}${location.search}`; + if (current === next) return; + if (mode === "push") history.pushState(null, "", next); + else history.replaceState(null, "", next); +} + +export function persistLoginSessionReturn() { + try { + const sessionId = parseSessionUrlQuery(globalThis.window.location.search).sessionId; + if (sessionId) globalThis.sessionStorage.setItem(SESSION_URL_RETURN_STORAGE_KEY, sessionId); + } catch { + // sessionStorage can throw in private mode; login still proceeds. + } +} + +export function readLoginSessionReturn(): string | null { + try { + const raw = globalThis.sessionStorage.getItem(SESSION_URL_RETURN_STORAGE_KEY); + if (!raw || !SESSION_URL_ID_PATTERN.test(raw)) return null; + return raw; + } catch { + return null; + } +} + +export function clearLoginSessionReturn() { + try { + globalThis.sessionStorage.removeItem(SESSION_URL_RETURN_STORAGE_KEY); + } catch { + // Ignore private-mode quota errors. + } +} + +export function resolveBootstrapSessionSelection(input: { + readonly listedIds: readonly string[]; + readonly defaultSessionId: string; + readonly search: string; + readonly storedReturnId: string | null; +}): BootstrapSessionSelection { + const query = parseSessionUrlQuery(input.search); + if (query.present) { + if (query.sessionId && input.listedIds.includes(query.sessionId)) { + return { + sessionId: query.sessionId, + urlAction: "keep", + missing: false, + clearStoredReturn: true, + }; + } + return { + sessionId: input.defaultSessionId, + urlAction: "replace-clear", + missing: true, + clearStoredReturn: true, + }; + } + if (input.storedReturnId) { + if (input.listedIds.includes(input.storedReturnId)) { + return { + sessionId: input.storedReturnId, + urlAction: "replace-selected", + missing: false, + clearStoredReturn: true, + }; + } + return { + sessionId: input.defaultSessionId, + urlAction: "replace-clear", + missing: true, + clearStoredReturn: true, + }; + } + return { + sessionId: input.defaultSessionId, + urlAction: "none", + missing: false, + clearStoredReturn: false, + }; +} diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 36ad5161..5a965eb9 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -58,7 +58,11 @@ test("both insufficient-credit paths are soft so the typed question is not throw test("auth redirects stay hard document loads so stale session state cannot survive", () => { // Given: a 401 means the client is holding a session the server has rejected. // Then: every login redirect is a deliberate full page load, not router.push. - assert.match(pageSource, /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/); + // Former value: /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/ + // 401 now stashes a UUID ?c= in sessionStorage so `/` can restore the session + // without adding a `next` query to the login page. + assert.match(pageSource, /function redirectToLogin\(\): never \{/); + assert.match(pageSource, /persistLoginSessionReturn\(\);\n window\.location\.replace\("\/login"\);/); assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4); assert.equal(pageSource.match(/window\.location\.replace\("\/login"\)/g)?.length, 1); assert.doesNotMatch(pageSource, /router\.push\("\/login"\)/); diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index 1c1625ba..02166455 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -57,6 +57,7 @@ test("assigns notice severity by message intent", () => { assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success"); assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success"); assert.equal(noticeTone("这段对话已写满,开个新对话继续吧"), "error"); + assert.equal(noticeTone("该对话不存在或已被删除"), "error"); assert.equal(noticeTone("删除失败:网络异常"), "error"); assert.equal(noticeTone("重命名同步失败"), "error"); assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error"); diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts new file mode 100644 index 00000000..b9961442 --- /dev/null +++ b/frontend/tests/chat-session-url.test.ts @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + SESSION_MISSING_NOTICE, + SESSION_URL_QUERY_KEY, + SESSION_URL_RETURN_STORAGE_KEY, + parseSessionUrlQuery, + persistLoginSessionReturn, + readLoginSessionReturn, + resolveBootstrapSessionSelection, + sessionHref, + writeSessionUrl, +} from "../src/lib/chat-session-url.ts"; + +const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const login = readFileSync(new URL("../src/components/email-otp-login.tsx", import.meta.url), "utf8"); +const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8"); +const sessionA = "11111111-1111-4111-8111-111111111111"; +const sessionB = "22222222-2222-4222-8222-222222222222"; +const sessionC = "33333333-3333-4333-8333-333333333333"; + +function sourceBetween(source: string, startMarker: string, endMarker: string) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.notEqual(start, -1, startMarker); + assert.notEqual(end, -1, endMarker); + return source.slice(start, end); +} + +test("bootstrap reads a listed ?c= session and ignores login storage", () => { + assert.deepEqual( + resolveBootstrapSessionSelection({ + listedIds: [sessionA, sessionB], + defaultSessionId: sessionA, + search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`, + storedReturnId: sessionC, + }), + { + sessionId: sessionB, + urlAction: "keep", + missing: false, + clearStoredReturn: true, + }, + ); +}); + +test("bootstrap restores a stored login return only when the URL has no c", () => { + assert.deepEqual( + resolveBootstrapSessionSelection({ + listedIds: [sessionA, sessionB], + defaultSessionId: sessionA, + search: "", + storedReturnId: sessionB, + }), + { + sessionId: sessionB, + urlAction: "replace-selected", + missing: false, + clearStoredReturn: true, + }, + ); +}); + +test("bootstrap clears an illegal or unknown session query", () => { + assert.equal(parseSessionUrlQuery("").present, false); + assert.deepEqual(parseSessionUrlQuery(`?${SESSION_URL_QUERY_KEY}=not-a-uuid`), { + present: true, + sessionId: null, + }); + assert.deepEqual( + resolveBootstrapSessionSelection({ + listedIds: [sessionA], + defaultSessionId: sessionA, + search: `?${SESSION_URL_QUERY_KEY}=not-a-uuid`, + storedReturnId: sessionB, + }), + { + sessionId: sessionA, + urlAction: "replace-clear", + missing: true, + clearStoredReturn: true, + }, + ); + assert.deepEqual( + resolveBootstrapSessionSelection({ + listedIds: [sessionA], + defaultSessionId: sessionA, + search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`, + storedReturnId: null, + }), + { + sessionId: sessionA, + urlAction: "replace-clear", + missing: true, + clearStoredReturn: true, + }, + ); +}); + +test("default bootstrap selection does not write a session URL", () => { + assert.deepEqual( + resolveBootstrapSessionSelection({ + listedIds: [sessionA, sessionB], + defaultSessionId: sessionA, + search: "", + storedReturnId: null, + }), + { + sessionId: sessionA, + urlAction: "none", + missing: false, + clearStoredReturn: false, + }, + ); + + const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();"); + assert.match(bootstrap, /defaultSessionId: nextSessions\[0\]\.id/); + assert.match(bootstrap, /setActiveSessionId\(bootstrapSelection\.sessionId\)/); + assert.match(bootstrap, /urlAction === "replace-clear"/); + assert.match(bootstrap, /urlAction === "replace-selected"/); + assert.doesNotMatch(bootstrap, /writeSessionUrl\([^)]*, "push"\)/); + assert.match(bootstrap, /setComposerNotice\(SESSION_MISSING_NOTICE\)/); +}); + +test("session href keeps sibling query keys and can drop a dead c", () => { + assert.equal(sessionHref("", sessionA), `/?${SESSION_URL_QUERY_KEY}=${sessionA}`); + assert.equal( + sessionHref("?preview=conversation", sessionA), + `/?preview=conversation&${SESSION_URL_QUERY_KEY}=${sessionA}`, + ); + assert.equal(sessionHref(`?${SESSION_URL_QUERY_KEY}=${sessionA}`, null), "/"); + assert.equal( + sessionHref(`?preview=conversation&${SESSION_URL_QUERY_KEY}=${sessionA}`, null), + "/?preview=conversation", + ); +}); + +test("user session switches push history; popstate reuses selectSession without a second push", () => { + const selectSession = sourceBetween( + page, + "function selectSession(sessionId: string)", + "async function selectSessionModel", + ); + assert.match(selectSession, /sessionSelectionSource\.current === "user"/); + assert.match(selectSession, /writeSessionUrl\(sessionId, "push"\)/); + assert.match(selectSession, /sessionSelectionSource\.current = "history";\n selectSession\(/); + assert.equal((selectSession.match(/writeSessionUrl\([^)]*, "push"\)/g) ?? []).length, 1); + assert.match(page, /window\.addEventListener\("popstate", onPopState\)/); +}); + +test("creating and leaving a session keep the address bar in sync", () => { + const startNewChat = sourceBetween(page, "async function startNewChat()", "function selectSession("); + assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/); + assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/); + + const deleteSession = sourceBetween(page, "async function deleteSession(", "function togglePinnedSession"); + assert.match(deleteSession, /writeSessionUrl\(fallbackId \|\| null, "replace"\)/); + + const archiveSession = sourceBetween(page, "function toggleArchivedSession(", "async function shareSession"); + assert.match(archiveSession, /writeSessionUrl\(fallbackId \|\| null, "replace"\)/); + + const recovery = sourceBetween( + page, + "if (status.status === \"completed\") {", + "pendingConsultation.current = null;", + ); + assert.match(recovery, /setActiveSessionId\(\(current\) => current \|\| detailed\.id\)/); + assert.doesNotMatch(recovery, /writeSessionUrl/); +}); + +test("401 login redirect stashes the current session id without changing the login page", () => { + const redirect = sourceBetween(page, "function redirectToLogin(): never {", "function waitForUndoWindow"); + // Former value: the body was only `window.location.replace("/login")`. + // 401 now stashes a UUID ?c= so returning to `/` can restore the session + // without adding a `next` query to the login page. + assert.match(redirect, /persistLoginSessionReturn\(\);/); + assert.match(redirect, /window\.location\.replace\("\/login"\);/); + assert.doesNotMatch(login, /sessionStorage|SESSION_URL_RETURN|searchParams\.get\("next"\)/); + assert.match(login, /successPath\?: "\/" \| "\/admin"/); + assert.doesNotMatch(loginPage, /next=/); + assert.equal(SESSION_URL_RETURN_STORAGE_KEY, "jyotisha.session-url-return"); +}); + +test("home stays a client-read query on a static route", () => { + const lib = readFileSync(new URL("../src/lib/chat-session-url.ts", import.meta.url), "utf8"); + assert.doesNotMatch(page, /useSearchParams/); + assert.doesNotMatch(page, /export const dynamic/); + assert.match(page, /writeSessionUrl\(sessionId, "push"\)/); + assert.match(page, /window\.history\.replaceState/); + assert.match(lib, /history\.pushState\(null, "", next\)/); + assert.match(lib, /history\.replaceState\(null, "", next\)/); + const preview = sourceBetween( + page, + "if (previewMode) {", + "const [nextAccount, modelCatalogResult, sessionsPayload]", + ); + assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/); + assert.match(page, /SESSION_MISSING_NOTICE/); + assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除"); +}); + +test("login return storage only accepts a UUID session id", () => { + const memory = new Map(); + const previousWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { search: `?${SESSION_URL_QUERY_KEY}=${sessionA}` }, + }, + }); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: { + setItem(key: string, value: string) { memory.set(key, value); }, + getItem(key: string) { return memory.get(key) ?? null; }, + removeItem(key: string) { memory.delete(key); }, + }, + }); + try { + persistLoginSessionReturn(); + assert.equal(readLoginSessionReturn(), sessionA); + memory.set(SESSION_URL_RETURN_STORAGE_KEY, "not-a-uuid"); + assert.equal(readLoginSessionReturn(), null); + } finally { + if (previousWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); + } + Reflect.deleteProperty(globalThis, "sessionStorage"); + } +}); + +test("writeSessionUrl no-ops when the address is already the target", () => { + const calls: string[] = []; + const previousWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { pathname: "/", search: `?${SESSION_URL_QUERY_KEY}=${sessionA}` }, + history: { + pushState(_state: unknown, _unused: string, url: string) { calls.push(`push:${url}`); }, + replaceState(_state: unknown, _unused: string, url: string) { calls.push(`replace:${url}`); }, + }, + }, + }); + try { + writeSessionUrl(sessionA, "push"); + assert.deepEqual(calls, []); + writeSessionUrl(sessionB, "push"); + assert.deepEqual(calls, [`push:/?${SESSION_URL_QUERY_KEY}=${sessionB}`]); + writeSessionUrl(null, "replace"); + assert.deepEqual(calls, [ + `push:/?${SESSION_URL_QUERY_KEY}=${sessionB}`, + "replace:/", + ]); + } finally { + if (previousWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); + } + } +}); diff --git a/progress.md b/progress.md index 44c9dcd2..1a175f03 100644 --- a/progress.md +++ b/progress.md @@ -1091,3 +1091,16 @@ - Payload:列表响应列集合为 `id,title,theme,model_id,session_type,rectification_case_id,chart_profile_* ,updated_at`,消息文本只能出现在详情 GET。多会话账户的字节对比取决于该账户存量消息,合同测试锁的是“列表不含消息文本”这一结构,而不是某一个登录态的绝对字节数。 - `session_full`:本地 PostgreSQL 把会话填到 200 条后再 append,返回 `error_code=session_full`,行内消息数仍为 200。 - 双标签页:同一会话两次 `append_consultation_question`(不同 request_id)后两条用户消息都在;这是改前 last-write-wins 必丢、改后必留的核心形状。 + +## 2026-09-01 - TASK-session-url:会话选择写入 `?c=` + +- 用户主动切换、新建、打开校正用原生 `history.pushState`;删除/归档当前会话、伪造 id、登录回跳用 `replaceState`。默认选中和咨询恢复不写 URL。`popstate` 复用 `selectSession`,以来源标志位避免二次 push。 +- 401 在 `redirectToLogin` 把合法 `?c=` 写入 `sessionStorage`;登录页零改动,`successPath` 仍是 `"/" | "/admin"`。落回 `/` 后启动逻辑读暂存、`replaceState` 写回 URL 并清暂存。 +- 首页继续只在客户端读 `location.search`,不用 `useSearchParams`,也没有服务端 `searchParams`。 +- 断言例外(锁住的正是本轮要修的缺陷): + - `chat-navigation-a11y-contract.test.ts`:原值 `function redirectToLogin(): never {\n window.location.replace("/login");`。401 现在会先 `persistLoginSessionReturn()`,登录跳转本身仍是硬 `replace`。 +- `next build` 路由表:`┌ ○ /`(Static)。`/login` 仍是 `ƒ`。 +- 本地验证: + - `./node_modules/.bin/tsc --noEmit` 通过。 + - `npm test`:**2424 通过 / fail=0 / skipped=0**(含数据库测试;本轮未改库)。 +- 实测:无登录态,浏览器验收(刷新仍在 A、粘贴 `/?c=`、A→B→C 后退、删除死链、流式中切走再 back、401 回跳)未做,由合同测试覆盖。浅色/深色:新文案「该对话不存在或已被删除」走既有 sonner toast,`已被删除` 命中 `failedNotice` 用 error 语气,颜色仍是 `--color-canvas` / `--color-ink`。