diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 83b64292..37621259 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -14888,3 +14888,19 @@ - 相关记录:BUG-1094 - 复发自:BUG-1094 的遗漏 - 修复版本:分支 `codex/surface-feedback-20260929` + +## BUG-1104 | 先打开星盘 / 报告等页面的标签,第一次点「新建对话」仍要等两三秒 + +- 状态:resolved(代码 + 生命周期回归测试;真机浏览器耗时未测) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`frontend/src/lib/session-list-context.tsx`(新增后台预取)、`frontend/src/lib/home-warm-prefetch.ts`(新)、`frontend/src/lib/home-cloud-sync.ts::fetchRectificationEntrySummary`(从首页 effect 抽出,冷启动与预取共用)、`frontend/src/lib/new-chat-timing.ts`(新)、`frontend/src/components/app-sidebar.tsx`。 +- 用户现象:在星盘、星历、我的报告、星盘档案等页面点侧栏「新建对话」,要等两三秒才进首页(整段加载屏)。 +- 触发条件:这个标签页里首页还没有完整冷启动过——刷新了次级页、从链接直接打开次级页,再点「新建对话」。首页已经冷启动过的标签不复现(BUG-1040 的暖快照生效)。 +- 根因:暖快照只由首页自己在冷启动成功后写入(`src/app/(app)/page.tsx` 写快照的 effect);`home-warm-start.ts::resolveHomeWarmStart` 读到 `isCompleteHomeWarmSnapshot` 为假(没有模型目录 / 入口摘要未 settle)就回冷路径。所以「先落在次级页」的标签第一次回首页必然整段冷启动。`/` 路由的代码块本来就由 Next `Link` 预取,不是原因。 +- 修复:`SessionListProvider` 在列表 settled、已登录、资料完整、首页未注册、地址不是 `/` 时,于空闲时刻用冷启动同一组客户端函数(`fetchModelCatalog`、`fetchRectificationEntrySummary`)只读预取,并经同一个 `writeHomeWarmSnapshot` 写入(`listBoot: null`,游标以 provider 自己的 boot 为准)。首页挂载 / 退出 / 换账户 / 列表重载即 abort,写入前再确认仍是当前;目录失败不写,摘要失败按冷启动同样 settle 为默认卡。另加本地 Performance 打点 `jyotisha:new-chat (warm|cold)`。 +- 验证:`frontend/tests/home-warm-return-lifecycle.test.tsx` 新增 3 条(真实 Home + 侧栏 + provider):标签先开 `/chart`、`/reports`,预取完成后「新建对话」首帧无加载屏且在任何请求发出前就绪,预取只发 2 个 GET;预取失败时不留快照、照旧冷启动。去掉预取调用后两条暖路径用例超时失败(复现原问题)。`frontend/tests/home-warm-prefetch.test.ts` 6 条覆盖取消、已完整不重取、失败、空目录、单飞与账户隔离。 +- 防复发:新的首页冷启动必需件(`isCompleteHomeWarmSnapshot` 条件)必须同时加进 `home-warm-prefetch.ts`,否则预取会写出一个永远不完整、被忽略的快照——生命周期用例会因此超时报警。 +- 未验证:真机浏览器上的实际秒数(无登录态与 Chrome);部署后可在 DevTools Performance 里看 `jyotisha:new-chat` 的测量值。 +- 相关记录:BUG-1040、BUG-1052 +- 复发自:无(BUG-1040 的覆盖缺口) +- 修复版本:分支 `codex/new-chat-prewarm-20260929` diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ebbe00a7..ebbdc027 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -806,6 +806,8 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小 **每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户菜单的弹窗项(`/?account=`)或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、其余一律当前人物的空白首页,同 BUG-1052),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。 +**先打开次级页的标签也走暖快照(2026-09-29,BUG-1104)。** 暖快照原本只在首页冷启动成功一次之后才有;刷新或直接打开 `/chart`、`/ephemeris`、`/reports`、`/people` 的标签,第一次点「新建对话」仍要整段加载屏。现在 `SessionListProvider` 在列表就绪、资料完整、首页未挂载、地址不是 `/` 时,于空闲时刻(`requestIdleCallback`,Safari 退回 300ms 定时器)用冷启动同一组客户端函数只读地取模型目录与校正入口摘要(`lib/home-warm-prefetch.ts`,只发 GET,不做下线模型 PATCH、不做咨询恢复——这些仍由落地后的后台刷新做),通过同一个 `writeHomeWarmSnapshot` 写入;游标留给 provider 自己的 boot。首页挂载、退出、换账户或列表重载即取消,迟到的回答不写入。取失败不留半个快照,首页照旧冷启动。点击到首页可用的耗时记为浏览器 Performance 的 `jyotisha:new-chat (warm|cold)`,只在本机,不上报。 + **打开 `/` 落在哪里(2026-09-27,BUG-1052)。** 地址栏带 `?c=` 就进那条对话(分享链接、侧栏历史、在对话里刷新都靠它);带 `?new=1` 是新建对话;其余——登录后、手输网址、书签、刷新裸 `/`——一律是当前人物的空白首页(开场问候 + 输入框),已有一个没发过消息的新对话就复用它。不再记「上次在哪条对话」:离开首页、登录过期都不存任何返回记录,后台还在恢复的旧回答也不把空白首页切走。 首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 46e018ab..c917db37 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -14,7 +14,6 @@ import { HomeBootstrapError } from "@/components/home-bootstrap-error"; import { HomeOnboardingShell } from "@/components/home-onboarding-shell"; import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal"; import { - entrySummaryFromResponse, rectificationEntryLabels, resolveRectificationEntryAction, } from "@/lib/rectification-entry"; @@ -159,12 +158,14 @@ import { discardLegacyCloudMirrorKeys, fetchCloudChartLibrary, fetchCloudSynastryHistory, + fetchRectificationEntrySummary, payloadCode, payloadMessage, waitForUndoWindow, } from "@/lib/home-cloud-sync"; import { runHomeBootstrap, runHomeWarmRefresh } from "@/lib/home-bootstrap-run"; import { writeHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; +import { markHomeReadyAfterNewChat } from "@/lib/new-chat-timing"; import { homeWarmStartInput, mountedHomeWarmStart, @@ -507,10 +508,8 @@ export default function Home() { if (bootstrapPhase === "account" || !accountId || !sessionListSettled) return; void (async () => { 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)); + const summary = await fetchRectificationEntrySummary(); + if (summary) setRectificationEntrySummary(summary); } catch { // The CTA falls back to the server-agnostic default labels. } finally { @@ -523,6 +522,7 @@ export default function Home() { // incomplete one (no catalog, summary not settled, error) is never written. useEffect(() => { if (!hydrated || uiPreview.current || accountError || !accountId || !modelCatalog || !rectificationEntrySummarySettled) return; + markHomeReadyAfterNewChat(); writeHomeWarmSnapshot({ accountId, modelCatalog, diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 2aab0c88..999702a7 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -47,6 +47,7 @@ import { ThemePreferenceMenu } from "@/components/theme-preference-menu"; import { UserAvatar } from "@/components/user-avatar"; import type { BeamAvatar } from "@/lib/beam-avatar"; import { groupSessionsByRecency } from "@/lib/session-groups"; +import { markNewChatClick } from "@/lib/new-chat-timing"; export type SidebarAccount = { name: string; @@ -185,6 +186,12 @@ export function AppSidebar({ closeDrawer(); } + /** Off `/`: the same drawer close, plus the click mark for new-chat timing (BUG-1104). */ + function handleNewChatLink() { + markNewChatClick(); + closeDrawer(); + } + /* Real links. Leaving `/` used to be a full document load: the whole React tree, the session list and the account went with it, so coming back re-ran the entire bootstrap from zero. `/login` keeps the hard exit — @@ -278,7 +285,7 @@ export function AppSidebar({ {showExpandedContent ? {creatingSession ? "正在创建" : "新建对话"} : null} ) : ( - + diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 2329f37b..5e9a0228 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -1,3 +1,4 @@ +import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry"; import { writeChatSession } from "@/lib/chat-session-write-contract"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { @@ -438,6 +439,17 @@ export async function fetchAccount(signal?: AbortSignal): Promise { return payload as Account; } +/** + * The rectification entry card's summary. Null on a non-OK answer; throws on + * a network error. Shared by Home's cold start and the warm prefetch (BUG-1104). + */ +export async function fetchRectificationEntrySummary(signal?: AbortSignal): Promise { + const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store", signal }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + return entrySummaryFromResponse(payload); +} + export async function fetchModelCatalog(signal?: AbortSignal) { const response = await fetch("/api/models", { signal, cache: "no-store" }); const payload = await response.json().catch(() => null); diff --git a/frontend/src/lib/home-warm-prefetch.ts b/frontend/src/lib/home-warm-prefetch.ts new file mode 100644 index 00000000..caa9df70 --- /dev/null +++ b/frontend/src/lib/home-warm-prefetch.ts @@ -0,0 +1,73 @@ +/** + * Warm the Home snapshot from a secondary page (BUG-1104). + * + * BUG-1040's warm return only works after Home has cold-started once in this + * document. A tab that opens on /chart, /ephemeris, /reports or /people (a + * refresh, a shared link, the post-login landing of a deep link) has no + * snapshot, so its first 「新建对话」 plays the whole loading ring. + * + * While such a page sits idle, fetch exactly what `isCompleteHomeWarmSnapshot` + * requires — the model catalog and the rectification entry summary — with the + * same client helpers the cold start uses, and write it through the same + * `writeHomeWarmSnapshot`. Read-only GETs only: no fallback PATCH, no + * consultation recovery. Those still run in Home's warm refresh after landing. + * The cursor is left to the provider's own boot (`listBoot: null`). + * + * Pure module state, no React. + */ + +import { fetchModelCatalog, fetchRectificationEntrySummary } from "./home-cloud-sync"; +import { isCompleteHomeWarmSnapshot, readHomeWarmSnapshot, writeHomeWarmSnapshot } from "./home-warm-snapshot"; +import type { PublicLanguageModelCatalog } from "./public-models"; +import type { RectificationEntrySummary } from "./rectification-entry"; + +export type HomeWarmPrefetchIo = Readonly<{ + fetchModelCatalog: (signal?: AbortSignal) => Promise; + fetchRectificationEntrySummary: (signal?: AbortSignal) => Promise; +}>; + +const defaultIo: HomeWarmPrefetchIo = { fetchModelCatalog, fetchRectificationEntrySummary }; + +let inflight: { accountId: string; promise: Promise } | null = null; + +/** + * Fill the snapshot for `accountId` unless it is already complete. Resolves to + * true when this call wrote a complete snapshot. Any failure leaves nothing + * behind: the next Home simply takes the unchanged cold path. + * `isCurrent` is asked again before writing, so an account switch, sign-out + * or a Home that mounted meanwhile never receives a late write. + */ +export function prefetchHomeWarmSnapshot( + accountId: string, + isCurrent: () => boolean, + io: HomeWarmPrefetchIo = defaultIo, + signal?: AbortSignal, +): Promise { + if (!accountId || isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(accountId))) return Promise.resolve(false); + if (inflight && inflight.accountId === accountId) return inflight.promise; + const promise = (async () => { + try { + const [modelCatalog, summary] = await Promise.all([ + io.fetchModelCatalog(signal), + // Same settle rule as the cold start: a failed summary reads as the default card. + io.fetchRectificationEntrySummary(signal).catch(() => null), + ]); + if (signal?.aborted || !isCurrent()) return false; + if (isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(accountId))) return false; + const next = { accountId, modelCatalog, entrySummary: summary, entrySummarySettled: true, sessionsCursor: null, listBoot: null }; + if (!isCompleteHomeWarmSnapshot(next)) return false; + writeHomeWarmSnapshot(next); + return true; + } catch { + return false; + } finally { + inflight = null; + } + })(); + inflight = { accountId, promise }; + return promise; +} + +export function resetHomeWarmPrefetchForTests(): void { + inflight = null; +} diff --git a/frontend/src/lib/home-warm-start.ts b/frontend/src/lib/home-warm-start.ts index b6f1ebb3..4172a955 100644 --- a/frontend/src/lib/home-warm-start.ts +++ b/frontend/src/lib/home-warm-start.ts @@ -44,6 +44,7 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog } from "@/lib/pu import type { RectificationEntrySummary } from "@/lib/rectification-entry"; import type { SessionListBoot, SessionListContextValue } from "@/lib/session-list-context"; import { replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter"; +import { noteHomeStartPath } from "@/lib/new-chat-timing"; import { createStartGreeting } from "@/lib/starter-greeting"; export type WarmLanding = Readonly<{ @@ -243,6 +244,7 @@ let landingCommitted: HomeWarmStart | null = null; export function takeHomeWarmStart(read: () => HomeWarmStartInput): HomeWarmStart | null { if (memo) return memo.result; const result = typeof window === "undefined" ? null : resolveHomeWarmStart(read()); + noteHomeStartPath(result ? "warm" : "cold"); memo = { result }; mounted = result; queueMicrotask(() => { diff --git a/frontend/src/lib/new-chat-timing.ts b/frontend/src/lib/new-chat-timing.ts new file mode 100644 index 00000000..a5779d78 --- /dev/null +++ b/frontend/src/lib/new-chat-timing.ts @@ -0,0 +1,45 @@ +/** + * 「新建对话」 timing (BUG-1104). Browser Performance marks only: nothing is + * sent anywhere and nothing personal is recorded. In DevTools → Performance + * (or `performance.getEntriesByType("measure")`) a slow case shows as + * `jyotisha:new-chat (warm|cold)` with its duration from click to a ready Home. + */ + +const CLICK = "jyotisha:new-chat:click"; +let pending = false; +let path: "warm" | "cold" | null = null; + +function perf(): Performance | null { + return typeof performance !== "undefined" && typeof performance.mark === "function" ? performance : null; +} + +/** The sidebar's 「新建对话」 was clicked (either the link off `/` or the button on it). */ +export function markNewChatClick(): void { + const p = perf(); + if (!p) return; + try { + p.clearMarks(CLICK); + p.mark(CLICK); + pending = true; + path = null; + } catch { + // Timing is best-effort. + } +} + +/** Which way the Home that mounts next decided to start. */ +export function noteHomeStartPath(kind: "warm" | "cold"): void { + if (pending) path ??= kind; +} + +/** Home is revealed and usable; closes the measure opened by the click, once. */ +export function markHomeReadyAfterNewChat(): void { + const p = perf(); + if (!p || !pending) return; + pending = false; + try { + p.measure(`jyotisha:new-chat (${path ?? "unknown"})`, CLICK); + } catch { + // The click mark was cleared by another tool; nothing to measure. + } +} diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index b7c0dcc5..a24a229f 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -19,6 +19,8 @@ import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCa import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; import { readSessions } from "@/lib/home-cloud-sync"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; +import { prefetchHomeWarmSnapshot } from "@/lib/home-warm-prefetch"; +import { isProfileComplete, readProfile } from "@/lib/home-profile"; import type { Account, ChatSession } from "@/lib/home-types"; import { SESSION_PAGE_SIZE } from "@/lib/session-cursor"; import { isListedSidebarSession } from "@/lib/session-list-filter"; @@ -211,6 +213,29 @@ export function SessionListProvider({ children }: { children: ReactNode }) { }; }, []); + // BUG-1104: a tab that opened on a secondary page has no warm snapshot yet, + // so its first 「新建对话」 would cold-start Home. Once the list is settled and + // no Home is mounted, fill the snapshot in the background (read-only GETs). + // Home mounting, sign-out, an account switch or a list reload cancels it. + const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile)) + ? account.user.id + : null; + useEffect(() => { + if (!prefetchAccountId || window.location.pathname === "/") return; + const controller = new AbortController(); + const run = () => { + void prefetchHomeWarmSnapshot(prefetchAccountId, () => !controller.signal.aborted, undefined, controller.signal); + }; + // Safari has no requestIdleCallback; a short timeout keeps it off the first paint. + const idle = "requestIdleCallback" in window; + const handle = idle ? window.requestIdleCallback(run, { timeout: 2000 }) : window.setTimeout(run, 300); + return () => { + controller.abort(); + if (idle) window.cancelIdleCallback(handle); + else window.clearTimeout(handle); + }; + }, [prefetchAccountId]); + const reload = useCallback(() => reloadRef.current(), []); const registerShellControls = useCallback((next: ShellRegistration | null) => { diff --git a/frontend/tests/home-warm-prefetch.test.ts b/frontend/tests/home-warm-prefetch.test.ts new file mode 100644 index 00000000..7cf4be7c --- /dev/null +++ b/frontend/tests/home-warm-prefetch.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { prefetchHomeWarmSnapshot, resetHomeWarmPrefetchForTests, type HomeWarmPrefetchIo } from "../src/lib/home-warm-prefetch.ts"; +import { clearHomeWarmSnapshot, isCompleteHomeWarmSnapshot, readHomeWarmSnapshot, writeHomeWarmSnapshot } from "../src/lib/home-warm-snapshot.ts"; + +// BUG-1104. Synthetic ids only. +const USER = "11111111-1111-4111-8111-111111111111"; +const OTHER = "22222222-2222-4222-8222-222222222222"; +const catalog = { defaultModelId: "m", models: [{ id: "m", label: "M", description: "x", creditCost: 1, isDefault: true }] }; + +function io(overrides: Partial = {}) { + const calls: string[] = []; + const value: HomeWarmPrefetchIo = { + fetchModelCatalog: async () => { calls.push("models"); return catalog; }, + fetchRectificationEntrySummary: async () => { calls.push("summary"); return null; }, + ...overrides, + }; + return { value, calls }; +} + +test.beforeEach(() => { + clearHomeWarmSnapshot(); + resetHomeWarmPrefetchForTests(); +}); + +test("writes a complete snapshot through the shared writer; cursor stays with the list boot", async () => { + const { value } = io(); + assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, value), true); + const snapshot = readHomeWarmSnapshot(USER); + assert.ok(isCompleteHomeWarmSnapshot(snapshot)); + assert.equal(snapshot.sessionsCursor, null); + assert.equal(snapshot.listBoot, null, "the provider's own boot cursor wins on the warm start"); + assert.equal(snapshot.entrySummarySettled, true); +}); + +test("an existing complete snapshot is left alone and nothing is fetched", async () => { + writeHomeWarmSnapshot({ accountId: USER, modelCatalog: catalog, entrySummary: null, entrySummarySettled: true, sessionsCursor: "c", listBoot: null }); + const { value, calls } = io(); + assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, value), false); + assert.deepEqual(calls, []); + assert.equal(readHomeWarmSnapshot(USER)?.sessionsCursor, "c"); +}); + +test("a late answer after cancel (Home mounted, account switched, sign-out) writes nothing", async () => { + let current = true; + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { release = resolve; }); + const { value } = io({ fetchModelCatalog: async () => { await gate; return catalog; } }); + const pending = prefetchHomeWarmSnapshot(USER, () => current, value); + current = false; + release(); + assert.equal(await pending, false); + assert.equal(readHomeWarmSnapshot(USER), null); +}); + +test("a failed catalog leaves nothing; a failed summary settles like the cold start", async () => { + assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, io({ fetchModelCatalog: async () => { throw new Error("down"); } }).value), false); + assert.equal(readHomeWarmSnapshot(USER), null); + assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, io({ fetchRectificationEntrySummary: async () => { throw new Error("down"); } }).value), true); + assert.equal(readHomeWarmSnapshot(USER)?.entrySummary, null); +}); + +test("an empty catalog is not a usable snapshot", async () => { + const { value } = io({ fetchModelCatalog: async () => ({ defaultModelId: "", models: [] }) }); + assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, value), false); + assert.equal(readHomeWarmSnapshot(USER), null); +}); + +test("one flight per account; another account's snapshot is never painted", async () => { + const { value, calls } = io(); + const [a, b] = await Promise.all([prefetchHomeWarmSnapshot(USER, () => true, value), prefetchHomeWarmSnapshot(USER, () => true, value)]); + assert.equal(a, true); + assert.equal(b, true, "the second caller shares the first flight"); + assert.deepEqual(calls.filter((call) => call === "models"), ["models"]); + assert.equal(readHomeWarmSnapshot(OTHER), null); +}); diff --git a/frontend/tests/home-warm-return-lifecycle.test.tsx b/frontend/tests/home-warm-return-lifecycle.test.tsx index 2923cff6..b2823662 100644 --- a/frontend/tests/home-warm-return-lifecycle.test.tsx +++ b/frontend/tests/home-warm-return-lifecycle.test.tsx @@ -48,6 +48,8 @@ const emptySummary = { has_resumable_case: false, has_terminal_case_with_time: f type Mode = { /** Paths whose response never arrives (to prove nothing waits for them). */ hang: Set; + /** Paths answered with 500 (a prefetch that fails must leave nothing behind). */ + fail: Set; models: unknown; summary: unknown; consultStatus: unknown; @@ -57,7 +59,7 @@ type Mode = { function installApi() { const originalFetch = globalThis.fetch; const requests: string[] = []; - const mode: Mode = { hang: new Set(), models: catalogPro, summary: terminalSummary, consultStatus: null, accountUser: USER }; + const mode: Mode = { hang: new Set(), fail: new Set(), models: catalogPro, summary: terminalSummary, consultStatus: null, accountUser: USER }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); globalThis.fetch = (async (input: unknown, init?: RequestInit) => { const url = new URL(String(input), "http://localhost"); @@ -65,6 +67,7 @@ function installApi() { const path = url.pathname; requests.push(`${method} ${path}${url.search}`); if (mode.hang.has(path)) return new Promise(() => undefined); + if (mode.fail.has(path)) return json({ error: "unavailable" }, 500); if (path === "/api/account") { return json({ user: { id: mode.accountUser, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9, @@ -637,3 +640,58 @@ test("another account in the same tab never paints the previous account's snapsh api.restore(); } }); + +// BUG-1104. A tab that opens on a secondary page (refresh, shared link) has +// never cold-started Home, so before the prefetch its first 「新建对话」 played +// the whole loading ring — the 2–3 s the product saw. +for (const path of ["/chart", "/reports"] as const) { + test(`a tab that opens on ${path}: 新建对话 is warm once the background prefetch has run`, async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await openDocument(tab, path, api.requests); + const { readHomeWarmSnapshot, isCompleteHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts"); + await doc.settle(() => isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(USER)), "the background prefetch"); + const prefetch = api.requests.filter((line) => !line.startsWith("GET /api/account") && !line.startsWith("GET /api/sessions") && !line.startsWith("GET /api/chart-profiles")); + assert.deepEqual(prefetch.sort(), ["GET /api/models", "GET /api/rectification/cases/entry-summary"], `read-only prefetch only: ${api.requests.join(", ")}`); + assert.ok(api.requests.every((line) => line.startsWith("GET ")), "nothing but GETs before Home mounts"); + for (const waited of WAITED_PATHS) api.mode.hang.add(waited); + const before = api.requests.length; + await doc.followLink("新建对话"); + const first = doc.firstCommits[0]; + assert.ok(first, "home mounted"); + assert.equal(first.loading, false, "no loading ring"); + assert.equal(first.title, "新对话"); + assert.equal(first.requests, before, "ready before any request was sent"); + assert.equal(doc.view().rectificationCard, "再次校正", "the prefetched summary drives the card"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } + }); +} + +test("a failed prefetch leaves nothing behind and 新建对话 takes the unchanged cold path", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + api.mode.fail.add("/api/models"); + doc = await openDocument(tab, "/chart", api.requests); + await doc.settle(() => api.requests.includes("GET /api/models"), "the prefetch attempt"); + for (let i = 0; i < 5; i += 1) await doc.h.idle(); + const { readHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts"); + assert.equal(readHomeWarmSnapshot(USER), null, "no partial snapshot"); + api.mode.fail.delete("/api/models"); + await doc.followLink("新建对话"); + assert.equal(doc.firstCommits[0]?.loading, true, "without a snapshot Home cold-starts as before"); + await doc.settle(() => doc!.view().title === "新对话", "the cold reveal"); + assert.equal(doc.view().loading, false); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index c1196a7a..71b91aec 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -243,7 +243,11 @@ test("the same component renders read-only when `/` is not the one mounting it", assert.doesNotMatch(readonlyRow, /session-menu-trigger/); // Only the new-chat action carries intent. - assert.match(appSidebar, /]*href=\{newChatHref\(\)\}[^>]*onClick=\{closeDrawer\}/); + // 原值:新建对话链接的 `onClick={closeDrawer}` + // 新值:`onClick={handleNewChatLink}`,该函数仍只做 closeDrawer,外加一个本地 performance mark + // 原因:BUG-1104 给「新建对话」到首页可用的耗时打点;意图仍只在 href 的 `/?new=1` 上,下一行断言不变 + assert.match(appSidebar, /]*href=\{newChatHref\(\)\}[^>]*onClick=\{handleNewChatLink\}/); + assert.match(appSidebar, /function handleNewChatLink\(\) \{\s*markNewChatClick\(\);\s*closeDrawer\(\);\s*\}/); assert.doesNotMatch(appSidebar, /]*href="\/"/); // 原值: /