Coming back to / from /people could show the previous rectification session
as a locked page: its title in the header, the composer stuck on
"正在打开生时校正…", and the new-chat greeting in the middle.
The state that survived between pages is the sessionStorage login-return
stash that secondary-page sidebar links write from the current ?c=:
- /people「和 TA 对话」used a second intent (?newChat=1) parsed by a
component mounted inside Home after bootstrap, so the bootstrap new-chat
branch never ran and the stash won.
- A stash id not in the current person's loaded list was looked up and
landed with urlAction "keep", which assumes ?c= is already in the address
bar. It was not, so the rectification auto-open never fired. The stash
also ignored which person was current.
- An in-page new chat left the stash in place.
Fix: delete NewChatDeepLink / ?newChat and route「和 TA 对话」through
newChatHref(); a looked-up stash writes ?c= back (replace-selected) and is
dropped when it belongs to another person; startNewChat and
openChatBoundToProfile clear the stash. ?c= deep links, BUG-989 and BUG-705
are unchanged.
Tests: new real-lifecycle suite mounting the real Home, sidebar and people
page (10 cases: four secondary pages + mobile drawer, 和 TA 对话 for self and
another person, out-of-scope stash, same-person stash beyond the first page,
in-page new chat), plus two contract/unit tests. Six fail on origin/staging,
all pass here. Full suite 3928 / 61 failing, failure names identical to the
0ab061b9 baseline (3916 / 61).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
export const SESSION_URL_QUERY_KEY = "c";
|
|
export const NEW_CHAT_QUERY_KEY = "new";
|
|
export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return";
|
|
export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除";
|
|
export const SESSION_LOOKUP_FAILED_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" | "lookup" | "new-chat";
|
|
readonly missing: boolean;
|
|
readonly clearStoredReturn: boolean;
|
|
};
|
|
|
|
/**
|
|
* The one new-chat intent. The read-only sidebar and /people「和 TA 对话」 both
|
|
* link here; the person rides on the current-subject store, not the URL.
|
|
* Bootstrap turns it into a local empty consultation and ignores (then clears)
|
|
* the stashed `?c=` return, so no older session is reopened (BUG-1015/1038).
|
|
*/
|
|
export function newChatHref(): string {
|
|
return "/?new=1";
|
|
}
|
|
|
|
export function parseNewChatIntent(search: string): boolean {
|
|
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
return params.has(NEW_CHAT_QUERY_KEY);
|
|
}
|
|
|
|
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);
|
|
params.delete(NEW_CHAT_QUERY_KEY);
|
|
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 {
|
|
if (parseNewChatIntent(input.search)) {
|
|
return {
|
|
sessionId: input.defaultSessionId,
|
|
urlAction: "new-chat",
|
|
missing: false,
|
|
clearStoredReturn: true,
|
|
};
|
|
}
|
|
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,
|
|
};
|
|
}
|
|
if (query.sessionId) {
|
|
return {
|
|
sessionId: query.sessionId,
|
|
urlAction: "lookup",
|
|
missing: false,
|
|
clearStoredReturn: false,
|
|
};
|
|
}
|
|
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.storedReturnId,
|
|
urlAction: "lookup",
|
|
missing: false,
|
|
clearStoredReturn: false,
|
|
};
|
|
}
|
|
return {
|
|
sessionId: input.defaultSessionId,
|
|
urlAction: "none",
|
|
missing: false,
|
|
clearStoredReturn: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `origin` says where the looked-up id came from. A `?c=` is already in the
|
|
* address bar, so a found session keeps it. A stashed return (the `?c=` a
|
|
* secondary-page link left behind) is not: landing on it must write it back,
|
|
* or the page shows that session without the URL that opens it — for a
|
|
* rectification session that is a locked composer that never opens (BUG-1038).
|
|
* A stashed session of another person is not a place to return to at all.
|
|
*/
|
|
export function bootstrapSelectionFromLookup(
|
|
status: "found" | "missing" | "unavailable" | "other-subject",
|
|
requestedId: string,
|
|
defaultSessionId: string,
|
|
origin: "url" | "stored-return" = "url",
|
|
): BootstrapSessionSelection {
|
|
if (status === "found") {
|
|
return {
|
|
sessionId: requestedId,
|
|
urlAction: origin === "stored-return" ? "replace-selected" : "keep",
|
|
missing: false,
|
|
clearStoredReturn: true,
|
|
};
|
|
}
|
|
if (status === "other-subject") {
|
|
return {
|
|
sessionId: defaultSessionId,
|
|
urlAction: "none",
|
|
missing: false,
|
|
clearStoredReturn: true,
|
|
};
|
|
}
|
|
if (status === "missing") {
|
|
return {
|
|
sessionId: defaultSessionId,
|
|
urlAction: "replace-clear",
|
|
missing: true,
|
|
clearStoredReturn: true,
|
|
};
|
|
}
|
|
return {
|
|
sessionId: defaultSessionId,
|
|
urlAction: "none",
|
|
missing: false,
|
|
clearStoredReturn: false,
|
|
};
|
|
}
|