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}
) : (
-
+
{showExpandedContent ? 新建对话 : 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="\/"/);
// 原值: /