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:
co-authored by
Claude Fable 5.1
parent
0cefaea6a2
commit
53017d4153
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user