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:
co-authored by
Claude Opus 5.5
parent
f4b8f26e15
commit
0a8350cc01
@@ -45,7 +45,7 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { bindCurrentSubjectAccount, readCurrentSubjectId, useCurrentSubjectProfile } from "@/lib/current-subject";
|
||||
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
|
||||
import { NewChatDeepLink, SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
@@ -1127,7 +1127,6 @@ export default function Home() {
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
setDailyStarlanguage={setDailyStarlanguage}
|
||||
/>
|
||||
<NewChatDeepLink ready={Boolean(account && modelCatalog)} startNewChat={startNewChat} />
|
||||
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
{pendingSessionDeletion ? (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types";
|
||||
|
||||
function stripQuery(key: string) {
|
||||
@@ -13,22 +12,6 @@ function stripQuery(key: string) {
|
||||
window.history.replaceState(null, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`);
|
||||
}
|
||||
|
||||
export function NewChatDeepLink(props: {
|
||||
ready: boolean;
|
||||
startNewChat: () => Promise<unknown>;
|
||||
}) {
|
||||
const started = useRef(false);
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
useEffect(() => {
|
||||
if (!props.ready || !subjectReady || started.current) return;
|
||||
if (!new URLSearchParams(window.location.search).has("newChat")) return;
|
||||
started.current = true;
|
||||
stripQuery("newChat");
|
||||
void props.startNewChat();
|
||||
}, [props.ready, props.startNewChat, subjectReady]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SynastryDeepLink(props: {
|
||||
profile: Profile;
|
||||
draft: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => Promise<void>;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type SetStateAction } from "react";
|
||||
import { FileText, MessageSquareText, Orbit } from "lucide-react";
|
||||
|
||||
import { newChatHref } from "@/lib/chat-session-url";
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
@@ -434,7 +435,7 @@ export function PeopleArchiveView({
|
||||
<dd>{peopleAyanamsaLabel(selected.profile)}</dd>
|
||||
</dl>
|
||||
<div className="people-archive-actions">
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/?newChat=1")}>
|
||||
<button type="button" onClick={() => openPerson(selected.id, newChatHref())}>
|
||||
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />和 TA 对话
|
||||
</button>
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/chart")}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
SESSION_LOOKUP_FAILED_NOTICE,
|
||||
SESSION_MISSING_NOTICE,
|
||||
clearLoginSessionReturn,
|
||||
parseSessionUrlQuery,
|
||||
writeSessionUrl,
|
||||
} from "@/lib/chat-session-url";
|
||||
@@ -348,6 +349,9 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
pendingCreateById.current.set(nextSession.id, {
|
||||
continuedFromSessionId: options?.continuedFromSessionId,
|
||||
});
|
||||
// A new chat is not a place to come back to (BUG-1038): drop the stashed
|
||||
// `?c=` a secondary-page link left, or the next bare `/` reopens it.
|
||||
clearLoginSessionReturn();
|
||||
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
|
||||
setActiveSessionId(nextSession.id);
|
||||
setDraft("");
|
||||
@@ -617,6 +621,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (isUnsavedEmptyConsultation(existing)) pendingCreateById.current.delete(existing.id);
|
||||
}
|
||||
pendingCreateById.current.set(nextSession.id, {});
|
||||
clearLoginSessionReturn();
|
||||
setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession));
|
||||
setActiveSessionId(nextSession.id);
|
||||
setDraft("");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user