fix(chat): reveal the home page once after a two-phase bootstrap
The loading screen now owns every wait: starter questions, today's starlanguage and the rectification entry summary are fetched in a prepare phase before reveal (4s budget, static fallbacks), the rectification chunk is warmed, recent sessions are prefetched, and no component spinner remains after the page appears. BUG-479. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7557e0d2ed
commit
482796fc52
@@ -1009,7 +1009,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; }
|
||||
.product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; }
|
||||
.rectification-entry-error { grid-column: 1 / -1; margin: 0; }
|
||||
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
|
||||
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
|
||||
.message-list {
|
||||
--assistant-content-inset: calc(32px + var(--space-3));
|
||||
@@ -2181,14 +2180,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
transition: color 180ms ease-out, transform 180ms var(--ease-out);
|
||||
}
|
||||
|
||||
.starter-loading {
|
||||
width: min(1040px, 100%);
|
||||
margin-left: 0;
|
||||
padding: var(--space-6);
|
||||
min-height: min(52vh, 460px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.starter-note {
|
||||
margin: calc(var(--space-2) * -1) 0 0;
|
||||
|
||||
+63
-21
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
@@ -227,6 +226,13 @@ import {
|
||||
waitForUndoWindow,
|
||||
writeStoredDailyStarlanguage,
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import {
|
||||
bootstrapLoadingCopy,
|
||||
bootstrapPrepareSettled,
|
||||
bootstrapRevealDelayMs,
|
||||
sessionIdsToPrefetch,
|
||||
type BootstrapPhase,
|
||||
} from "@/lib/home-bootstrap";
|
||||
|
||||
const BirthTimeRectification = dynamic(
|
||||
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
|
||||
@@ -291,6 +297,9 @@ export default function Home() {
|
||||
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
|
||||
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
|
||||
const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
|
||||
const prepareStartedAt = useRef<number | null>(null);
|
||||
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
@@ -518,7 +527,7 @@ export default function Home() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
if (bootstrapPhase === "account" || !accountId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
|
||||
@@ -527,9 +536,11 @@ export default function Home() {
|
||||
setRectificationEntrySummary(entrySummaryFromResponse(payload));
|
||||
} catch {
|
||||
// The CTA falls back to the server-agnostic default labels.
|
||||
} finally {
|
||||
setRectificationEntrySummarySettled(true);
|
||||
}
|
||||
})();
|
||||
}, [accountId, hydrated]);
|
||||
}, [accountId, bootstrapPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
@@ -628,12 +639,43 @@ export default function Home() {
|
||||
: "深入看今日";
|
||||
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
|
||||
? dailyStarlanguage.card.trend
|
||||
: dailyStarlanguage.kind === "pending"
|
||||
? "正在写下今天的星语。"
|
||||
: "今天的星语还没写出来。";
|
||||
: "今天的星语还没写出来。";
|
||||
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
|
||||
const dailyStarlanguageBusy = natalMinuteAvailable && dailyStarlanguage.kind === "pending";
|
||||
const onboardingPending = profileComplete && !onboarding && !onboardingError;
|
||||
const bootstrapPrepareReady = bootstrapPrepareSettled({
|
||||
profileComplete,
|
||||
onboardingSettled: onboarding !== null || onboardingError !== "",
|
||||
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
|
||||
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
|
||||
entrySummarySettled: rectificationEntrySummarySettled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated || bootstrapPhase !== "prepare") return;
|
||||
prepareStartedAt.current ??= Date.now();
|
||||
const delay = bootstrapRevealDelayMs(Date.now(), prepareStartedAt.current, bootstrapPrepareReady);
|
||||
const timer = window.setTimeout(() => setHydrated(true), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [bootstrapPhase, bootstrapPrepareReady, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bootstrapPhase !== "prepare" || uiPreview.current) return;
|
||||
void import("@/components/birth-time-rectification");
|
||||
}, [bootstrapPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || uiPreview.current) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (const sessionId of sessionIdsToPrefetch(sessionsRef.current, activeSessionIdRef.current)) {
|
||||
if (cancelled) return;
|
||||
await ensureSessionMessages(sessionId);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hydrated]);
|
||||
const currentOnboardingMessage = onboardingJustCompleted
|
||||
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
|
||||
: onboardingStep === "birth"
|
||||
@@ -756,6 +798,7 @@ export default function Home() {
|
||||
|
||||
async function loadCloudData() {
|
||||
let redirectedToLogin = false;
|
||||
let bootstrapFailed = false;
|
||||
try {
|
||||
const previewMode = process.env.NODE_ENV === "development"
|
||||
? new URLSearchParams(window.location.search).get("preview")
|
||||
@@ -1001,12 +1044,16 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
|
||||
bootstrapFailed = true;
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
|
||||
}
|
||||
} finally {
|
||||
if (redirectedToLogin) return;
|
||||
window.clearTimeout(bootstrapTimeout);
|
||||
if (!controller.signal.aborted) setHydrated(true);
|
||||
if (!controller.signal.aborted) {
|
||||
if (bootstrapFailed) setHydrated(true);
|
||||
else setBootstrapPhase("prepare");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,7 +1198,7 @@ export default function Home() {
|
||||
}, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId || !profileComplete || uiPreview.current) return;
|
||||
if (bootstrapPhase === "account" || !accountId || !profileComplete || uiPreview.current) return;
|
||||
const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint);
|
||||
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
|
||||
activeOnboardingRequestIdentity.current = requestIdentity;
|
||||
@@ -1185,10 +1232,10 @@ export default function Home() {
|
||||
}
|
||||
controller.abort();
|
||||
};
|
||||
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]);
|
||||
}, [accountId, bootstrapPhase, onboardingFingerprint, profile.name, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;
|
||||
if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return;
|
||||
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
|
||||
const fingerprint = dailyStarlanguageProfileKey(profile);
|
||||
const stored = readStoredDailyStarlanguage(accountId);
|
||||
@@ -1229,7 +1276,7 @@ export default function Home() {
|
||||
controller.abort();
|
||||
if (retryTimer !== undefined) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);
|
||||
}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (starterHomeVisible) {
|
||||
@@ -1491,9 +1538,10 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (!hydrated || (!account && !accountError)) {
|
||||
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
|
||||
return (
|
||||
<main className="app-loading" aria-busy="true" aria-live="polite">
|
||||
<AppLoadingIndicator title="正在载入账户" detail="同步个人资料与对话记录" />
|
||||
<AppLoadingIndicator title={loadingCopy.title} detail={loadingCopy.detail} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1824,11 +1872,7 @@ export default function Home() {
|
||||
|
||||
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
|
||||
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
|
||||
<div className="starter-loading" role="status">
|
||||
<AppLoadingIndicator title="正在准备问题" detail="根据你的资料整理今天的起点。" />
|
||||
</div>
|
||||
) : (
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
|
||||
<StarterHome
|
||||
starterGreeting={starterGreeting}
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
@@ -1836,7 +1880,6 @@ export default function Home() {
|
||||
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
|
||||
dailyStarlanguageTrend={dailyStarlanguageTrend}
|
||||
dailyStarlanguageAction={dailyStarlanguageAction}
|
||||
dailyStarlanguageBusy={dailyStarlanguageBusy}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
|
||||
rectificationCardLabel={rectificationCardLabel}
|
||||
@@ -1853,11 +1896,10 @@ export default function Home() {
|
||||
startSuggestedConsultation={startSuggestedConsultation}
|
||||
onboardingError={onboardingError}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
) : sessionMessagesLoading ? (
|
||||
<div className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">
|
||||
<InlineSpinner size={20} />
|
||||
<span className="sr-only">正在加载聊天记录</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -22,7 +22,6 @@ export type StarterHomeProps = {
|
||||
readonly dailyStarlanguageQuestion: string;
|
||||
readonly dailyStarlanguageTrend: string;
|
||||
readonly dailyStarlanguageAction: string;
|
||||
readonly dailyStarlanguageBusy: boolean;
|
||||
readonly productEntrypointsDisabled: boolean;
|
||||
readonly startDailyStarlanguageConsultation: () => void;
|
||||
readonly rectificationCardLabel: string;
|
||||
@@ -47,7 +46,6 @@ export function StarterHome({
|
||||
dailyStarlanguageQuestion,
|
||||
dailyStarlanguageTrend,
|
||||
dailyStarlanguageAction,
|
||||
dailyStarlanguageBusy,
|
||||
productEntrypointsDisabled,
|
||||
startDailyStarlanguageConsultation,
|
||||
rectificationCardLabel,
|
||||
@@ -85,7 +83,6 @@ export function StarterHome({
|
||||
<div className="product-entrypoint-copy">
|
||||
<h2 id="daily-starlanguage-title">{natalMinuteAvailable ? "今日星语" : "每日运势"}</h2>
|
||||
<p
|
||||
aria-busy={dailyStarlanguageBusy}
|
||||
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
|
||||
>{natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Home bootstrap reveal policy.
|
||||
*
|
||||
* The loading screen owns every wait before the page appears. Phase "account"
|
||||
* fetches account, model catalog and the session list; phase "prepare" fetches
|
||||
* the starter questions, today's starlanguage and the rectification entry
|
||||
* summary in parallel. The page is revealed once every applicable item has
|
||||
* settled, or when the prepare budget runs out — never with a spinner inside.
|
||||
*/
|
||||
|
||||
export type BootstrapPhase = "account" | "prepare";
|
||||
|
||||
export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000;
|
||||
export const SESSION_PREFETCH_COUNT = 5;
|
||||
|
||||
export type BootstrapPrepareState = Readonly<{
|
||||
profileComplete: boolean;
|
||||
onboardingSettled: boolean;
|
||||
dailyStarlanguageApplicable: boolean;
|
||||
dailyStarlanguageSettled: boolean;
|
||||
entrySummarySettled: boolean;
|
||||
}>;
|
||||
|
||||
export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean {
|
||||
if (!state.entrySummarySettled) return false;
|
||||
if (state.profileComplete && !state.onboardingSettled) return false;
|
||||
if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bootstrapRevealDelayMs(now: number, prepareStartedAt: number | null, settled: boolean): number {
|
||||
if (settled) return 0;
|
||||
if (prepareStartedAt === null) return BOOTSTRAP_PREPARE_TIMEOUT_MS;
|
||||
return Math.max(0, BOOTSTRAP_PREPARE_TIMEOUT_MS - (now - prepareStartedAt));
|
||||
}
|
||||
|
||||
export function bootstrapLoadingCopy(phase: BootstrapPhase): Readonly<{ title: string; detail: string }> {
|
||||
return phase === "prepare"
|
||||
? { title: "正在准备对话", detail: "整理推荐问题、今日星语与对话记录" }
|
||||
: { title: "正在载入账户", detail: "同步个人资料与对话记录" };
|
||||
}
|
||||
|
||||
export type PrefetchableSession = Readonly<{
|
||||
id: string;
|
||||
sessionType: string;
|
||||
messagesHydrated?: boolean;
|
||||
}>;
|
||||
|
||||
export function sessionIdsToPrefetch(
|
||||
sessions: readonly PrefetchableSession[],
|
||||
activeSessionId: string,
|
||||
limit = SESSION_PREFETCH_COUNT,
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const session of sessions) {
|
||||
if (ids.length >= limit) break;
|
||||
if (session.id === activeSessionId) continue;
|
||||
if (session.messagesHydrated) continue;
|
||||
if (session.sessionType !== "consultation") continue;
|
||||
ids.push(session.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
Reference in New Issue
Block a user