fix(home): one new-chat intent and a scoped login-return stash (BUG-1038)

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
This commit is contained in:
Jesse_Chen
2026-09-26 07:23:50 +08:00
co-authored by Claude Opus 5.5
parent f4b8f26e15
commit 0a8350cc01
19 changed files with 765 additions and 24 deletions
+25 -2
View File
@@ -17,6 +17,12 @@ export type BootstrapSessionSelection = {
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";
}
@@ -147,15 +153,32 @@ export function resolveBootstrapSessionSelection(input: {
};
}
/**
* `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",
status: "found" | "missing" | "unavailable" | "other-subject",
requestedId: string,
defaultSessionId: string,
origin: "url" | "stored-return" = "url",
): BootstrapSessionSelection {
if (status === "found") {
return {
sessionId: requestedId,
urlAction: "keep",
urlAction: origin === "stored-return" ? "replace-selected" : "keep",
missing: false,
clearStoredReturn: true,
};
}
if (status === "other-subject") {
return {
sessionId: defaultSessionId,
urlAction: "none",
missing: false,
clearStoredReturn: true,
};
+2
View File
@@ -44,6 +44,7 @@ import {
} from "@/lib/home-cloud-sync";
import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile";
import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import {
emptyProfile,
pendingConsultationStorageKey,
@@ -351,6 +352,7 @@ export async function resolveLanding(
catalog: ready.nextModelCatalog,
defaultSessionId: ready.nextSessions[0].id,
signal,
inSubjectScope: (session) => sessionMatchesSubject(session, readCurrentSubjectId()),
});
let nextSessions = lookedUp.sessions;
const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection;
+12 -1
View File
@@ -1,6 +1,7 @@
import { writeChatSession } from "@/lib/chat-session-write-contract";
import {
bootstrapSelectionFromLookup,
parseSessionUrlQuery,
persistLoginSessionReturn,
readLoginSessionReturn,
resolveBootstrapSessionSelection,
@@ -514,6 +515,8 @@ export async function resolveLookupBootstrap(input: {
defaultSessionId: string;
catalog: PublicLanguageModelCatalog | null;
signal?: AbortSignal;
/** Current person's scope. A stashed return outside it is dropped, not reopened. */
inSubjectScope?: (session: ChatSession) => boolean;
}): Promise<{
selection: BootstrapSessionSelection;
sessions: ChatSession[];
@@ -529,11 +532,19 @@ export async function resolveLookupBootstrap(input: {
if (selection.urlAction !== "lookup") {
return { selection, sessions: input.sessions, notice: null };
}
const origin = parseSessionUrlQuery(window.location.search).present ? "url" : "stored-return";
const looked = await lookupSessionById(selection.sessionId, input.catalog, input.signal);
throwIfAborted(input.signal);
if (looked.status === "found") {
if (origin === "stored-return" && input.inSubjectScope && !input.inSubjectScope(looked.session)) {
return {
selection: bootstrapSelectionFromLookup("other-subject", looked.session.id, input.defaultSessionId, origin),
sessions: input.sessions,
notice: null,
};
}
return {
selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId),
selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId, origin),
sessions: mergeHydratedSession(input.sessions, looked.session),
notice: null,
};