fix(chat): keep the current session in the URL as ?c=
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Refresh, back, and login return were dropping the open conversation because selection lived only in React state.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 20:42:21 +08:00
co-authored by Cursor
parent 48174a3cc5
commit 924f420202
8 changed files with 502 additions and 7 deletions
+16
View File
@@ -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
- 复发自:无
- 修复版本:待发布
+80 -5
View File
@@ -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<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const sessionSelectionSource = useRef<"user" | "history">("user");
const applySessionPopStateRef = useRef<(search: string) => void>(() => undefined);
const chartLibraryLoadedAccount = useRef("");
const accountOverlayRef = useRef<AccountOverlayModel | null>(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);
};
+1 -1
View File
@@ -10,7 +10,7 @@ export type ChatNoticeAction = Readonly<{
}>;
const ongoingNotice = /正在|请稍候|请先|联网后/;
const failedNotice = /失败|无法|不可用|未找到|已写满/;
const failedNotice = /失败|无法|不可用|未找到|已写满|已被删除/;
const settledNotice = /^已|^回答已恢复/;
export function noticeTone(message: string): NoticeTone {
+120
View File
@@ -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,
};
}
@@ -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"\)/);
@@ -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");
+266
View File
@@ -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<string, string>();
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 });
}
}
});
+13
View File
@@ -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`。