fix(rectification): reveal the surface once — hydrate the Case before the switch, never remount, static entry feedback

Opening a rectification Case used to switch sessions first and read the
turns afterwards, so the reader saw a plain transcript, then an empty
panel, then a remount when the turns arrived (and again after the first
turn settled, because the panel key carried a ready/loading suffix).
The surface hook now reads turns and snapshot in one Case request under
the home reveal budget and only then makes the session active; the
sidebar defers the switch the same way; a session selected at bootstrap
(deep link, refresh) is hydrated during the prepare phase so the reveal
shows the surface itself; the panel key is the session/Case binding only,
later turns fill an empty transcript as a prop update, and unmounting
aborts any stream or snapshot read. The homepage card and the sidebar row
say 正在打开 statically while the Case opens — no spinner after the reveal.

BUG-505

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-03 07:07:31 +00:00
co-authored by Claude Fable 5.1
parent 0cefaea6a2
commit 53017d4153
13 changed files with 647 additions and 59 deletions
+4
View File
@@ -59,6 +59,8 @@ export type AppSidebarProps = {
sessions: readonly SidebarSession[];
charts: readonly SidebarChart[];
activeSessionId: string | null;
/** A rectification session whose Case is being opened and hydrated; its row says so, statically. */
openingSessionId?: string | null;
account: SidebarAccount;
accountMenuOpen: boolean;
accountTriggerRef: Ref<HTMLButtonElement>;
@@ -82,6 +84,7 @@ export function AppSidebar({
sessions,
charts,
activeSessionId,
openingSessionId = null,
account,
accountMenuOpen,
accountTriggerRef,
@@ -141,6 +144,7 @@ export function AppSidebar({
ref={index === 0 ? firstSessionRef : undefined}
session={session}
active={session.id === activeSessionId}
opening={session.id === openingSessionId}
disabled={sessionControls.disabled}
menuOpen={sessionControls.menuSessionId === session.id}
onMenuOpenChange={(open) => sessionControls.onMenuSessionChange(open ? session.id : null)}
@@ -2,6 +2,7 @@
import type { PublicLanguageModel } from "../lib/public-models.ts";
import type { ChatMessage } from "../lib/chat-message-view.ts";
import type { RectificationCaseSnapshotPayload } from "../lib/rectification-surface-state.ts";
import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx";
export type PersistedRectificationTurn = Readonly<{
@@ -10,9 +11,18 @@ export type PersistedRectificationTurn = Readonly<{
text: string | null;
status: string;
question?: unknown;
offer_result_id?: string | null;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tool_activities?: readonly Readonly<{
tool: string;
status: string;
methods?: readonly string[];
started_at?: string | null;
elapsed_ms?: number | null;
detail?: Readonly<Record<string, unknown>> | null;
}>[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
@@ -26,6 +36,10 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -43,6 +43,7 @@ import {
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import type { RectificationCaseSnapshotPayload } from "@/lib/rectification-surface-state";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
@@ -196,6 +197,10 @@ type RectificationAgenticChatProps = Readonly<{
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
/** The Case snapshot read together with the turns before mount; null when hydration failed or timed out. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
/** The declared birth minute from the profile, for the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
@@ -386,6 +391,32 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
});
}
type CaseSnapshotState = Readonly<{
candidate: CandidateResult;
question: CurrentQuestionModel | null;
questionSource: "focus" | "unavailable" | null;
choice: ChoiceCardModel | null;
caseStatus: RectificationCaseStatus | null;
savedTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
}>;
/** The snapshot that arrived with the reveal, as initial state; nothing is fetched on mount. */
function caseSnapshotState(payload: RectificationCaseSnapshotPayload | null): CaseSnapshotState | null {
if (!payload) return null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
return {
candidate: parseRectificationCandidateResult(payload.latest_result),
question: currentQuestionFromSnapshot(payload.current_question),
questionSource: questionSourceFromSnapshot(payload.question_source),
choice: parseRectificationChoiceCard(payload.choice_card),
caseStatus: isRectificationCaseStatus(payload.case?.status) ? payload.case.status : null,
savedTime: confirmedTime ?? acceptedTime,
savedStatus: confirmedTime ? "confirmed" : acceptedTime ? "accepted" : null,
};
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
@@ -393,6 +424,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
readonly,
shouldStartOpening,
initialTurns,
initialSnapshot,
models,
selectedModelId,
onSelectModel,
@@ -411,14 +443,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false);
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(() => caseSnapshotState(initialSnapshot)?.candidate ?? null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(() => caseSnapshotState(initialSnapshot)?.choice ?? null);
const [currentQuestion, setCurrentQuestion] = useState<CurrentQuestionModel | null>(() => caseSnapshotState(initialSnapshot)?.question ?? null);
const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null);
const [caseStatus, setCaseStatus] = useState<RectificationCaseStatus | null>(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -432,6 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const openingStarted = useRef(false);
const previousBoardResult = useRef<CandidateResult>(null);
const runAbort = useRef<AbortController | null>(null);
const snapshotAbort = useRef<AbortController | null>(null);
const choiceActionIds = useRef(new Map<string, string>());
const currentQuestionRef = useRef(currentQuestion);
const offerSectionRef = useRef<HTMLDivElement | null>(null);
@@ -606,13 +639,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
question: CurrentQuestionModel | null;
turns: readonly unknown[];
} | null | undefined> => {
snapshotAbort.current?.abort();
const controller = new AbortController();
snapshotAbort.current = controller;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
{ cache: "no-store", signal: controller.signal },
);
if (!response.ok) return undefined;
const payload = await response.json().catch(() => null);
if (controller.signal.aborted) return undefined;
applyCaseSnapshot(payload);
return {
question: currentQuestionFromSnapshot(payload?.current_question),
@@ -621,25 +658,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} catch {
// Snapshot refresh is best-effort; the durable Case remains on the server.
return undefined;
} finally {
if (snapshotAbort.current === controller) snapshotAbort.current = null;
}
}, [applyCaseSnapshot, caseId, sessionId]);
useEffect(() => {
const controller = new AbortController();
void fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store", signal: controller.signal },
)
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (controller.signal.aborted) return;
applyCaseSnapshot(payload);
})
.catch(() => {
// Snapshot refresh is best-effort; the durable Case remains on the server.
});
return () => controller.abort();
}, [applyCaseSnapshot, caseId, sessionId]);
// Turns that arrive after mount (the parent refreshes the Case after each
// completed turn) only ever fill an empty transcript; a live or finished
// conversation is never overwritten or remounted. Adjusted during render
// from the previous prop, the way React documents it, not in an effect.
const [seededTurns, setSeededTurns] = useState(initialTurns);
if (seededTurns !== initialTurns) {
setSeededTurns(initialTurns);
if (messages.length === 0 && initialTurns.length > 0) setMessages(messagesFromTurns(initialTurns));
}
// Leaving the surface mid-turn ends the stream and any snapshot read
// instead of letting them write into an unmounted component.
useEffect(() => () => {
runAbort.current?.abort();
snapshotAbort.current?.abort();
}, []);
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
@@ -12,6 +12,7 @@ import {
Trash2,
} from "lucide-react";
import { forwardRef } from "react";
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
@@ -39,6 +40,8 @@ export type SidebarSessionControls = {
type SidebarSessionRowProps = {
readonly session: SidebarSession;
readonly active: boolean;
/** Its Case is being opened: a static note, no spinner. */
readonly opening?: boolean;
readonly disabled: boolean;
readonly menuOpen: boolean;
readonly onMenuOpenChange: (open: boolean) => void;
@@ -53,6 +56,7 @@ type SidebarSessionRowProps = {
export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRowProps>(function SidebarSessionRow({
session,
active,
opening = false,
disabled,
menuOpen,
onMenuOpenChange,
@@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
type="button"
isActive={active}
aria-current={active ? "page" : undefined}
aria-busy={opening ? true : undefined}
disabled={disabled}
onClick={onSelect}
>
<span className="session-title">
{session.pinned ? <Star aria-label="已收藏" /> : null}
<span className="truncate">{session.title}</span>
{opening ? <span className="session-opening-note">{RECTIFICATION_SIDEBAR_OPENING_NOTE}</span> : null}
</span>
</SidebarMenuButton>
<Menu.Trigger
+5 -1
View File
@@ -93,7 +93,11 @@ export function StarterHome({
<span className="product-entrypoint-action" aria-hidden="true">{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
<article
className="birth-rectification-card product-entrypoint-card"
aria-labelledby="birth-rectification-title"
data-opening={rectificationLoading ? "true" : undefined}
>
<button
className="product-entrypoint-hitarea"
type="button"