diff --git a/PROGRESS-unified-loading-20260902.md b/PROGRESS-unified-loading-20260902.md new file mode 100644 index 00000000..c604aafa --- /dev/null +++ b/PROGRESS-unified-loading-20260902.md @@ -0,0 +1,46 @@ +# PROGRESS · 首页加载统一:一次等待、一次揭幕(2026-09-02) + +分支:`codex/unified-loading-20260902` +基线:`origin/staging` @ `02f06255`,并先行合入 `codex/streaming-ux-20260901` @ `c846c44a`(任务书前置条件;合并无冲突,merge commit `25e3094f`)。本轮改动写在合并后的代码形态上。 + +执行方:Claude(用户 2026-09-02 明示"直接开始执行")。 + +## 结论 + +登录后只有一次揭幕。加载屏两阶段(载入账户 → 准备对话),阶段二并行取推荐问题、今日星语、校正入口摘要并预热校正分包;全部就绪或 4 秒到期才揭幕;揭幕后首页无任何组件级 spinner / busy / "正在…"文案(流式生成中除外)。 + +## 与任务书的差异 + +- **当前会话消息**:合入的流式分支已把 `fetchSessionDetail` 前移到 `setHydrated` 之前(`page.tsx` 启动 effect),本轮不再重复做,只把它计入"阶段二"的语义说明。 +- **阶段二实现方式**:没有把三个请求搬进启动 effect 的 `Promise.allSettled`,而是把三个既有 effect 的门槛从 `hydrated` 改为 `bootstrapPhase`(`"account" → "prepare"`),再用一个揭幕 effect 汇总就绪状态。理由:三个 effect 各自带重试/身份守卫/缓存逻辑(推荐问题的 `onboardingRequestIdentity`、今日星语的当日缓存与一次重试),原样复用不会产生重复请求,也不必复制这些逻辑。合同测试锁 `bootstrapPhase === "account" || !accountId` 门槛恰为 3 处。 +- **今日星语的 pending 文案**:任务书写"非个性化文案";实际采用与 unavailable 相同的静态句「今天的星语还没写出来。」,到达后静默替换。原「正在写下今天的星语。」删除。 + +## 改动清单 + +| 文件 | 内容 | +| --- | --- | +| `src/lib/home-bootstrap.ts`(新) | `BootstrapPhase`、`BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000`、`SESSION_PREFETCH_COUNT = 5`、`bootstrapPrepareSettled`、`bootstrapRevealDelayMs`、`bootstrapLoadingCopy`、`sessionIdsToPrefetch` | +| `src/app/page.tsx` | `bootstrapPhase` / `rectificationEntrySummarySettled` state、`prepareStartedAt` ref;`finally` 里成功路径改 `setBootstrapPhase("prepare")`(失败仍 `setHydrated(true)`);三个 effect 门槛与依赖改为 `bootstrapPhase`;入口摘要 effect 加 `finally` 置 settled;新增揭幕 effect(就绪或超时)、分包预热 effect、揭幕后预取 effect;加载屏文案按阶段;删 `starter-loading` 分支、`dailyStarlanguageBusy`、`InlineSpinner` import 与会话切换 spinner | +| `src/components/starter-home.tsx` | 删 `dailyStarlanguageBusy` prop 与 `aria-busy` | +| `src/app/globals.css` | 删两处 `.starter-loading` 定义(已无使用) | +| `DESIGN.md` §9 | 整页阻塞行改为"两阶段载入";补首页揭幕规则段 | +| `tests/home-bootstrap-reveal.test.ts`(新) | 7 条:纯函数 + 源码合同 | +| `tests/daily-starlanguage.test.ts` | 例外条款:effect 切片标记改为新门槛与新依赖(原值 `!hydrated ...` / `[..., hydrated, ...]`);`aria-busy={dailyStarlanguageBusy}` 正断言改为不存在(原值见注释) | +| `tests/starter-questions.test.ts` | 例外条款:守卫切片终点由 `"(onboardingPending ?"` 改为 `"` 是独立设计系统,不在此表。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 43cee3fe..5ba01b09 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3127c204..25395b50 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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([]); const [rectificationEntrySummary, setRectificationEntrySummary] = useState(null); const [hydrated, setHydrated] = useState(false); + const [bootstrapPhase, setBootstrapPhase] = useState("account"); + const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false); + const prepareStartedAt = useRef(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 (
- +
); } @@ -1824,11 +1872,7 @@ export default function Home() { {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} - {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? ( -
- -
- ) : ( + {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && ( - ))} + )} ) : sessionMessagesLoading ? (
- 正在加载聊天记录
) : ( diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx index 39a29a1d..0ba40545 100644 --- a/frontend/src/components/starter-home.tsx +++ b/frontend/src/components/starter-home.tsx @@ -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({

{natalMinuteAvailable ? "今日星语" : "每日运势"}

{natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}

diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts new file mode 100644 index 00000000..78b474b9 --- /dev/null +++ b/frontend/src/lib/home-bootstrap.ts @@ -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; +} diff --git a/frontend/tests/daily-starlanguage.test.ts b/frontend/tests/daily-starlanguage.test.ts index 2ec064e6..98a48cad 100644 --- a/frontend/tests/daily-starlanguage.test.ts +++ b/frontend/tests/daily-starlanguage.test.ts @@ -309,11 +309,15 @@ test("the homepage card is engine-backed first, with Agent polish off the reques test("the home requests the card exactly when it renders one, and retries a failed day once", () => { const page = homeSurface; const effect = page.slice( - page.indexOf("if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;"), - page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);"), + // Former value: guarded on `!hydrated`; the card is now requested during the bootstrap + // "prepare" phase so it is settled before the page is revealed. + page.indexOf("if (bootstrapPhase === \"account\" || !accountId || !profileComplete || !natalMinuteAvailable) return;"), + page.indexOf("}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);"), ); - assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/); + // Former value: assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/) — the first fetch + // no longer shows a busy state after reveal; a pending card reads as the static copy. + assert.doesNotMatch(page, /dailyStarlanguageBusy/); assert.ok(effect.length > 0); assert.doesNotMatch(page, /birthTimeDisplayState\(profile\)/); assert.match(effect, /next\.kind === "unavailable" && remainingRetries > 0/); diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts new file mode 100644 index 00000000..cd396578 --- /dev/null +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + BOOTSTRAP_PREPARE_TIMEOUT_MS, + SESSION_PREFETCH_COUNT, + bootstrapLoadingCopy, + bootstrapPrepareSettled, + bootstrapRevealDelayMs, + sessionIdsToPrefetch, +} from "../src/lib/home-bootstrap.ts"; +import { homeSurface } from "./home-surface.ts"; + +// Product rule (2026-09-02): the loading screen owns every wait. After the home +// page is revealed there is no component-level spinner left — only the streaming +// answer keeps a live marker, because that is content being generated, not data +// being loaded. + +const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8"); +const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8"); + +test("prepare phase settles only when every applicable item has an answer", () => { + const base = { + profileComplete: true, + onboardingSettled: true, + dailyStarlanguageApplicable: true, + dailyStarlanguageSettled: true, + entrySummarySettled: true, + }; + assert.equal(bootstrapPrepareSettled(base), true); + assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false); + assert.equal(bootstrapPrepareSettled({ ...base, onboardingSettled: false }), false); + assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false); + // Items that do not apply to this account never hold the reveal. + assert.equal(bootstrapPrepareSettled({ ...base, profileComplete: false, onboardingSettled: false }), true); + assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true); +}); + +test("reveal delay is zero once settled and counts down from the phase start otherwise", () => { + assert.equal(BOOTSTRAP_PREPARE_TIMEOUT_MS, 4000); + assert.equal(bootstrapRevealDelayMs(10_000, 9_000, true), 0); + assert.equal(bootstrapRevealDelayMs(10_000, 9_000, false), 3000); + assert.equal(bootstrapRevealDelayMs(20_000, 9_000, false), 0); + assert.equal(bootstrapRevealDelayMs(10_000, null, false), BOOTSTRAP_PREPARE_TIMEOUT_MS); +}); + +test("loading copy changes per phase so the single loading screen narrates progress", () => { + const account = bootstrapLoadingCopy("account"); + const prepare = bootstrapLoadingCopy("prepare"); + assert.notEqual(account.title, prepare.title); + assert.notEqual(account.detail, prepare.detail); + assert.match(page, /const loadingCopy = bootstrapLoadingCopy\(bootstrapPhase\);/); + assert.match(page, //); +}); + +test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => { + assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/); + // The three secondary fetches start in the prepare phase, not after reveal. + const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? []; + assert.equal(guards.length, 3, "onboarding, daily starlanguage and entry summary must all key off bootstrapPhase"); + assert.match(page, /setRectificationEntrySummarySettled\(true\);/); + assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/); + assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/); + assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/); +}); + +test("revealed home carries no component loading state", () => { + assert.doesNotMatch(page, /starter-loading/); + assert.doesNotMatch(starterHome, /starter-loading|dailyStarlanguageBusy|aria-busy=\{/); + assert.doesNotMatch(globalsCss, /\.starter-loading/); + assert.doesNotMatch(page, /InlineSpinner/); + assert.doesNotMatch(page, /dailyStarlanguageBusy|正在写下今天的星语/); + // Switching to a session whose messages are not cached keeps a silent placeholder. + assert.match(page, /className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">\n\s*正在加载聊天记录<\/span>/); +}); + +test("recent sessions are prefetched after reveal so switches do not wait", () => { + assert.equal(SESSION_PREFETCH_COUNT, 5); + const sessions = [ + { id: "active", sessionType: "consultation" }, + { id: "a", sessionType: "consultation" }, + { id: "b", sessionType: "consultation", messagesHydrated: true }, + { id: "r", sessionType: "birth_time_rectification" }, + { id: "c", sessionType: "consultation" }, + { id: "d", sessionType: "consultation" }, + { id: "e", sessionType: "consultation" }, + { id: "f", sessionType: "consultation" }, + { id: "g", sessionType: "consultation" }, + ]; + assert.deepEqual(sessionIdsToPrefetch(sessions, "active"), ["a", "c", "d", "e", "f"]); + assert.match(page, /sessionIdsToPrefetch\(sessionsRef\.current, activeSessionIdRef\.current\)/); + assert.match(page, /await ensureSessionMessages\(sessionId\);/); +}); + +test("the loading screen DOM contract stale-client recovery depends on is untouched", () => { + assert.match(page, /
/); + assert.match(page, /
/); + assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/); + assert.match(staleClientRecovery, /main\.app-loading-error/); + assert.ok(homeSurface.includes("bootstrapPrepareSettled(")); +}); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index aff178f9..e348b08f 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -26,7 +26,10 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) { test("keeps starter questions visible while the user edits a draft", () => { // Given: the empty-session starter block and its render guard. const guardStart = pageSource.indexOf("{profileComplete && presetMessageFinished"); - const onboardingBranch = pageSource.indexOf("(onboardingPending ?", guardStart); + // Former value: pageSource.indexOf("(onboardingPending ?", guardStart) — the guard used to + // branch into a `starter-loading` block. Home now reveals only after the starter questions + // settle (or time out), so the guard leads straight into . + const onboardingBranch = pageSource.indexOf("