From 91b8b33aa690de1ea8a924d800425203aafc833e Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 04:53:59 +0000 Subject: [PATCH] fix(rectification): reveal the surface once, keep one live row across choice and adoption, and give an empty Case a start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a Case now hydrates turns and snapshot in one read (4s budget) before the session switches, so the sidebar no longer flashes a plain transcript, the panel never mounts empty, and the first completed turn no longer remounts the whole surface (the key is the session/Case binding only). Entry feedback is static: the card says 正在打开…, the sidebar row 打开中. A tapped choice or an adopted candidate continues the follow-up turn on the live row already in place, with busy held across the chain, so there is no empty frame and no next card flashing in. The question slot has four pure states — a gap is a timeline live row with timed refetches, then a reload — and no copy asks the reader to wait for the server. A resumed Case with no turns shows 这段校正还没有开始 and 开始提问. Stopping keeps what streamed and says so; 402 explains before redirecting; the opening row names what it is doing; the tap is echoed as the reader's own line. BUG-479, BUG-480, BUG-481, BUG-482 (echo) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- frontend/src/app/globals.css | 59 +++ frontend/src/app/page.tsx | 20 +- frontend/src/components/app-sidebar.tsx | 4 + .../components/consultation-run-timeline.tsx | 14 + ...onversational-birth-time-rectification.tsx | 5 + .../components/rectification-agentic-chat.tsx | 418 ++++++++++++------ .../src/components/sidebar-session-row.tsx | 6 + frontend/src/components/starter-home.tsx | 7 +- .../src/hooks/use-rectification-surface.ts | 53 ++- frontend/src/hooks/use-session-management.ts | 13 +- .../src/lib/rectification-surface-state.ts | 243 ++++++++++ .../tests/rectification-agentic-entry.test.ts | 45 +- .../tests/rectification-answer-choice.test.ts | 5 +- .../rectification-spoken-collect.test.ts | 10 +- .../rectification-surface-contract.test.ts | 132 ++++++ .../tests/rectification-surface-state.test.ts | 160 +++++++ 16 files changed, 1029 insertions(+), 165 deletions(-) create mode 100644 frontend/src/lib/rectification-surface-state.ts create mode 100644 frontend/tests/rectification-surface-contract.test.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 43cee3fe..699643ac 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2624,6 +2624,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 (BUG-483). */ +.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); @@ -2926,6 +2931,60 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class color: var(--color-ink-secondary); font-size: var(--type-caption); } +.rectification-question-slot__recover { + display: grid; + gap: var(--space-2); + justify-items: start; +} +.rectification-message-wrap > .rectification-question-slot:empty { display: none; } +/* A Case with nothing to show and no automatic opening: one line and one way to start (BUG-481). */ +.rectification-empty-state { + display: grid; + gap: var(--space-3); + justify-items: start; + margin: var(--space-4) 0; + margin-inline-start: var(--assistant-content-inset); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.55; +} +.rectification-empty-state p { margin: 0; } +/* The question slot's own live step reuses the timeline row; no summary above it. */ +.consultation-run-timeline__list.is-standalone { margin-top: 0; } +/* Static "opening" notes: no spinner after the reveal (unified-loading ruling). */ +.session-opening-note { + flex: 0 0 auto; + color: var(--color-ink-tertiary); + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: .02em; +} +.product-entrypoint-card[data-opening="true"], +.product-entrypoint-card[data-opening="true"] .product-entrypoint-hitarea { cursor: progress; } +.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/app/page.tsx b/frontend/src/app/page.tsx index 3127c204..e3d13785 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -25,6 +25,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, @@ -289,6 +294,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 [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false); @@ -406,7 +413,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; @@ -474,7 +484,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, }); @@ -1671,6 +1682,7 @@ export default function Home() { sessions={sidebarSessions} charts={sidebarCharts} activeSessionId={activeSession?.id ?? null} + openingSessionId={rectificationOpeningSessionId} account={sidebarAccount} accountMenuOpen={accountMenuOpen} accountTriggerRef={accountTrigger} @@ -1887,12 +1899,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/consultation-run-timeline.tsx b/frontend/src/components/consultation-run-timeline.tsx index 633ef3ba..039c4602 100644 --- a/frontend/src/components/consultation-run-timeline.tsx +++ b/frontend/src/components/consultation-run-timeline.tsx @@ -83,6 +83,20 @@ export function ConsultationRunTimeline({ ); } +/** + * One live step outside a reply's timeline: the same marker, spinner and + * shimmer label as a timeline row, for a surface that is waiting on the agent + * between turns (the rectification question slot). There is no second waiting + * vocabulary; this is the timeline row itself. + */ +export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) { + return ( +
    + +
+ ); +} + function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) { const KindIcon = KIND_ICONS[row.kind]; const expandable = Boolean( diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index e4273bf7..72f0062b 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<{ @@ -25,6 +26,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 dec4fe93..d54b5b08 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -48,10 +48,29 @@ import { isPublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; import { userFacingRunFailure, isIncompleteRunBanner } from "@/lib/rectification-agentic/v9/run-diagnostic"; +import { + parsePersistedRectificationTurns, + RECTIFICATION_EMPTY_ACTION_LABEL, + RECTIFICATION_EMPTY_COPY, + RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE, + RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS, + RECTIFICATION_QUESTION_PREPARING_LABEL, + RECTIFICATION_QUESTION_RELOAD_LABEL, + RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, + RECTIFICATION_QUESTION_RETRY_LIMIT, + RECTIFICATION_QUESTION_UNAVAILABLE_COPY, + RECTIFICATION_STOPPED_NOTICE, + rectificationAdoptingLabel, + rectificationConversationState, + rectificationInitialLiveLabel, + rectificationQuestionSlotState, + type RectificationCaseSnapshotPayload, +} from "@/lib/rectification-surface-state"; +import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; import { CHOICE_ACTION, STOP_ACTION, - isStructuredChoiceUserText, + userVisibleChoiceLine, shouldContinueAfterStructuredChoice, stableChoiceActionKey, type ChoiceOptionId, @@ -68,6 +87,7 @@ import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anc import { CharacterRemaining } from "./character-remaining"; import { ChatComposer } from "./chat-composer"; import { ChatMessageRow } from "./chat-message-row"; +import { ConsultationTimelineLiveRow } from "./consultation-run-timeline"; import { JumpToLatestButton } from "./jump-to-latest-button"; import { ChatMessageActions, @@ -172,6 +192,8 @@ type RectificationAgenticChatProps = Readonly<{ readonly: boolean; shouldStartOpening: boolean; initialTurns: readonly PersistedTurn[]; + initialSnapshot: RectificationCaseSnapshotPayload | null; + declaredTime: string | null; models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; @@ -265,7 +287,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag }]; } if (isIncompleteRunBanner(turn.text ?? "")) return []; - if (isStructuredChoiceUserText(turn.text)) return []; + // A structured choice the reader tapped is persisted as a user turn and is + // shown as one: the reader can see what they answered after a refresh, the + // same as the live echo (BUG-482). return [{ role: "user", text: turn.text ?? "", @@ -275,6 +299,31 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag }); } +type CaseSnapshotState = Readonly<{ + candidate: CandidateResult; + question: CurrentQuestionModel | null; + choice: ChoiceCardModel | null; + caseStatus: RectificationCaseStatus | null; + savedTime: string | null; + savedStatus: "accepted" | "confirmed" | null; +}>; + +// Candidate snapshot comes from the persisted Candidate Snapshot API, never +// from parsing agent text or hidden sentinels. +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), + 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, @@ -282,6 +331,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { readonly, shouldStartOpening, initialTurns, + initialSnapshot, + declaredTime, models, selectedModelId, onSelectModel, @@ -297,23 +348,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { headerSlot, } = props; + // Turns and snapshot were read before this surface mounted (one reveal, + // BUG-479); both are initial state, and later arrivals are prop updates, + // never a remount. const [messages, setMessages] = useState(() => messagesFromTurns(initialTurns)); 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 [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 [caseStatus, setCaseStatus] = useState(() => caseSnapshotState(initialSnapshot)?.caseStatus ?? null); + const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null); + const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0); + const [openingRequested, setOpeningRequested] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); 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); @@ -359,11 +414,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const setPending = useCallback((value: boolean) => { setBusy(value); + // A new turn starting also ends the question slot's retry cycle. + if (value) setQuestionRetryAttempts(0); onPendingChange?.(value); }, [onPendingChange]); - // Candidate snapshot comes from the persisted Candidate Snapshot API, never - // from parsing agent text or hidden sentinels. const applyCaseSnapshot = useCallback((payload: { latest_result?: unknown; current_question?: unknown; @@ -373,62 +428,83 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { accepted_time?: unknown; confirmed_time?: unknown; }; + turns?: unknown; } | null) => { - if (!payload) return; - const nextCandidate = parseRectificationCandidateResult(payload.latest_result); - const nextQuestion = currentQuestionFromSnapshot(payload.current_question); - const nextChoice = parseRectificationChoiceCard(payload.choice_card); - const nextCaseStatus = isRectificationCaseStatus(payload.case?.status) - ? payload.case.status - : null; - const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; - const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; - setCandidateResult(nextCandidate); + const next = caseSnapshotState(payload); + if (!next) return; + const nextQuestion = next.question; + setCandidateResult(next.candidate); setCurrentQuestion(nextQuestion); - setChoiceCard(nextChoice); - setCaseStatus(nextCaseStatus); + // A question arriving ends the slot's retry cycle. + if (nextQuestion !== null) setQuestionRetryAttempts(0); + setChoiceCard(next.choice); + setCaseStatus(next.caseStatus); setCaseSnapshotLoaded(true); - if (confirmedTime) { - setSavedTime(confirmedTime); - setSavedStatus("confirmed"); - } else if (acceptedTime) { - setSavedTime(acceptedTime); - setSavedStatus("accepted"); + if (next.savedStatus && next.savedTime) { + setSavedTime(next.savedTime); + setSavedStatus(next.savedStatus); + } + // A late hydration (the reveal deadline passed first) lands here: the + // Case read carries the turns too, so an empty transcript fills from it. + const turns = parsePersistedRectificationTurns(payload?.turns); + if (turns.length > 0) { + setMessages((current) => (current.length === 0 ? messagesFromTurns(turns) : current)); } }, []); + // Snapshot reads after mount (turn completion, question-slot retries) are + // abortable so none of them lands after the surface has gone. + const snapshotAbort = useRef(null); const loadCaseSnapshot = useCallback(async () => { + 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; applyCaseSnapshot(await response.json().catch(() => null)); } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. + } 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) 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)); + } - const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => { + // 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(); + }, []); + + /** + * 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 + * (BUG-480). 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); @@ -436,19 +512,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 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 initialLabel = rectificationInitialLiveLabel(action, continuation?.label); + const initialActivity: AgentActivityView = { + phase: "evidence-validation", + label: initialLabel, + startedAt: Date.now(), + }; - setMessages((current) => [ - ...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: "正在处理…", - startedAt: Date.now(), - } }, - ]); + setMessages((current) => (continuation + ? current.map((message) => (message.renderKey === assistantRenderKey + ? { ...message, text: "", state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity } + : message)) + : [ + ...current, + ...(action === "message" + ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] + : []), + { role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: initialActivity }, + ])); setDraft(""); let raw = ""; @@ -456,11 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; - let currentActivity: AgentActivityView | undefined = { - phase: "evidence-validation", - label: "正在处理…", - startedAt: Date.now(), - }; + let currentActivity: AgentActivityView | undefined = initialActivity; // Every stream event lands in `frames`; it commits at most once per animation // frame and releases text at a steady pace. The loop below never calls // setMessages for a live turn directly except on `attempt.reset`. @@ -507,7 +585,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { return; } if (response.status === 402) { - window.location.assign(membershipHref("rectification")); + // Say why before the page changes underneath the reader (task 1.5). + setError(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE); + window.setTimeout(() => { + window.location.assign(membershipHref("rectification")); + }, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS); return; } if (response.status === 401) setError("请先登录。"); @@ -694,6 +776,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? caught.name === "AbortError" : caught instanceof Error && caught.name === "AbortError"; if (aborted) { + // Stopped by the reader: what streamed stays in place and the notice + // says so; nothing here is an outage (task 0.7). + if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (raw.trim() || hasActivityReceipt(completedReceipt)) { @@ -763,6 +848,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { keyCounter.current += 1; const turnKey = keyCounter.current; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; + const echoRenderKey = `v9-choice-echo-${turnKey}`; + // The tap is echoed as the reader's own line, right after the answered + // card and before the working row, so the transcript reads as a + // conversation and survives a refresh the same way (BUG-482). + const echoText = optionId === "stop" ? answeredCard.stop_label : userVisibleChoiceLine(answeredCard, optionId); + const recordingLabel = RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer; setMessages((current) => [ ...current.map((message) => ( offeringRenderKey && message.renderKey === offeringRenderKey @@ -772,6 +863,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } : message )), + { role: "user", text: echoText, renderKey: echoRenderKey, state: "settled" }, { role: "assistant", text: "", @@ -780,7 +872,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { activityTrace: emptyActivityTrace(), activity: { phase: "evidence-validation", - label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS.recording_answer, + label: recordingLabel, startedAt: Date.now(), }, }, @@ -808,7 +900,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const payload = await response.json().catch(() => null); if (!response.ok) { setMessages((current) => current.flatMap((message) => { - if (message.renderKey === assistantRenderKey) return []; + if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return []; if (offeringRenderKey && message.renderKey === offeringRenderKey) { return [{ ...message, choiceAttachment: undefined }]; } @@ -822,12 +914,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setError(payload?.message || payload?.error || `请求失败(${response.status})`); return; } + const serverEcho = typeof payload?.userMessage === "string" && payload.userMessage.trim() + ? payload.userMessage.trim() + : echoText; + if (serverEcho !== echoText) { + setMessages((current) => current.map((message) => ( + message.renderKey === echoRenderKey ? { ...message, text: serverEcho } : message + ))); + } const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); onCompleted?.(); await loadCaseSnapshot(); if (willContinue) { - setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); - choiceContinuationPending.current = true; + // The same working row carries straight into the follow-up turn: no + // frame without a live row, and no next card flashing in between + // (BUG-480). `busy` stays true across the whole chain. + onMessagesChange?.([{ role: "user", text: serverEcho }]); + await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: recordingLabel }); } else { const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() @@ -841,12 +944,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } : message)); onMessagesChange?.([ + { role: "user", text: serverEcho }, { role: "assistant", text: narration }, ]); } } catch { setMessages((current) => current.flatMap((message) => { - if (message.renderKey === assistantRenderKey) return []; + if (message.renderKey === assistantRenderKey || message.renderKey === echoRenderKey) return []; if (offeringRenderKey && message.renderKey === offeringRenderKey) { return [{ ...message, choiceAttachment: undefined }]; } @@ -868,16 +972,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?.(); @@ -885,15 +984,43 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } if (readonly || openingStarted.current || !shouldStartOpening) return; openingStarted.current = true; + setOpeningRequested(true); onOpeningConsumed?.(); void send("opening", ""); }, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]); + // A Case with no turns and no automatic first turn (the server never starts + // an opening for a resumed Case) needs a way to begin; the server suppresses + // a duplicate opening, so this is safe to press twice (BUG-481). + function startOpeningManually() { + if (readonly || busy || openingStarted.current) return; + openingStarted.current = true; + setOpeningRequested(true); + void send("opening", ""); + } + const acceptCandidate = useCallback(async (candidateId: string) => { if (!candidateResult || acceptingCandidateId || busy || readonly) return; 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 (BUG-480). + const candidateTime = candidateResult.candidates.find((candidate) => candidate.candidateId === candidateId)?.time ?? ""; + const adoptingLabel = rectificationAdoptingLabel(candidateTime); + keyCounter.current += 1; + const assistantRenderKey = `v9-adopt-assistant-${keyCounter.current}`; + 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`, @@ -941,14 +1068,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, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]); async function copyMessage(message: RenderMessage) { try { @@ -1048,24 +1176,53 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? currentQuestion.prompt : null; const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); - const showMissingQuestion = Boolean( - caseSnapshotLoaded - && resumableCase - && !readonly - && !busy - && currentQuestion === null, - ); - const showUnavailableQuestion = Boolean( - caseSnapshotLoaded - && resumableCase - && !readonly - && !busy - && currentQuestion !== null - && ((currentQuestion.kind === "choice" && !choiceCard) - || (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)), - ); + // The slot has four visible states. A gap after a settled turn is + // `preparing` (one live row, timed refetches) and then `unavailable` (a + // reload button) — never bare copy telling the reader to wait (BUG-479). + const questionSlot = rectificationQuestionSlotState({ + questionKind: currentQuestion?.kind ?? null, + questionPrompt: currentQuestion?.prompt ?? null, + hasChoiceCard: Boolean(choiceCard), + choiceAnswered: Boolean(choiceCard && answeredQuestionIds.has(choiceCard.question_id)), + busy, + readonly, + regenerating: regeneratingMessageKey !== null, + snapshotLoaded: caseSnapshotLoaded, + resumableCase, + retryAttempts: questionRetryAttempts, + retryLimit: RECTIFICATION_QUESTION_RETRY_LIMIT, + }); + const conversationState = rectificationConversationState({ + messageCount: messages.length, + busy, + readonly, + shouldStartOpening, + openingStarted: openingRequested, + }); const canSend = !busy && !readonly && !regeneratingMessageKey; + // Question-slot recovery: the turn already refetched once on completion; + // while the slot is `preparing`, refetch on a timer up to the retry limit, + // then hand the reader a reload button. Attempts reset once a question + // arrives or another turn starts. + const questionRetrying = questionSlot === "preparing"; + useVisibilityAwarePoll({ + enabled: questionRetrying, + intervalMs: RECTIFICATION_QUESTION_RETRY_INTERVAL_MS, + refreshOnVisible: false, + onPoll: () => { + setQuestionRetryAttempts((current) => current + 1); + void refetchQuestion(); + }, + }); + async function refetchQuestion() { + await loadCaseSnapshot(); + } + function reloadQuestion() { + setQuestionRetryAttempts(0); + void refetchQuestion(); + } + function submitChoice(key: ChoiceKey) { if (!choiceCard) return; void submitStructuredChoice(CHOICE_ACTION, key); @@ -1079,6 +1236,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const boardPeek = compactBoard && !boardOpen ? ( {headerSlot && boardPeek ? createPortal(boardPeek, headerSlot) : null}
@@ -1104,6 +1262,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”

)} + {conversationState === "empty" && ( +
+

{RECTIFICATION_EMPTY_COPY}

+ +
+ )} {messages.map((message) => { const showActions = message.role === "assistant" && message.state === "settled" @@ -1188,34 +1352,39 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
); })} -
- {showLiveChoiceCard && choiceCard && ( - - )} - {collectSpokenPrompt && !showLiveChoiceCard && !readonly && ( -

- {collectSpokenPrompt} -

- )} - {showMissingQuestion && ( -

- 当前没有可回答的问题,正在等待服务端更新。 -

- )} - {showUnavailableQuestion && ( -

- 当前问题暂时无法显示,请等待服务端更新。 -

- )} -
+ {/* The question slot is the last entry of the transcript, indented like + every assistant entry, never a widget hanging below the cards. */} +
+
+ {showLiveChoiceCard && choiceCard && ( + + )} + {collectSpokenPrompt && !showLiveChoiceCard && !readonly && ( +

+ {collectSpokenPrompt} +

+ )} + {questionSlot === "preparing" && ( + + )} + {questionSlot === "unavailable" && ( +
+

{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}

+ +
+ )} +
+
{savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime} @@ -1292,6 +1461,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 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}

+