Files
Jyotisha/frontend/src/lib/chat-session-url.ts
T
Jesse_Chen 924f420202
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
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>
2026-09-01 20:42:21 +08:00

121 lines
3.8 KiB
TypeScript

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,
};
}