From 53017d4153fd23169e03c101a66b5b70e7db4972 Mon Sep 17 00:00:00 2001
From: Jesse_Chen
Date: Thu, 3 Sep 2026 06:53:33 +0000
Subject: [PATCH 1/5] =?UTF-8?q?fix(rectification):=20reveal=20the=20surfac?=
=?UTF-8?q?e=20once=20=E2=80=94=20hydrate=20the=20Case=20before=20the=20sw?=
=?UTF-8?q?itch,=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}
{natalMinuteAvailable ? dailyStarlanguageQuestion : "查看今日运势"}
-
+
)}
+ {conversationState === "empty" && (
+
+
{RECTIFICATION_EMPTY_COPY}
+
+
+ )}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
@@ -1527,6 +1593,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
})}
+ {questionGap === "preparing" && (
+
+
+
+ )}
+ {questionGap === "unavailable" && (
+
+
{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}
+
+
+ )}
{savedTime && savedStatus === "confirmed" && (
已确认校正时间:{savedTime}
@@ -1564,15 +1643,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
之后新建对话即按此时间排盘。
)}
- {(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (
-
- {showQuestionLoadFailed
- ? "题目加载失败,请刷新。"
- : showMissingQuestion
- ? "当前没有可回答的问题,正在等待服务端更新。"
- : "当前问题暂时无法显示,请等待服务端更新。"}
-
- )}
{
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
- assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/);
+ // Was: `const showMissingQuestion = Boolean(... caseSnapshotLoaded ... resumableCase ...
+ // currentQuestion === null` plus the copy /当前没有可回答的问题,正在等待服务端更新/ and
+ // /题目加载失败,请刷新/. Those were bare waiting sentences; the same inputs now feed
+ // `rectificationQuestionGapState`, which yields a live row with retries and then a
+ // reload button, and is still gated on a resumable case (BUG-505).
+ assert.match(chat, /const questionGap = rectificationQuestionGapState\(\{[\s\S]*questionMissing: currentQuestion === null,[\s\S]*snapshotLoaded: caseSnapshotLoaded,[\s\S]*resumableCase,/);
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
- assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/);
- assert.match(chat, /题目加载失败,请刷新/);
+ assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
+ assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /readonly && \(/);
- assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/);
+ assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
});
test("choice options render inside the same assistant message, not a sibling slot", () => {
From 1e50007c50a48ba9fc32a0e1d5909ee6909ba592 Mon Sep 17 00:00:00 2001
From: Jesse_Chen
Date: Thu, 3 Sep 2026 06:58:22 +0000
Subject: [PATCH 3/5] fix(rectification): keep one live row from a tapped
choice or an adopted candidate through the follow-up turn
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After a choice was recorded the chat removed its "正在记录本次选择…" row,
dropped `busy`, and let an effect start the follow-up turn a frame later —
one blank frame, and the next card could flash before the turn hid it.
Adopting a candidate showed nothing but a greyed button. `send` now takes a
continuation that reuses an existing live row, the choice and adoption
flows await it in the same async chain, `busy` is held throughout, and
adoption puts "正在采用 HH:MM…" at the end of the transcript before the
round trip.
BUG-506
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
---
.../components/rectification-agentic-chat.tsx | 99 ++++++++++++-------
.../tests/rectification-agentic-entry.test.ts | 11 ++-
.../tests/rectification-answer-choice.test.ts | 5 +-
3 files changed, 79 insertions(+), 36 deletions(-)
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 09a7a494..6159add0 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -44,6 +44,7 @@ import {
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import {
+ rectificationAdoptingLabel,
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
@@ -475,7 +476,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null);
const [choiceNonce, setChoiceNonce] = useState(0);
- const choiceContinuationPending = useRef(false);
const conversation = useRef(null);
const workspace = useRef(null);
const composer = useRef(null);
@@ -702,34 +702,52 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
snapshotAbort.current?.abort();
}, []);
- const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
+ /**
+ * Run one agent turn. A `continuation` reuses the live row an earlier step
+ * (a tapped choice, an adopted candidate) already put at the end of the
+ * transcript, so the reader sees one uninterrupted working row from the tap
+ * to the next question instead of a gap and a re-appended row. The caller
+ * of a continuation already holds `busy`.
+ */
+ const send = useCallback(async (
+ action: "opening" | "message" | "read_only",
+ messageText: string,
+ continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
+ ) => {
const trimmed = action === "message" ? messageText.trim() : "";
- if ((action === "message" && !trimmed) || busy || readonly) return;
+ if ((action === "message" && !trimmed) || readonly) return;
+ if (!continuation && busy) return;
setError("");
setPending(true);
- const initialLabel = rectificationInitialLiveLabel(action);
+ const initialLabel = rectificationInitialLiveLabel(action, continuation?.label);
beginLiveRun(initialLabel);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const turnKey = keyCounter.current;
const userRenderKey = `v9-user-${turnKey}`;
- const assistantRenderKey = `v9-assistant-${turnKey}`;
+ const assistantRenderKey = continuation?.reuseAssistantRenderKey ?? `v9-assistant-${turnKey}`;
const pendingFocusId = currentQuestionRef.current?.focus_id;
- setMessages((current) => [
- ...(action === "message" && pendingFocusId
- ? markQuestionAnswered(current, pendingFocusId, "typed")
- : current),
- ...(action === "message"
- ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
- : []),
- { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
- phase: "evidence-validation",
- label: initialLabel,
- startedAt: Date.now(),
- } },
- ]);
+ const liveRow: RenderMessage = {
+ role: "assistant",
+ text: "",
+ renderKey: assistantRenderKey,
+ state: "thinking",
+ activityTrace: emptyActivityTrace(),
+ activity: { phase: "evidence-validation", label: initialLabel, startedAt: Date.now() },
+ };
+ setMessages((current) => (continuation
+ ? current.map((message) => (message.renderKey === assistantRenderKey ? { ...liveRow, activity: message.activity ?? liveRow.activity } : message))
+ : [
+ ...(action === "message" && pendingFocusId
+ ? markQuestionAnswered(current, pendingFocusId, "typed")
+ : current),
+ ...(action === "message"
+ ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
+ : []),
+ liveRow,
+ ]));
setDraft("");
let raw = "";
@@ -1056,7 +1074,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
keyCounter.current += 1;
const turnKey = keyCounter.current;
const assistantRenderKey = `v9-choice-assistant-${turnKey}`;
- beginLiveRun(RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer);
+ const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer;
+ beginLiveRun(recordingLabel);
setMessages((current) => [
...markQuestionAnswered(current, focusId, optionId),
{
@@ -1067,7 +1086,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activityTrace: emptyActivityTrace(),
activity: {
phase: "evidence-validation",
- label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer,
+ label: recordingLabel,
startedAt: Date.now(),
},
},
@@ -1108,11 +1127,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const snapshot = await loadCaseSnapshot();
const turns = snapshot?.turns ?? [];
if (willContinue) {
- setMessages((current) => mergeTurnQuestions(
- current.filter((message) => message.renderKey !== assistantRenderKey),
- turns,
- ));
- choiceContinuationPending.current = true;
+ // The follow-up turn continues on the row already in place: no removed
+ // row, no effect hop, and `busy` never drops in between (so the next
+ // card cannot flash before the turn hides it).
+ setMessages((current) => mergeTurnQuestions(current, turns));
+ await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel });
} else {
const narration = typeof payload?.narration === "string" && payload.narration.trim()
? payload.narration.trim()
@@ -1155,16 +1174,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onMessagesChange,
onProfileIncomplete,
readonly,
+ send,
sessionId,
setPending,
]);
- useEffect(() => {
- if (!choiceContinuationPending.current || busy || readonly) return;
- choiceContinuationPending.current = false;
- void send("read_only", "");
- }, [busy, readonly, send]);
-
useEffect(() => {
if (initialTurns.length > 0) {
if (shouldStartOpening) onOpeningConsumed?.();
@@ -1181,6 +1195,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError("");
setAcceptingCandidateId(candidateId);
setPending(true);
+ // Adoption is a server round trip followed by an agent turn; the reader sees
+ // one live row for the whole of it instead of a greyed button.
+ const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? "";
+ const adoptingLabel = rectificationAdoptingLabel(candidateTime);
+ keyCounter.current += 1;
+ const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`;
+ beginLiveRun(adoptingLabel);
+ setMessages((current) => [
+ ...current,
+ {
+ role: "assistant",
+ text: "",
+ renderKey: assistantRenderKey,
+ state: "thinking",
+ activityTrace: emptyActivityTrace(),
+ activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
+ },
+ ]);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
@@ -1228,14 +1260,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onSaved?.(payload.saved_time, "accepted");
onCompleted?.();
await loadCaseSnapshot();
- choiceContinuationPending.current = true;
+ await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) {
+ setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingCandidateId(null);
setPending(false);
}
- }, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, sessionId, setPending]);
+ }, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
try {
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts
index 442e4246..59b5560b 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -230,9 +230,16 @@ test("candidate acceptance is non-billable, mutually exclusive, and continues th
assert.match(chat, /resultId: candidateResult\.resultId/);
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
- assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
+ // Was: `await loadCaseSnapshot(); choiceContinuationPending.current = true;` and an
+ // effect `if (!choiceContinuationPending.current || busy || readonly) return; void
+ // send("read_only", "")`. That hop dropped `busy` for a frame and removed the live
+ // row, so the next card flashed and the transcript went blank between the tap and
+ // the follow-up turn. Continuation now runs in the same async chain on the same
+ // row (BUG-506). The old `doesNotMatch` guarded against a *bare* `send("read_only",
+ // "")` after the snapshot — a continuation carries the row to reuse, and is kept.
+ assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel \}\);/);
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
- assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
+ assert.doesNotMatch(chat, /choiceContinuationPending/);
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
assert.doesNotMatch(chat, /action: "accept_candidate"/);
const acceptRoute = readFileSync(
diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts
index 94606fa1..8b8a61d6 100644
--- a/frontend/tests/rectification-answer-choice.test.ts
+++ b/frontend/tests/rectification-answer-choice.test.ts
@@ -1198,7 +1198,10 @@ test("the public agent route treats structured choice as a non-model command", (
assert.match(chat, /isPersistedFocusId\(focusId\)/);
assert.match(chat, /focusId,/);
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction, payload\)/);
- assert.match(chat, /send\("read_only", ""\)/);
+ // Was: /send\("read_only", ""\)/ — the bare follow-up call from an effect hop. The
+ // follow-up now continues on the live row already in place, so the call carries
+ // that row (BUG-506); the route contract (read_only after a structured choice) holds.
+ assert.match(chat, /send\("read_only", "", \{ reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel \}\)/);
assert.match(chat, /willContinue/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
From 3c33e59814a18fb8d1a3880df45a5c401861cd1b Mon Sep 17 00:00:00 2001
From: Jesse_Chen
Date: Thu, 3 Sep 2026 07:00:17 +0000
Subject: [PATCH 4/5] feat(rectification): confirm the tap on the choice card
and show the declared minute on the empty board
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The choice card now marks the tapped option with a check and 已选择 and,
while the tap is being recorded, carries the same live row shape as a
timeline step instead of only a wait cursor. Before any candidate exists
the board's clock shows the declared birth minute, its body says what
fills it later, and the column narrows to minmax(16rem, 18rem); the
compact peek reads "填报 HH:MM". No house table is invented for the
declared time — the Case API does not provide one.
BUG-508, BUG-509
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
---
frontend/src/app/globals.css | 30 +++++++++++++++++++
.../components/rectification-agentic-chat.tsx | 5 +++-
.../src/components/rectification-board.tsx | 23 ++++++++++----
.../components/rectification-choice-card.tsx | 22 ++++++++++++++
frontend/src/lib/rectification-board-model.ts | 9 ++++--
.../tests/rectification-agentic-entry.test.ts | 5 +++-
6 files changed, 85 insertions(+), 9 deletions(-)
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 3d4a6097..f94c7f73 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -2635,6 +2635,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
background: var(--color-canvas);
}
+/* No candidate yet: the board only carries the declared time, so it takes less
+ width until there is a house table to show. */
+.rectification-workspace.is-board-empty {
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
+}
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
@@ -2960,6 +2965,31 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
line-height: 1.55;
}
.rectification-empty-state p { margin: 0; }
+/* The tap is being recorded: same shape as a timeline live row. */
+.rectification-choice-card__pending {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 24px;
+ margin: 0;
+ color: var(--color-ink-secondary);
+ font-size: var(--type-body-sm);
+ line-height: 1.5;
+}
+.rectification-choice-card__selected {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-inline-start: var(--space-2);
+ color: var(--color-action);
+ font-size: var(--type-caption);
+ font-weight: 600;
+ vertical-align: middle;
+}
+.rectification-choice-card__selected svg { width: 14px; height: 14px; }
+.rectification-board__clock.is-declared { color: var(--color-ink-tertiary); }
+.rectification-board__empty { display: grid; gap: var(--space-1); }
+.rectification-board__empty p { margin: 0; }
.rectification-snapshot {
display: grid;
gap: var(--space-3);
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 6159add0..c8611ffe 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -443,6 +443,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
shouldStartOpening,
initialTurns,
initialSnapshot,
+ declaredTime,
models,
selectedModelId,
onSelectModel,
@@ -1471,6 +1472,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const boardPeek = compactBoard && !boardOpen ? (
{headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
@@ -1733,6 +1735,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
function RectificationBoardBody({
result,
+ declaredTime,
savedStatus,
diff,
compact,
@@ -42,6 +44,7 @@ function RectificationBoardBody({
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
+ declaredTime: string | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
@@ -50,6 +53,7 @@ function RectificationBoardBody({
}>) {
const table = result ? workingRectificationHouseTable(result) : null;
const workingTime = workingRectificationTime(result);
+ const emptyCopy = rectificationBoardEmptyCopy(declaredTime);
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
@@ -69,7 +73,11 @@ function RectificationBoardBody({