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
+31 -23
View File
@@ -27,6 +27,14 @@ import {
} from "@/lib/rectification-entry";
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
parsePersistedRectificationTurns,
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
rectificationCaseHref,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
export type RectificationSurfaceParams = {
account: Account | null;
@@ -58,6 +66,8 @@ export type RectificationSurfaceParams = {
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
@@ -98,6 +108,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationReadonly,
setRectificationSessionId,
setRectificationShouldStartOpening,
setRectificationSnapshot,
setRectificationOpeningSessionId,
setRectificationTurns,
setSessions,
uiPreview,
@@ -121,28 +133,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
async function refreshRectificationCase(caseId: string, sessionId: string) {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
if (!response.ok) return;
const payload = await response.json().catch(() => null);
const turns = Array.isArray(payload?.turns) ? payload.turns : [];
setRectificationTurns(turns.map((turn: { id?: unknown; role?: unknown; text?: unknown; status?: unknown; receipt?: unknown; question?: unknown }) => ({
id: String(turn?.id ?? ""),
role: turn?.role === "user" ? "user" as const : "assistant" as const,
text: typeof turn?.text === "string" ? turn.text : null,
status: String(turn?.status ?? "completed"),
question: turn?.question ?? null,
receipt: turn?.receipt && typeof turn.receipt === "object" ? {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
})));
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
} catch {
// History refresh is best-effort; the stream restores live turns.
}
@@ -172,6 +166,10 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
setRectificationError("");
// Read the selection source now: `selectSession` resets it synchronously
// right after handing off, and the URL write below happens after awaits.
const selectionSource = sessionSelectionSource.current;
setRectificationOpeningSessionId(exactSessionId);
try {
const response = await fetch("/api/rectification/cases/open", {
@@ -225,6 +223,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
void persistSession(merged).catch(() => {});
// One reveal: turns and snapshot are read before the surface mounts, so
// the reader never sees an empty transcript or an empty question area
// while they load. Past the deadline the surface opens with what arrived
// and its own retry path fills in the rest.
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
});
setRectificationPendingQuestion(pendingConsultationQuestion);
setDraft("");
setDraftTheme(null);
@@ -235,13 +241,14 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setRectificationReadonly(
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
);
setRectificationTurns([]);
setRectificationTurns(hydration.turns);
setRectificationSnapshot(hydration.snapshot);
activeSessionIdRef.current = opened.sessionId;
setActiveSessionId(opened.sessionId);
if (!uiPreview.current && sessionSelectionSource.current === "user") {
if (!uiPreview.current && selectionSource === "user") {
writeSessionUrl(opened.sessionId, "push");
}
void refreshRectificationCase(opened.caseId, opened.sessionId);
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
void refreshRectificationEntrySummary();
return opened;
} catch {
@@ -250,6 +257,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
} finally {
sessionSelectionSource.current = "user";
rectificationOpenInFlight.current = false;
setRectificationOpeningSessionId(null);
setRectificationLoading(false);
}
}
+10 -3
View File
@@ -314,7 +314,14 @@ export function useSessionManagement(params: SessionManagementParams) {
function selectSession(sessionId: string) {
const nextSession = sessions.find((session) => session.id === sessionId);
setActiveSessionId(sessionId);
// A rectification session that is not the open one is not switched to
// here: the surface hook opens the Case, hydrates turns and snapshot, and
// only then makes it active and writes the URL, so the current view stays
// put instead of flashing a plain transcript and then an empty panel.
// It reads the selection source before we reset it below.
const deferredRectificationSwitch = nextSession?.sessionType === "birth_time_rectification"
&& nextSession.id !== rectificationSessionId;
if (!deferredRectificationSwitch) setActiveSessionId(sessionId);
setDraft("");
setDraftEntrypoint(null);
setComposerNotice("");
@@ -327,7 +334,7 @@ export function useSessionManagement(params: SessionManagementParams) {
}
if (nextSession?.sessionType === "birth_time_rectification") {
setRectificationError("");
if (nextSession.id !== rectificationSessionId) {
if (deferredRectificationSwitch) {
// The exact sessionId is passed to the server; the server resolves
// the exact Case and never switches to another rectification record.
void openRectificationSession(nextSession.id);
@@ -335,7 +342,7 @@ export function useSessionManagement(params: SessionManagementParams) {
} else {
void ensureSessionMessages(sessionId);
}
if (!uiPreview.current && sessionSelectionSource.current === "user") {
if (!deferredRectificationSwitch && !uiPreview.current && sessionSelectionSource.current === "user") {
writeSessionUrl(sessionId, "push");
}
sessionSelectionSource.current = "user";