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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
|
|
import { resolveSessionTitle } from "@/lib/agent-reply";
|
|
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
|
import { writeSessionUrl } from "@/lib/chat-session-url";
|
|
import type { ConsultationEntrypoint } from "@/lib/consultation-entrypoint";
|
|
import { chartSnapshotForSession, missingProfileStep } from "@/lib/home-profile";
|
|
import {
|
|
timestamp,
|
|
type Account,
|
|
type AccountDialog,
|
|
type ChartLibraryRecord,
|
|
type ChatSession,
|
|
type Message,
|
|
type OnboardingStep,
|
|
type Profile,
|
|
type Theme,
|
|
} from "@/lib/home-types";
|
|
import {
|
|
entrySummaryFromResponse,
|
|
isTerminalRectificationStatus,
|
|
openRectificationRequestBody,
|
|
openResponseFromPayload,
|
|
type RectificationEntrySummary,
|
|
} from "@/lib/rectification-entry";
|
|
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
|
|
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
|
import {
|
|
hydrateRectificationCase,
|
|
parsePersistedRectificationTurns,
|
|
RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE,
|
|
RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
|
rectificationCaseHref,
|
|
type RectificationCaseSnapshotPayload,
|
|
} from "@/lib/rectification-surface-state";
|
|
|
|
export type RectificationSurfaceParams = {
|
|
account: Account | null;
|
|
activeChartId: string;
|
|
activeSessionIdRef: MutableRefObject<string>;
|
|
chartLibrary: ChartLibraryRecord[];
|
|
creatingSession: boolean;
|
|
modelCatalog: PublicLanguageModelCatalog | null;
|
|
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
|
|
profile: Profile;
|
|
rectificationLoading: boolean;
|
|
rectificationMutationPending: boolean;
|
|
rectificationOpenInFlight: MutableRefObject<boolean>;
|
|
rectificationSessionId: string | null;
|
|
resumeRectificationSession: MutableRefObject<(session: ChatSession) => void>;
|
|
sessionSelectionSource: MutableRefObject<"user" | "history">;
|
|
sessions: ChatSession[];
|
|
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
|
setDraft: (value: string) => void;
|
|
setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void;
|
|
setDraftTheme: (theme: Theme | null) => void;
|
|
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
|
|
setProfileNotice: Dispatch<SetStateAction<string>>;
|
|
setRectificationCaseId: Dispatch<SetStateAction<string | null>>;
|
|
setRectificationEntrySummary: Dispatch<SetStateAction<RectificationEntrySummary | null>>;
|
|
setRectificationError: Dispatch<SetStateAction<string>>;
|
|
setRectificationLoading: Dispatch<SetStateAction<boolean>>;
|
|
setRectificationPendingQuestion: Dispatch<SetStateAction<string | null>>;
|
|
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
|
|
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
|
|
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
|
|
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
|
|
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
|
|
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
|
|
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
|
|
uiPreview: MutableRefObject<boolean>;
|
|
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
|
|
openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void;
|
|
refreshAccount: () => Promise<void>;
|
|
rectificationSessionOpenerRef: MutableRefObject<(exactSessionId: string) => Promise<void>>;
|
|
};
|
|
|
|
export function useRectificationSurface(params: RectificationSurfaceParams) {
|
|
const {
|
|
account,
|
|
activeChartId,
|
|
activeSessionIdRef,
|
|
chartLibrary,
|
|
creatingSession,
|
|
modelCatalog,
|
|
persistSession,
|
|
profile,
|
|
rectificationLoading,
|
|
rectificationMutationPending,
|
|
rectificationOpenInFlight,
|
|
rectificationSessionId,
|
|
resumeRectificationSession,
|
|
sessionSelectionSource,
|
|
sessions,
|
|
setActiveSessionId,
|
|
setDraft,
|
|
setDraftEntrypoint,
|
|
setDraftTheme,
|
|
setOnboardingStep,
|
|
setProfileNotice,
|
|
setRectificationCaseId,
|
|
setRectificationEntrySummary,
|
|
setRectificationError,
|
|
setRectificationLoading,
|
|
setRectificationPendingQuestion,
|
|
setRectificationReadonly,
|
|
setRectificationSessionId,
|
|
setRectificationShouldStartOpening,
|
|
setRectificationSnapshot,
|
|
setRectificationOpeningSessionId,
|
|
setRectificationTurns,
|
|
setSessions,
|
|
uiPreview,
|
|
updateSession,
|
|
openAccountDialog,
|
|
refreshAccount,
|
|
rectificationSessionOpenerRef,
|
|
} = params;
|
|
|
|
async function refreshRectificationEntrySummary() {
|
|
if (!account) return;
|
|
try {
|
|
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
|
|
if (!response.ok) return;
|
|
const payload = await response.json().catch(() => null);
|
|
setRectificationEntrySummary(entrySummaryFromResponse(payload));
|
|
} catch {
|
|
// The CTA falls back to the server-agnostic default labels.
|
|
}
|
|
}
|
|
|
|
async function refreshRectificationCase(caseId: string, sessionId: string) {
|
|
try {
|
|
const response = await fetch(rectificationCaseHref(caseId, sessionId), { cache: "no-store" });
|
|
if (!response.ok) return;
|
|
const payload = await response.json().catch(() => null);
|
|
setRectificationTurns(parsePersistedRectificationTurns(payload?.turns));
|
|
} catch {
|
|
// History refresh is best-effort; the stream restores live turns.
|
|
}
|
|
}
|
|
|
|
async function openRectificationCase(
|
|
intent: "homepage" | "session" | "new",
|
|
exactSessionId: string | null,
|
|
pendingConsultationQuestion: string | null,
|
|
) {
|
|
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|
|
|| rectificationMutationPending) {
|
|
sessionSelectionSource.current = "user";
|
|
return null;
|
|
}
|
|
const missingStep = missingProfileStep(profile);
|
|
if (missingStep) {
|
|
sessionSelectionSource.current = "user";
|
|
setRectificationSessionId(null);
|
|
setRectificationCaseId(null);
|
|
setRectificationPendingQuestion(null);
|
|
setOnboardingStep(missingStep);
|
|
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
|
return null;
|
|
}
|
|
|
|
// Read the selection source now: `selectSession` resets it synchronously
|
|
// right after handing off, and the URL write below happens after awaits.
|
|
const selectionSource = sessionSelectionSource.current;
|
|
rectificationOpenInFlight.current = true;
|
|
setRectificationLoading(true);
|
|
setRectificationOpeningSessionId(exactSessionId);
|
|
setRectificationError("");
|
|
|
|
try {
|
|
const response = await fetch("/api/rectification/cases/open", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(openRectificationRequestBody(intent, exactSessionId)),
|
|
});
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
const code = payload?.code;
|
|
if (code === "profile_incomplete") {
|
|
handleRectificationProfileIncomplete();
|
|
} else if (code === "invalid_open_request") {
|
|
setRectificationError(payload?.error || "生时校正打开请求不正确,请刷新后重试。");
|
|
} else if (code === "active_case_conflict") {
|
|
setRectificationError(payload?.error || "仍有未完成的校正,请先回到当前校正。");
|
|
} else if (code === "case_session_not_found") {
|
|
setRectificationError("该校正会话不存在或已结束。");
|
|
} else {
|
|
setRectificationError(payload?.error || "暂时无法打开生时校正。");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const opened = openResponseFromPayload(payload);
|
|
if (!opened) {
|
|
setRectificationError("生时校正服务响应不正确,请稍后重试。");
|
|
return null;
|
|
}
|
|
|
|
// One reveal: turns and snapshot are read before the surface mounts, so
|
|
// the reader never sees an empty transcript or an empty question slot
|
|
// while they load. Past the deadline the surface opens with what arrived
|
|
// and its own retry path fills in the rest (BUG-479).
|
|
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
|
|
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
|
});
|
|
|
|
// Merge the server-created session into the local list. The browser
|
|
// never generates a Case id; it only mirrors the returned binding.
|
|
const existing = sessions.find((session) => session.id === opened.sessionId);
|
|
const merged: ChatSession = {
|
|
id: opened.sessionId,
|
|
title: resolveSessionTitle("生时校正", undefined, {
|
|
entrypoint: "birth_time_rectification",
|
|
existingTitles: sessions.map((session) => session.title),
|
|
}),
|
|
theme: "general",
|
|
modelId: modelCatalog.defaultModelId ?? "",
|
|
messages: [],
|
|
updatedAt: timestamp(),
|
|
sessionType: "birth_time_rectification",
|
|
rectificationCaseId: opened.caseId,
|
|
pinned: existing?.pinned ?? false,
|
|
archivedAt: existing?.archivedAt ?? null,
|
|
messagesHydrated: true,
|
|
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
|
|
};
|
|
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
|
|
void persistSession(merged).catch(() => {});
|
|
|
|
setRectificationPendingQuestion(pendingConsultationQuestion);
|
|
setDraft("");
|
|
setDraftTheme(null);
|
|
setDraftEntrypoint(null);
|
|
setRectificationTurns(hydration.turns);
|
|
setRectificationSnapshot(hydration.snapshot);
|
|
setRectificationSessionId(opened.sessionId);
|
|
setRectificationCaseId(opened.caseId);
|
|
setRectificationShouldStartOpening(opened.shouldStartOpening);
|
|
setRectificationReadonly(
|
|
opened.disposition === "readonly" || isTerminalRectificationStatus(opened.status),
|
|
);
|
|
activeSessionIdRef.current = opened.sessionId;
|
|
setActiveSessionId(opened.sessionId);
|
|
if (!uiPreview.current && selectionSource === "user") {
|
|
writeSessionUrl(opened.sessionId, "push");
|
|
}
|
|
if (!hydration.complete) setComposerNotice(RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE);
|
|
void refreshRectificationEntrySummary();
|
|
return opened;
|
|
} catch {
|
|
setRectificationError("生时校正会话暂时无法打开,请稍后重试。");
|
|
return null;
|
|
} finally {
|
|
sessionSelectionSource.current = "user";
|
|
rectificationOpenInFlight.current = false;
|
|
setRectificationOpeningSessionId(null);
|
|
setRectificationLoading(false);
|
|
}
|
|
}
|
|
|
|
async function openRectificationFromHomepage(pendingConsultationQuestion: string | null = null) {
|
|
await openRectificationCase("homepage", null, pendingConsultationQuestion);
|
|
}
|
|
|
|
async function openRectificationSession(exactSessionId: string) {
|
|
await openRectificationCase("session", exactSessionId, null);
|
|
}
|
|
|
|
async function startNewRectification() {
|
|
await openRectificationCase("new", null, null);
|
|
}
|
|
|
|
resumeRectificationSession.current = (session) => {
|
|
sessionSelectionSource.current = "history";
|
|
void openRectificationSession(session.id);
|
|
};
|
|
rectificationSessionOpenerRef.current = openRectificationSession;
|
|
|
|
function handleRectificationProfileIncomplete() {
|
|
setRectificationError("profile_incomplete");
|
|
setRectificationSessionId(null);
|
|
setRectificationCaseId(null);
|
|
setRectificationPendingQuestion(null);
|
|
const missingStep = missingProfileStep(profile);
|
|
if (missingStep) {
|
|
setOnboardingStep(missingStep);
|
|
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
|
return;
|
|
}
|
|
openAccountDialog("profile");
|
|
setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。");
|
|
void refreshAccount();
|
|
}
|
|
|
|
function handleRectificationMessagesChange(messages: Message[]) {
|
|
if (!rectificationSessionId) return;
|
|
updateSession(rectificationSessionId, (session) => ({
|
|
...session,
|
|
messages,
|
|
updatedAt: timestamp(),
|
|
}));
|
|
}
|
|
|
|
return {
|
|
refreshRectificationEntrySummary,
|
|
refreshRectificationCase,
|
|
openRectificationCase,
|
|
openRectificationFromHomepage,
|
|
openRectificationSession,
|
|
startNewRectification,
|
|
handleRectificationProfileIncomplete,
|
|
handleRectificationMessagesChange,
|
|
};
|
|
}
|