From 53017d4153fd23169e03c101a66b5b70e7db4972 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 3 Sep 2026 06:53:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20reveal=20the=20surface=20?= =?UTF-8?q?once=20=E2=80=94=20hydrate=20the=20Case=20before=20the=20switch?= =?UTF-8?q?,=20never=20remount,=20static=20entry=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- frontend/src/app/globals.css | 11 + frontend/src/app/page.tsx | 32 +- frontend/src/components/app-sidebar.tsx | 4 + ...onversational-birth-time-rectification.tsx | 14 + .../components/rectification-agentic-chat.tsx | 89 ++++-- .../src/components/sidebar-session-row.tsx | 6 + frontend/src/components/starter-home.tsx | 6 +- .../src/hooks/use-rectification-surface.ts | 54 ++-- frontend/src/hooks/use-session-management.ts | 13 +- frontend/src/lib/home-bootstrap.ts | 4 + .../src/lib/rectification-surface-state.ts | 289 ++++++++++++++++++ .../tests/rectification-agentic-entry.test.ts | 18 +- .../tests/rectification-surface-state.test.ts | 166 ++++++++++ 13 files changed, 647 insertions(+), 59 deletions(-) create mode 100644 frontend/src/lib/rectification-surface-state.ts create mode 100644 frontend/tests/rectification-surface-state.test.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5d804d09..c12e414e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -757,6 +757,14 @@ button:disabled { cursor: default; opacity: .45; } .session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: 0; width: 2px; background: var(--sidebar-ring); } .session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-caption); font-weight: 500; } .session-title > svg { width: 14px; height: 14px; flex: 0 0 auto; color: currentColor; } +/* A rectification session whose Case is being opened: a static note, no spinner after the reveal. */ +.session-opening-note { + flex: 0 0 auto; + color: var(--color-ink-tertiary); + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: .02em; +} .session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); } .session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; } .session-menu-trigger > svg { width: 18px; height: 18px; } @@ -995,6 +1003,9 @@ button:disabled { cursor: default; opacity: .45; } .product-entrypoint-hitarea { position: absolute; z-index: 2; inset: 0; width: 100%; min-height: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .product-entrypoint-card > :not(.product-entrypoint-hitarea) { position: relative; z-index: 1; pointer-events: none; } +/* The card is opening a Case: static copy and a progress cursor, no spinner (unified-loading ruling). */ +.product-entrypoint-card[data-opening="true"], +.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; } .product-entrypoint-card:has(.product-entrypoint-hitarea:not(:disabled):active) { border-color: color-mix(in srgb, var(--color-action) 44%, var(--color-border)); background: var(--color-action-soft); transform: translateY(0); } .product-entrypoint-card:has(.product-entrypoint-hitarea:focus-visible) { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; } @media (hover: hover) { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 56429b65..bb349f94 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -24,6 +24,11 @@ import { type RectificationEntrySummary, } from "@/lib/rectification-entry"; import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; +import { + declaredBirthTime, + RECTIFICATION_OPENING_LABEL, + type RectificationCaseSnapshotPayload, +} from "@/lib/rectification-surface-state"; import { toggleChatMessageFeedback, type ChatMessageFeedback, @@ -295,6 +300,8 @@ export default function Home() { const [rectificationReadonly, setRectificationReadonly] = useState(false); const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false); const [rectificationTurns, setRectificationTurns] = useState([]); + const [rectificationSnapshot, setRectificationSnapshot] = useState(null); + const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState(null); const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null); const [hydrated, setHydrated] = useState(false); const [bootstrapPhase, setBootstrapPhase] = useState("account"); @@ -415,7 +422,10 @@ export default function Home() { latestTerminal: null, }, ); - const rectificationCardLabel = rectificationEntryLabels[rectificationCardAction]; + const rectificationCardLabel = rectificationLoading + ? RECTIFICATION_OPENING_LABEL + : rectificationEntryLabels[rectificationCardAction]; + const rectificationDeclaredTime = declaredBirthTime(profile); const rectificationErrorMessage = rectificationError === "profile_incomplete" ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" : rectificationError; @@ -482,7 +492,8 @@ export default function Home() { setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId, setRectificationEntrySummary, setRectificationError, setRectificationLoading, setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId, - setRectificationShouldStartOpening, setRectificationTurns, setSessions, uiPreview, + setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId, + setRectificationTurns, setSessions, uiPreview, updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef, }); @@ -502,8 +513,13 @@ export default function Home() { return () => window.removeEventListener("popstate", onPopState); }, [hydrated]); + // A rectification session selected at bootstrap (deep link, refresh, the + // most recent session) is opened and hydrated during the prepare phase, so + // the reveal shows the surface itself rather than a plain transcript that is + // swapped out a moment later. After the reveal the same effect serves + // history navigation. useEffect(() => { - if (!hydrated + if ((!hydrated && bootstrapPhase === "account") || !account || !modelCatalog || activeSession?.sessionType !== "birth_time_rectification" @@ -516,6 +532,7 @@ export default function Home() { }, [ account, activeSession, + bootstrapPhase, creatingSession, hydrated, modelCatalog, @@ -647,6 +664,10 @@ export default function Home() { dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable, dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending", entrySummarySettled: rectificationEntrySummarySettled, + rectificationApplicable: activeSession?.sessionType === "birth_time_rectification", + rectificationSettled: activeSession?.id === rectificationSessionId + || rectificationError !== "" + || !profileComplete, }); useEffect(() => { @@ -1718,6 +1739,7 @@ export default function Home() { sessions={sidebarSessions} charts={sidebarCharts} activeSessionId={activeSession?.id ?? null} + openingSessionId={rectificationOpeningSessionId} account={sidebarAccount} accountMenuOpen={accountMenuOpen} accountTriggerRef={accountTrigger} @@ -1928,12 +1950,14 @@ export default function Home() { {rectificationSurfaceOpen && rectificationCaseId && ( 0 ? "ready" : "loading"}`} + key={`${rectificationSessionId}-${rectificationCaseId}`} caseId={rectificationCaseId} sessionId={rectificationSessionId ?? ""} readonly={rectificationReadonly} shouldStartOpening={rectificationShouldStartOpening} initialTurns={rectificationTurns} + initialSnapshot={rectificationSnapshot} + declaredTime={rectificationDeclaredTime} models={modelCatalog?.models ?? []} selectedModelId={activeSession?.modelId ?? ""} onSelectModel={(modelId) => void selectSessionModel(modelId)} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 104e7c06..008d981e 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -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; @@ -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)} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 6a75aec4..26dbffb3 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -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> | 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; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 0c657c1d..f8e08bf4 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -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(null); - const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); - const [candidateResult, setCandidateResult] = useState(null); - const [choiceCard, setChoiceCard] = useState(null); - const [currentQuestion, setCurrentQuestion] = useState(null); - const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null); - const [caseStatus, setCaseStatus] = useState(null); - const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false); + const [savedTime, setSavedTime] = useState(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); + const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(() => caseSnapshotState(initialSnapshot)?.savedStatus ?? null); + const [candidateResult, setCandidateResult] = useState(() => caseSnapshotState(initialSnapshot)?.candidate ?? null); + const [choiceCard, setChoiceCard] = useState(() => caseSnapshotState(initialSnapshot)?.choice ?? null); + const [currentQuestion, setCurrentQuestion] = useState(() => caseSnapshotState(initialSnapshot)?.question ?? null); + const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(() => caseSnapshotState(initialSnapshot)?.questionSource ?? null); + const [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); + const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); @@ -432,6 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const openingStarted = useRef(false); const previousBoardResult = useRef(null); const runAbort = useRef(null); + const snapshotAbort = useRef(null); const choiceActionIds = useRef(new Map()); const currentQuestionRef = useRef(currentQuestion); const offerSectionRef = useRef(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() : ""; diff --git a/frontend/src/components/sidebar-session-row.tsx b/frontend/src/components/sidebar-session-row.tsx index 7f419420..9cdcc49e 100644 --- a/frontend/src/components/sidebar-session-row.tsx +++ b/frontend/src/components/sidebar-session-row.tsx @@ -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(function SidebarSessionRow({ session, active, + opening = false, disabled, menuOpen, onMenuOpenChange, @@ -83,12 +87,14 @@ export const SidebarSessionRow = forwardRef {session.pinned ? : null} {session.title} + {opening ? {RECTIFICATION_SIDEBAR_OPENING_NOTE} : null}