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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ChatNoticeAction = Readonly<{
|
||||
}>;
|
||||
|
||||
const ongoingNotice = /正在|请稍候|请先|联网后/;
|
||||
const failedNotice = /失败|无法|不可用|未找到|已写满/;
|
||||
const failedNotice = /失败|无法|不可用|未找到|已写满|已被删除/;
|
||||
const settledNotice = /^已|^回答已恢复/;
|
||||
|
||||
export function noticeTone(message: string): NoticeTone {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user