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}

+