diff --git a/BLOCKED.md b/BLOCKED.md
index 00483f28..733489ae 100644
--- a/BLOCKED.md
+++ b/BLOCKED.md
@@ -1,5 +1,11 @@
# BLOCKED
+## TASK-home-warm-return:登录态真机、Node 22 全量与部署待验(2026-09-26)
+
+- 无受控登录账号:暖返回只在本地 `next start` + Chrome 151 无头上验过(CDP 拦截 `/api/*` 返回虚构账户与会话、所有接口人为延迟 1.5 s;脚本在 scratchpad,未提交)。iPhone Safari 真机与真实 staging 数据按 `docs/testing/home-warm-return-20260926.md` 待产品走。
+- 本机只有 Node v20.19.2:`mock.module` 类测试失败(基线 61 条);本轮全量 3953 条 / 61 失败,失败名单与基线逐条一致、无消失测试名。任务书要求的 Linux Node 22 全量待协调方复跑。无 Docker,`npm run test:db` 未跑(本单不动表)。
+- 未部署;BUG-1040 保持 investigating。
+
## TASK-new-chat-from-people:登录态真机、Node 22 与 DB 待验(2026-09-26)
- 无受控登录账号:修复只在本地 `next start` + Chrome 151 上验过(CDP 拦截 `/api/*` 返回虚构账户、会话与人物,未提交脚本)。iPhone Safari 真机、真实 staging 数据按 `docs/testing/secondary-new-chat-20260923.md` 追加节 A1–A6 待产品走。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e1e7c3d..9f9cb53b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
+## 2026-09-26 — 从其他页面回首页不再重放加载动画(待验收)
+
+- 同一次打开网页里,从星盘、星历、我的报告、星盘档案点「新建对话」、点侧栏里的历史对话、点左下角头像或按浏览器返回回到首页,不再出现「正在载入账户 / 正在准备对话」加载环,首页立刻可以输入。
+- 刷新、第一次打开、登录后跳转仍会放一次加载动画,和以前一样。
+- 模型列表、后台回答恢复、生时校正入口、今日星语在页面可用之后在后台更新,结果到了直接替换;模型下线仍提示「此前选择的模型已下线,已切换为默认模型。」。换账号、退出、登录失效时不会带出上一个账号的内容;今日星语过了零点或换了人会重新取。
+- Skill 版本不 bump。不改接口、数据库和样式;登录态真机与部署待验收。
+
## 2026-09-26 — 从星盘档案页回首页不再卡在上一次的生时校正(待验收)
- 从校正会话去过次级页、在星盘档案里换了人,再回首页时,不再出现「标题是旧校正、输入框写着正在打开生时校正却一直打不开、中间是新对话开场」的画面;落到当前这个人的新对话。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 78cef7f3..15b1c344 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -13913,3 +13913,18 @@
- 防复发:新增「组件用到的每个 `people-archive*` 类都必须在 `globals.css` 有规则」的合同测试,这类「写了类没写样式」的回归会在测试里失败。验收流程缺口:无登录态页面只做源码断言等于没看过页面;本轮起此类页面至少用本地宿主页在真实浏览器里截一次图,写进 PROGRESS。
- 相关记录:BUG-1030(本页由其引入)。
- 修复版本:`codex/people-ephemeris-ui-20260926`,未部署。
+
+## BUG-1040 | 从其他页面回首页每次都重放加载动画、要等好几秒
+
+- 状态:investigating(已修复待部署;登录态真机未复测)
+- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
+- 影响面:首页启动(`app/(app)/page.tsx` 的 `hydrated` / `bootstrapPhase` 初值与启动 effect、`lib/home-bootstrap-run.ts`)、新增 `lib/home-warm-snapshot.ts` / `lib/home-warm-start.ts` / `lib/client-navigation-target.ts`、`AppLink`、`DailyStarlanguageBinder`、`useSessionManagement.ensureSessionMessages`、`useProfileOnboarding`(初始资料、退出清快照)、`SessionListProvider`(401 / 换账户清快照)、`redirectToLogin`。
+- 用户现象:产品 09-26 真机:第一次进首页有加载动画是预期;从星盘 / 星历 / 我的报告 / 星盘档案点「新建对话」回首页,每次都要再看一遍加载动画、等很久。
+- 触发条件:同一次打开里,任何客户端导航回 `/`(侧栏新建对话、历史会话行、账户页脚、浏览器返回)。
+- 根因:首页的「一次等待一次揭幕」按组件挂载计,不是按这次打开网页计。客户端导航会卸载重挂 `Home`,`hydrated=false`、`bootstrapPhase="account"` 从头来:重新 `fetchModelCatalog` + `fetchActiveConsultationStatus`、prepare 阶段再拉入口摘要与今日星语、揭幕预算最多 4 秒。本地 Chrome 基线实测(所有 `/api/*` 人为延迟 1.5 s):`/chart` → 新建对话 5530 ms 才可交互且出现加载环。BUG-966 已给星盘 / 星历 / 报告做了模块级首屏缓存,首页没有同等机制。
+- 修复:第一次冷启动成功后,把模型目录、校正入口摘要、会话分页游标、今日星语(按人物 + 出生资料指纹 + 日期键)写进模块级暖快照(按账户隔离、只存内存);账户、资料、会话列表本来就在布局层 `SessionListProvider` 里跨页存活,不再复制。`Home` 重挂时 `useState(() => …)` 初始化函数读快照:快照齐全 + 列表已就绪 + 资料完整 + 落点可在内存里算出时,`hydrated` / `bootstrapPhase` 初值即就绪态,落点按冷启动同一套函数同步算出(`resolveBootstrapSessionSelection` / `resolveStarterHomeLandingSessionId`,`?new=1` 建本地空对话、`?c=` 保持、登录返回存根与其人物范围同 BUG-1038);启动 effect 改为提交落点后后台刷新模型目录(下线模型沿用原提示并 PATCH)、账户、后台回答恢复(与冷启动共用 `resolveReservedConsultation`),入口摘要与今日星语走各自既有 effect,结果静默替换。缺任何一项、落点需要 lookup、或客户端导航目标未知一律回冷启动。Next 先渲染新页面、后在 insertion effect 里写地址栏,挂载时 `location` 还是来源页,所以 `AppLink` / `navigateAppPath` 在点击时记下目标 href(`client-navigation-target.ts`),首页只信 10 秒内的一次记录。账户变化、退出、任一 401(`redirectToLogin`、provider 401)清空快照。`Home()` 的 useState 33 / useRef 37 不变,新增一个只写快照的 effect。
+- 验证:新增 `frontend/tests/home-warm-return-lifecycle.test.tsx`(13 条,真实 `Home` + `AppSidebar` + `SessionListProvider`,按 Next 的顺序先渲染、后在 insertion effect 写 URL):四个次级页 → 新建对话首帧无加载环、标题「新对话」、首帧提交前 0 个请求、所有接口挂起也不回加载态;报告页 → 历史会话;整页刷新仍放一次加载环;快照缺项回冷启动;模型下线提示 + PATCH;后台回答恢复(落到该会话 / 不抢新建对话);入口摘要静默替换;存根校正会话带 URL 打开;换账户清快照。关掉暖启动时其中 11 条失败;去掉目标记录时新建对话与历史会话两条失败。`frontend/tests/home-warm-snapshot.test.ts`(12 条纯函数 / 源码合同)。本地 `next start` + Chrome 151(CDP 拦截 `/api/*`、全部延迟 1.5 s):`/chart` → 新建对话 18–23 ms 可交互、0 次加载环、可交互前 0 个请求返回;`/reports` → 历史会话 18–19 ms;整页刷新 22 ms 出加载环。全量 3953 条 / 61 失败,失败名单与基线 3928 / 61 逐条一致。
+- 防复发:lifecycle 测试锁「客户端返回首帧即就绪、任何接口都不被等待」与「整页加载仍放一次」;测试 harness 按 Next 的「先渲染后写 URL」顺序导航,防止再出现只在测试里成立的 URL 读取。BUG-966 的缓存规则(模块级、组件卸载不丢)推广到首页。
+- 相关记录:BUG-966(次级页模块缓存先例)、BUG-479(一次等待一次揭幕)、BUG-1021(不半揭幕,快照缺项回冷启动)、BUG-1038(新建意图与存根规则,暖路径沿用)、BUG-936(首屏兜底,`data-hydrated` 不变)。
+- 复发自:无。
+- 修复版本:`codex/home-warm-return-20260926`,未部署。
diff --git a/docs/tasks/PROGRESS-home-warm-return-20260926.md b/docs/tasks/PROGRESS-home-warm-return-20260926.md
new file mode 100644
index 00000000..682b2398
--- /dev/null
+++ b/docs/tasks/PROGRESS-home-warm-return-20260926.md
@@ -0,0 +1,163 @@
+# PROGRESS · 从其他页面回首页不再放加载动画(2026-09-26)
+
+任务书:`docs/tasks/TASK-home-warm-return-20260926.md`。分支 `codex/home-warm-return-20260926`,worktree `.worktrees/home-warm-return-20260926`。执行方:Claude 子代理(产品授权直接执行)。未推送、未部署。BUG-1040(开工时最大 BUG-1039,1040 空)。
+
+## 结论
+
+| 项 | 修复前(`origin/staging` 6a22626d) | 修复后 |
+| --- | --- | --- |
+| 首页 → 星盘 → 新建对话,所有 `/api/*` 人为延迟 1.5 s(本地 Chrome) | 出加载环,**5530 ms** 才可交互;可交互前等完 5 个请求 | **无加载环,23 ms** 可交互(复跑 18 ms);可交互前 **0** 个请求返回 |
+| 首页 → 我的报告 → 点历史会话(同上) | 出加载环,**5532 ms** | **无加载环,19 ms**(复跑 18 ms) |
+| 整页刷新 `/` | 22 ms 出加载环,约 4.1 s 进入 | 22–24 ms 出加载环,约 4.1 s 进入(不变) |
+| 首次打开 `/` | 64 ms 出加载环,约 4.4 s | 32–63 ms 出加载环,约 4.1–4.4 s(不变) |
+
+冷启动约 4.1 s 是本地虚构数据下的 prepare 揭幕预算,基线与本分支一致,不属本单。
+
+## 基线
+
+- `origin/staging` = `6a22626d`(BUG-1038 `0a8350cc` 已合入,开工前 `git log` 核对)。实现期间 staging 前进到 `bd669aed`(只改 BUG_HISTORY 与任务索引两行文档),本分支已 rebase 到 `bd669aed`,`docs/tasks/README.md` 冲突一处:保留 staging 的 BUG-1038 行、本单行改「待验收」。
+- 全量基线(同一台机器、Node v20.19.2、Linux):协调方给的 `test9.log`,3928 条,61 失败(环境缺口:Node 20 没有 `mock.module`),27 跳过。
+- 首屏 gzip(`.next/build-manifest.json` `rootMainFiles`,gzip -9):130933 B。`/` 的 index.html 引用的全部 js/css:654283 B(同代码的 `new-chat-from-people` 工作树构建)。
+- `npm run lint`:0 error / 126 warning(同代码工作树实测)。
+- `page.tsx` 1329 行;`Home()` useState 33、useRef 37(合同上限 36 / 37,行数上限 1567)。
+
+## 设计
+
+### 快照里放什么(T1,D2)
+
+`frontend/src/lib/home-warm-snapshot.ts`:模块变量,一次一个账户,纯内存。
+
+| 字段 | 来源 | 说明 |
+| --- | --- | --- |
+| `accountId` | 冷启动成功后的账户 | 键;读到别的账户立即清空 |
+| `modelCatalog` | 冷启动 / 后台刷新 | 缺或为空 → 不算齐全 |
+| `entrySummary` + `entrySummarySettled` | 校正入口摘要 effect | 未 settled → 不算齐全(揭幕预算先到时,等摘要落定才写) |
+| `sessionsCursor` + `listBoot` | `useSessionManagement` 的分页游标,与写入时 provider 的 boot 对象身份 | provider 在外重载过(例如换人)则用 provider 自己的游标 |
+| 今日星语 `{accountId, subjectId, fingerprint, day, card}` | `DailyStarlanguageBinder` 拿到 ready 时 | 人物、出生资料指纹、日期三者全等才用;否则 `pending`(卡片静态句),binder 按新键重取 |
+
+**与 D2 的偏离(有意)**:`account`、`profile`、`startGreeting`、`onboardingStep` 不复制进快照。账户本来就在布局层 `SessionListProvider` 的状态里跨客户端导航存活(那就是这份内存快照),而且比快照新(provider 换人重载会重读 `/api/account`,其它页面也不会改它);资料按冷启动同样的方式 `readProfile(account.profile)` 派生,问候语与引导步骤再由资料派生。复制一份只会多一个过期来源。齐全判定照样要求账户存在、同账户、资料完整。
+
+写入:`Home()` 里一个只写快照的 effect,条件 `hydrated && !accountError && accountId && modelCatalog && entrySummarySettled`(不写半成品,BUG-1021)。只增 effect、不增 state / ref。
+
+失效:`SessionListProvider` 的 `/api/account` 401、`/api/sessions` 401、换账户分支;`redirectToLogin()`(所有 401 跳登录的出口);`signOut()`;`readHomeWarmSnapshot(别的账户)`。
+
+### 暖返回(T2,D3)
+
+`frontend/src/lib/home-warm-start.ts`,无 React hook:
+
+1. `Home()` 的 `useState(() => …)` 初始化函数(`activeSessionId`、`modelCatalog`、`dailyStarlanguage`、`rectification`、`hydrated`、`bootstrapPhase`、`onboardingStep`、`startGreeting`,以及 `useProfileOnboarding` 新增的 `initialProfile`)都调 `takeHomeWarmStart()`。同一次渲染(含 StrictMode 双调)共用一个结果,微任务后失效,下次挂载重新判断。
+2. 条件全部满足才是暖启动,否则返回 null,走**原封不动**的冷启动:provider 已 settled、未登出、有账户、boot 无错;同账户快照齐全;资料完整(引导中的用户走冷启动);非开发 `?preview=`;落点能在内存里算出;知道要打开的 `/` 的 query。
+3. 落点 `resolveWarmLanding()` 用冷启动同一组函数:`resolveBootstrapSessionSelection` → `resolveStarterHomeLandingSessionId` / `starterHomeLandingNeedsConsultation`。`?new=1` 建本地空咨询(替换旧的未保存空对话,BUG-989 不落库);`?c=` 在内存 → `keep`;存根在内存且属当前人物 → `replace-selected`,属别人 → `other-subject`(BUG-1038);需要 lookup(`?c=` 或存根不在内存)→ null → 冷启动(BUG-705 的查找只有冷路径做)。
+4. 首帧:`hydrated=true`、`bootstrapPhase="prepare"`、目录 / 资料 / 摘要 / 星语就位;新建的本地空咨询在进列表前由 `pendingWarmLandingSession()` 充当活动会话(`ensureSessionMessages` 同样认它,不去服务端拉)。
+5. 启动 effect:挂载时 `hydrated` 为真即暖启动,调 `runHomeWarmRefresh()`(`home-bootstrap-run.ts`):同步提交落点(列表、游标、写 / 清 URL、清存根、缺失提示;存根校正会话在写回 `?c=` 后补一次自动打开,因为自动打开 effect 已在 URL 写回之前跑过),然后后台并行:`/api/models`(变了才替换,下线模型沿用原提示并 PATCH,未保存的本地空对话不 PATCH)、`refreshAccount()`、后台回答恢复(与冷启动共用抽出的 `resolveReservedConsultation`;本标签页的 pending 记录在挂载时读取,因为首页自己的 pending effect 第一轮就会清掉它;新建落点不被恢复抢走)。入口摘要与今日星语走各自原有 effect(`bootstrapPhase !== "account"` 即触发)。全部不 await 在可交互之前,失败保持快照值,不回加载态。
+
+### 客户端导航读不到目标 URL(浏览器实测才发现)
+
+第一版在真实浏览器里「新建对话」落到了旧会话、地址停在 `/?new=1`。原因:Next 16 的 app router 先渲染新页面,**之后**才在 `HistoryUpdater` 的 `useInsertionEffect` 里 `pushState`(`node_modules/next/dist/client/components/app-router.js`)。所以 `Home` 挂载渲染时 `window.location` 还是 `/chart`,初始化函数读不到 `?new=1`。冷启动没这个问题,因为它在 await 之后才读 URL。
+
+修法:新增 `frontend/src/lib/client-navigation-target.ts`(无依赖,随共享侧栏加载)。`AppLink` 在普通左键点击、未被拦截时记下目标 href,`navigateAppPath` 在客户端跳转前同样记下。首页挂载时:地址栏已是 `/`(刷新、浏览器前进后退)就读地址栏;否则读 10 秒内的那一次记录;都没有 → 冷启动(安全回落,不猜)。侧栏的新建对话、会话行、账户页脚都是 `AppLink`。
+
+lifecycle 测试 harness 也改成 Next 的顺序(先渲染,URL 在 insertion effect 里写),原先「先 pushState 再渲染」的写法测不出这个问题。去掉记录时新测试里新建对话与历史会话两条失败。
+
+## 改了什么
+
+| 文件 | 内容 |
+| --- | --- |
+| `frontend/src/lib/home-warm-snapshot.ts`(新) | 快照存取、按账户清空、齐全判定、今日星语按人物 + 指纹 + 日期存取 |
+| `frontend/src/lib/home-warm-start.ts`(新) | 暖启动判断、落点、挂载 query、模型对账、StrictMode 幂等提交 |
+| `frontend/src/lib/client-navigation-target.ts`(新) | 客户端导航目标记录 |
+| `frontend/src/app/(app)/page.tsx` | 8 个 useState 改初始化函数;`activeSession` 多一个首帧回退;启动 effect 分暖 / 冷;一个写快照 effect。1329 → 1373 行;useState 33 / useRef 37 不变 |
+| `frontend/src/lib/home-bootstrap-run.ts` | 抽出 `resolveReservedConsultation`(冷启动行为不变);新增 `runHomeWarmRefresh` |
+| `frontend/src/components/daily-starlanguage-binder.tsx` | 先读内存卡再读 localStorage;ready 时记进内存;原有 guard 与依赖数组不动 |
+| `frontend/src/hooks/use-profile-onboarding.ts` | `initialProfile` 参数;`signOut` 先清快照 |
+| `frontend/src/hooks/use-session-management.ts` | `ensureSessionMessages` 认首帧的活动会话 |
+| `frontend/src/lib/session-list-context.tsx`、`home-cloud-sync.ts` | 401 / 换账户 / `redirectToLogin` 清快照 |
+| `frontend/src/components/app-link.tsx`、`frontend/src/lib/app-navigation.ts` | 记录导航目标 |
+| `frontend/tests/home-warm-return-lifecycle.test.tsx`(新,13 条) | 见下 |
+| `frontend/tests/home-warm-snapshot.test.ts`(新,12 条) | 见下 |
+| `frontend/tests/new-chat-from-people-lifecycle.test.tsx` | 只在 `openDocument` 里加两行:文档加载时清快照(模块内存本来就随文档重置)。无断言改动 |
+| 文档 | `docs/BUG_HISTORY.md`(BUG-1040)、`frontend/DESIGN.md` §9 与 Secondary page shell、`CHANGELOG.md`、`BLOCKED.md`、`docs/tasks/README.md`、`docs/testing/home-warm-return-20260926.md` 与两张截图 |
+
+## 测试
+
+### 新增
+
+`home-warm-return-lifecycle.test.tsx`(真实 `Home` + `AppSidebar` + `SessionListProvider`,与 `(app)/layout.tsx` 同构;用兄弟组件的 layout effect 记录每次挂载的**首个提交帧**):
+
+| # | 用例 | 关掉暖启动时 |
+| --- | --- | --- |
+| 1–4 | `/chart` `/ephemeris` `/reports` `/people` → 新建对话:导航前把所有接口设为永不返回;首帧无加载环、标题「新对话」、首帧提交时请求数 = 导航前、starter 首页、地址 `/`、存根清掉、校正卡用快照摘要;之后 `/api/models` 与 `/api/consult/status` 在后台发出,挂起也不回加载态 | 失败 |
+| 5 | `/reports` → 历史会话:首帧即该会话、`?c=` 保留、消息出现 | 失败 |
+| 6 | 暖返回后整页刷新:首帧是加载环 | 失败(前半段暖返回断言) |
+| 7 | 快照被清空再回首页:首帧加载环,目录挂起时一直等(BUG-1021) | 通过(本来就冷) |
+| 8 | 后台目录换成别的模型:首帧不等;随后模型切到新默认、出现原提示「此前选择的模型已下线,已切换为默认模型。」、PATCH 该会话 | 失败 |
+| 9 | 离开时回答还在生成(pending 记录 + reserved)→ 点该会话回来:出现停止按钮、pending 记录保留 | 失败 |
+| 10 | 同上但点新建对话:停在新对话,旧回答继续恢复 | 失败 |
+| 11 | 摘要变了:校正卡从「再次校正」静默换成「开始新的生时校正」 | 失败 |
+| 12 | 存根校正会话经账户页脚回裸 `/`:写回 `?c=`、校正界面打开(不是 BUG-1038 的锁死页) | 失败 |
+| 13 | provider 读到另一个账户:快照清空 | 通过 |
+
+`home-warm-snapshot.test.ts`(12 条):写入 / 读取;换账户清空(含今日星语);`redirectToLogin` 清空;退出与 provider 三个分支清空(源码合同);快照源码不含任何持久存储;不齐全判定;今日星语跨日 / 换人 / 换账户不命中、无出生分钟无键;齐全时暖启动(新建本地空对话、默认模型、游标取舍);每种缺项都拒绝暖启动;落点与冷规则一致(keep / 存根写回 / 他人存根丢弃 / 裸 `/` 校正头落咨询 / 新建清存根 / 无会话的人只建一个 / 存根不在内存要 lookup);落点替换旧空对话、模型对账不改身份;挂载 query 的三种来源与记录只取一次、过期不信、只收同源路径。
+
+### 全量
+
+| 项 | 基线 | 本分支 |
+| --- | --- | --- |
+| `tsc --noEmit` | 0 | 0 |
+| `npm run lint` | 0 error / 126 warning | 0 error / 126 warning |
+| `npm test`(Node 20,Linux) | 3928 / pass 3840 / fail 61 / skip 27 | 3953 / pass 3865 / fail 61 / skip 27 |
+| 失败名单 diff | — | 逐条一致(61 = 61,0 新增) |
+| 测试名 diff | — | 0 消失,+25(13 + 12) |
+| Python 源码合同(`tests/test_birth_time_journey_contract.py` 等 5 个文件) | 25 pass / 2 fail | 25 pass / 2 fail(同两条:`test_jyotish_web_auth.py` 仍查 Supabase 登录源码,基线即失败) |
+
+过程中一次全量多出 1 条失败:`chat-navigation-a11y-contract.test.ts`「auth redirects stay hard document loads…」要求 `await selfHostedOtpActions.signOut();` 与 `window.location.assign("/login")` 紧邻。改为把清快照挪到 `signOut()` 调用之前,断言未动。
+
+### 改动的既有断言(三栏)
+
+无。唯一触及的既有测试文件 `new-chat-from-people-lifecycle.test.tsx` 只在文档加载的 setup 里加了清快照两行,不改任何断言。
+
+## 构建
+
+`npm run build -- --webpack`(Linux):
+
+| 项 | 基线 | 本分支 |
+| --- | --- | --- |
+| `/` | ○ Static | ○ Static |
+| `/chart` `/ephemeris` `/people` | ○ | ○ |
+| `/reports` | ƒ | ƒ |
+| 路由表整体 | — | 与基线构建逐行一致 |
+| `rootMainFiles` gzip -9 | 130933 B | 130933 B(0.00%) |
+| `/` index.html 引用的全部 js/css gzip -9 | 654283 B | 656646 B(+2363 B,+0.36%) |
+
+## 浏览器与网络
+
+本地 `next start`(端口 3431,本分支构建;基线用同代码工作树的构建,端口 3432),Chrome 151 `--headless=new`,CDP `Fetch` 拦截 `/api/*` 返回虚构账户(林遥)、一条生时校正、一条普通咨询。页面里用 `Page.addScriptToEvaluateOnNewDocument` 装 MutationObserver 记录「加载环出现」和「首页可交互(标题存在 + 输入框可用 + 路径 `/`)」的时间戳。返回阶段每个 `/api/*` 响应都人为延迟 1500 ms:如果首页等任何一个请求,可交互时间不可能早于 1500 ms。脚本在 scratchpad,未提交。
+
+`/chart` → 新建对话(本分支):
+
+```
+interactiveMs 23, title 新对话, loadingRingShown 0, url /
+POST /api/daily-starlanguage @+15ms → answered +1519ms
+GET /api/rectification/cases/entry-summary @+16ms → +1519ms
+GET /api/chart-profiles @+16ms → +1519ms
+GET /api/synastry-reports @+16ms → +1520ms
+GET /api/models @+21ms → +1523ms
+GET /api/consult/status @+22ms → +1523ms
+GET /api/account @+22ms → +1524ms
+requestsAnsweredBeforeInteractive: []
+```
+
+同一路径(基线):`interactiveMs 5530, loadingRingShown 1`,可交互前已返回 `chart-profiles` `synastry-reports` `models` `consult/status` `entry-summary`;`daily-starlanguage` 在 +5526 ms 才发出。
+
+`/reports` → 历史会话:本分支 `interactiveMs 19, loadingRingShown 0, url /?c=…`,可交互前 0 个请求返回;基线 `5532 ms, loadingRingShown 1`。
+
+整页刷新:两边都 22–24 ms 出加载环。控制台 error:0。
+
+截图:`docs/testing/home-warm-return-20260926-chart-new-chat.png`(新建对话暖返回,今日星语卡与校正入口已就位)、`docs/testing/home-warm-return-20260926-reports-history.png`(历史会话暖返回)。
+
+## 环境缺口
+
+- Node 22:本机只有 v20.19.2,任务书要求的 Node 22 全量待协调方复跑(见 `BLOCKED.md`)。
+- 无受控登录账号:登录态真机、iPhone Safari、真实 staging 数据按 `docs/testing/home-warm-return-20260926.md` 由产品走。
+- 无 Docker:`npm run test:db` 未跑(不动表)。
+- 未部署。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 35503bbe..2860aa3e 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -120,7 +120,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-starter-home-polish-20260926.md` | — | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后 | 待领取 | — |
-| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 待领取 | — |
+| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 待验收 | `PROGRESS-home-warm-return-20260926.md`(分支 `codex/home-warm-return-20260926`,未推送) |
| `TASK-new-chat-from-people-20260926.md` | `PROGRESS-new-chat-from-people-20260926.md` | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 已验收(Claude 09-26 直接执行:子代理复现并定位 sessionStorage 登录返回存根;Claude 独立复验 tsc/lint 0、全量 3928 条失败名单与基线逐条一致、四路由 ○、gzip 不变、修复前后截图核对) | `0a8350cc`(已部署 `6a22626d`,health 一致) |
| `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 已验收(Claude 09-25:两 P0、两 P1、七 P2 通过;新 P2 删后跳错人、人物接口故障连累聊天 → 收尾单) | `4d801e53`(已部署,health 核对一致) |
| `TASK-home-page-split-20260925.md` | `PROGRESS-home-page-split-20260925.md` | **首页 page.tsx 拆分(只搬不改)**:启动流程约 285 行抽成 lib 纯函数 `runHomeBootstrap`、恢复轮询约 100 行抽出、错误 / 引导屏抽组件,目标 ≤1300 行并下调增长合同基线;两个按源码切片的测试改为直接调用函数;还原 824647f2 的 import 挤行。排在 chart-ephemeris 之后、people-archive P1 之前 | 已验收(行为逐行一致;1417 行达让步线、合同基线已下调;流程违规:未跑全量致门禁红 3 次) | `da35b3e4`(已部署) |
diff --git a/docs/testing/home-warm-return-20260926-chart-new-chat.png b/docs/testing/home-warm-return-20260926-chart-new-chat.png
new file mode 100644
index 00000000..eaa313b4
Binary files /dev/null and b/docs/testing/home-warm-return-20260926-chart-new-chat.png differ
diff --git a/docs/testing/home-warm-return-20260926-reports-history.png b/docs/testing/home-warm-return-20260926-reports-history.png
new file mode 100644
index 00000000..338bfdd0
Binary files /dev/null and b/docs/testing/home-warm-return-20260926-reports-history.png differ
diff --git a/docs/testing/home-warm-return-20260926.md b/docs/testing/home-warm-return-20260926.md
new file mode 100644
index 00000000..fa860718
--- /dev/null
+++ b/docs/testing/home-warm-return-20260926.md
@@ -0,0 +1,31 @@
+# 从其他页面回首页不再放加载动画 · 真人验收(2026-09-26)
+
+关联:`docs/tasks/TASK-home-warm-return-20260926.md`、`docs/tasks/PROGRESS-home-warm-return-20260926.md`、BUG-1040。
+
+## 前置与记录边界
+
+- 本清单尚未执行。本机只在本地 `next start` + Chrome 151 无头、用虚构账户(林遥)和虚构数据走过前三项,截图见 `home-warm-return-20260926-chart-new-chat.png`、`home-warm-return-20260926-reports-history.png`;不以此代替登录态真机。
+- 在本轮已部署的 staging 用受控测试账号;先核对 `/api/health` 的 `deployment.gitCommit`。不借用他人登录态。
+- 测试账号需要:资料完整(有出生时间)、至少一条普通咨询、一条生时校正会话;星盘档案里至少一个他人。
+- 打开浏览器开发者工具的网络面板(iPhone 可用 Mac 上的 Safari「开发」菜单连手机)。只记方法、路径、先后与耗时,不保存 Cookie、JWT、请求正文、会话 ID 或出生资料。
+- 每项记录:通过 / 未通过、设备与浏览器、日期、脱敏现象(截图可以,遮住姓名)。
+
+## 必验
+
+| 步骤 | 操作 | 预期 |
+| --- | --- | --- |
+| 1 | 打开首页,等加载动画结束。点侧栏「星盘」,等星盘页出来,再点侧栏「新建对话」 | **不出现**「正在载入账户 / 正在准备对话」加载环;首页立刻可输入,标题「新对话」,中间是开场问候与「今日星语」「生时校正」两个入口;地址为 `/`(没有 `new=1`)。网络面板:首页可输入之前没有任何请求在等;之后出现的 `/api/models`、`/api/consult/status`、`/api/account`、`/api/rectification/cases/entry-summary`、`/api/daily-starlanguage` 都在页面已可用之后,结果到了页面不闪、不回加载态 |
+| 2 | 分别从「星历」「我的报告」「星盘档案」重复步骤 1(桌面一次、iPhone 抽屉一次) | 与步骤 1 一致;手机抽屉点完关闭 |
+| 3 | 首页 → 「我的报告」→ 点侧栏里一条历史普通咨询 | 不出现加载环;直接打开那条对话,地址 `/?c=…`;消息已看过的立刻显示,没看过的消息区先留白再出现,不转圈 |
+| 4 | 在首页按浏览器刷新(或关掉标签页重新打开) | 仍然**放一次**加载环,然后正常进入;这是预期 |
+| 5 | 首页打开一条生时校正会话 → 点「星盘」→ 点左下角自己的头像回首页 | 不出现加载环;回到那条生时校正,校正界面正常打开,输入框不停在「正在打开生时校正…」 |
+| 6 | 登出,换另一个受控账号登录,进首页 → 「星盘」→「新建对话」 | 登录后第一次有加载环;之后回首页不放;页面上的名字、余额、今日星语、校正入口都是新账号的,**没有**上一个账号的任何内容 |
+| 7 | 晚上 23:5x 打开首页看一眼今日星语,停在「星盘」页过零点后点「新建对话」 | 回首页不放加载环;今日星语卡先显示「今天的星语还没写出来。」或新一天的内容,随后换成新一天的内容,不显示前一天那句 |
+| 8 | 在星盘档案顶栏把当前人物换成他人,再点「新建对话」 | 不放加载环(若本次放了一次,记下来,不算未通过——换人会重载列表,允许回冷启动);新对话标题旁是那个人的名字;今日星语按那个人重取 |
+| 9 | 首页发一个问题,回答还在生成时点「星盘」,再点侧栏里这条对话回来 | 不放加载环;回答继续恢复(出现停止按钮或「回答仍在后台生成,正在自动恢复。」),最终回答完整出现 |
+| 10 | 同步骤 9,但回来时点「新建对话」 | 停在新对话;旧回答在后台继续,回到那条对话能看到完整回答 |
+
+## 边界
+
+- 暖返回只在「这次打开网页已经完整进过一次首页」时成立。直接从 `/chart` 打开网页、再点「新建对话」,首页会放一次加载环,这是预期(这次打开的第一次揭幕)。
+- 真实模型回复(步骤 9、10 的回答内容)需要模型凭据;只看恢复链路即可,回复质量不在本单。
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index d561c56b..594d3404 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -436,7 +436,8 @@ one skeleton, none of them carrying the sidebar.
tall content does not change the shell height. No spinner or 「正在加载」;
skeletons are limited to the chart-page plot positions (§15, 2026-09-24). First-screen data for the three pages lives in a module-level
cache; a second visit paints the last result immediately and refreshes in
- the background. Sidebar `pointerenter` / `pointerdown` prefetches that data
+ the background. Home follows the same rule for a client return (§9, warm
+ snapshot, BUG-1040): the loading ring plays once per document load. Sidebar `pointerenter` / `pointerdown` prefetches that data
(`` only prefetches JS). A tab that outlived a deploy compares
`NEXT_PUBLIC_GIT_COMMIT` with `/api/health` `.deployment.gitCommit` on
visibility and before navigation; a mismatch uses `window.location.assign`,
@@ -700,6 +701,8 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
+**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户页脚或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、登录返回存根与其人物范围同 BUG-1038),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。
+
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 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 e3e7f137..21be2d9e 100644
--- a/frontend/src/app/(app)/page.tsx
+++ b/frontend/src/app/(app)/page.tsx
@@ -161,7 +161,15 @@ import {
payloadMessage,
waitForUndoWindow,
} from "@/lib/home-cloud-sync";
-import { runHomeBootstrap } from "@/lib/home-bootstrap-run";
+import { runHomeBootstrap, runHomeWarmRefresh } from "@/lib/home-bootstrap-run";
+import { writeHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
+import {
+ homeWarmStartInput,
+ mountedHomeWarmStart,
+ pendingWarmLandingSession,
+ takeHomeWarmStart,
+ warmRectificationEntry,
+} from "@/lib/home-warm-start";
import { runConsultationRecoveryPoll } from "@/lib/consultation-recovery-poll";
import {
bootstrapLoadingCopy,
@@ -192,15 +200,18 @@ export default function Home() {
reload: reloadSessionList,
registerShellControls,
} = sessionList;
+ // Warm return (BUG-1040): read only by the useState initializers below, so
+ // it decides once per mount. Null means the unchanged cold bootstrap.
+ const warmStart = () => takeHomeWarmStart(() => homeWarmStartInput(sessionList));
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState(null);
const [chartLibrary, setChartLibrary] = useState([]);
const [billingPane, setBillingPane] = useState({});
- const [dailyStarlanguage, setDailyStarlanguage] = useState({ kind: "pending" });
+ const [dailyStarlanguage, setDailyStarlanguage] = useState(() => warmStart()?.daily ?? { kind: "pending" });
const [accountError, setAccountError] = useState("");
const [signingOut, setSigningOut] = useState(false);
- const [modelCatalog, setModelCatalog] = useState(null);
- const [activeSessionId, setActiveSessionId] = useState("");
+ const [modelCatalog, setModelCatalog] = useState(() => warmStart()?.modelCatalog ?? null);
+ const [activeSessionId, setActiveSessionId] = useState(() => warmStart()?.landing.activeSessionId ?? "");
const draftTheme = useRef(null);
const draftEntrypoint = useRef(null);
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
@@ -215,7 +226,7 @@ export default function Home() {
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState(
createBirthTimeConsultationConsentState,
);
- const [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL);
+ const [rectification, setRectification] = useState(() => ({ ...EMPTY_RECTIFICATION_SHELL, ...warmRectificationEntry(warmStart()) }));
const {
sessionId: rectificationSessionId,
caseId: rectificationCaseId,
@@ -233,18 +244,18 @@ export default function Home() {
setRectificationEntrySummary,
setRectificationEntrySummarySettled,
} = useMemo(() => createRectificationShellSetters(setRectification), [setRectification]);
- const [hydrated, setHydrated] = useState(false);
- const [bootstrapPhase, setBootstrapPhase] = useState("account");
+ const [hydrated, setHydrated] = useState(() => warmStart() !== null);
+ const [bootstrapPhase, setBootstrapPhase] = useState(() => (warmStart() ? "prepare" : "account"));
const prepareStartedAt = useRef(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
- const [onboardingStep, setOnboardingStep] = useState("name");
+ const [onboardingStep, setOnboardingStep] = useState(() => warmStart()?.onboardingStep ?? "name");
const [onboardingJustCompleted, setOnboardingJustCompleted] = useState(false);
const [onboardingPaywallOpen, setOnboardingPaywallOpen] = useState(false);
const [birthTimeJourney, setBirthTimeJourney] = useState(null);
const [birthTimeError, setBirthTimeError] = useState("");
const [birthTimeAssessmentPhase, setBirthTimeAssessmentPhase] = useState(null);
- const [startGreeting, setStartGreeting] = useState("");
+ const [startGreeting, setStartGreeting] = useState(() => warmStart()?.startGreeting ?? "");
const [starterGreetingSelection, setStarterGreetingSelection] = useState(() => Math.random());
const [presetMessageLength, setPresetMessageLength] = useState(0);
const conversation = useRef(null);
@@ -317,6 +328,7 @@ export default function Home() {
setOnboardingJustCompleted, setOnboardingStep, setPresetMessageLength,
setRectificationError,
setSigningOut, setStartGreeting, signingOut, uiPreview,
+ initialProfile: () => warmStart()?.profile ?? null,
});
const birthTimeGuided = useBirthTimeGuidedJourney({
@@ -328,6 +340,7 @@ export default function Home() {
});
const activeSession = sessions.find((session) => session.id === activeSessionId)
+ ?? pendingWarmLandingSession(activeSessionId)
?? sessions.find((session) => session.sessionType !== "birth_time_rectification" && sessionMatchesSubject(session, readCurrentSubjectId()));
const activeRectificationSession = composerLocksAsRectification(activeSession, sessions);
const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId);
@@ -503,6 +516,20 @@ export default function Home() {
})();
}, [accountId, bootstrapPhase, setRectificationEntrySummary, setRectificationEntrySummarySettled]);
+ // Keep the warm snapshot current once a bootstrap has fully succeeded; an
+ // incomplete one (no catalog, summary not settled, error) is never written.
+ useEffect(() => {
+ if (!hydrated || uiPreview.current || accountError || !accountId || !modelCatalog || !rectificationEntrySummarySettled) return;
+ writeHomeWarmSnapshot({
+ accountId,
+ modelCatalog,
+ entrySummary: rectificationEntrySummary,
+ entrySummarySettled: true,
+ sessionsCursor,
+ listBoot: sessionListBoot(),
+ });
+ }, [accountError, accountId, hydrated, modelCatalog, rectificationEntrySummary, rectificationEntrySummarySettled, sessionListBoot, sessionsCursor]);
+
const {
status: chatProfileStatus,
markReady: markChatProfilesReady,
@@ -743,6 +770,23 @@ export default function Home() {
useEffect(() => {
const controller = new AbortController();
+ // `hydrated` is true on mount only for a warm start: paint first, then
+ // commit the landing and refresh in the background (BUG-1040).
+ const mountedWarmStart = hydrated ? mountedHomeWarmStart() : null;
+ if (mountedWarmStart) {
+ void runHomeWarmRefresh({
+ start: mountedWarmStart,
+ storage: sessionStorage,
+ consultationStatusMissingCount,
+ currentSessions: () => sessionsRef.current,
+ restoreConsultationRecovery,
+ resumeRectification: (session) => resumeRectificationSession.current(session),
+ refreshAccount,
+ setComposerNotice,
+ commit: { setActiveSessionId, setModelCatalog, setSessions, setSessionsCursor },
+ }, controller.signal);
+ return () => controller.abort();
+ }
void runHomeBootstrap({
sessionListReady,
waitForSessionList,
diff --git a/frontend/src/components/app-link.tsx b/frontend/src/components/app-link.tsx
index 3ae89613..1fdf39e0 100644
--- a/frontend/src/components/app-link.tsx
+++ b/frontend/src/components/app-link.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { forwardRef, type ComponentProps } from "react";
import { hrefFromLinkTarget, interceptAppNavigation } from "@/lib/app-navigation";
+import { noteClientNavigationTarget } from "@/lib/client-navigation-target";
export type AppLinkProps = ComponentProps;
@@ -25,7 +26,13 @@ export const AppLink = forwardRef(function AppL
href={href}
onClick={(event) => {
onClick?.(event);
- interceptAppNavigation(event, hrefFromLinkTarget(href));
+ const target = hrefFromLinkTarget(href);
+ interceptAppNavigation(event, target);
+ // A plain click the client router will take: remember where it goes,
+ // for a page that reads its URL before the router writes it (BUG-1040).
+ if (!event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
+ noteClientNavigationTarget(target);
+ }
}}
{...props}
/>
diff --git a/frontend/src/components/daily-starlanguage-binder.tsx b/frontend/src/components/daily-starlanguage-binder.tsx
index 868103e4..17cf9e90 100644
--- a/frontend/src/components/daily-starlanguage-binder.tsx
+++ b/frontend/src/components/daily-starlanguage-binder.tsx
@@ -7,6 +7,7 @@ import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation
import { missingOtherProfileStep } from "@/lib/home-profile";
import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage";
import type { BootstrapPhase } from "@/lib/home-bootstrap";
+import { readHomeWarmDaily, rememberHomeWarmDaily } from "@/lib/home-warm-snapshot";
import {
fetchDailyStarlanguage,
readStoredDailyStarlanguage,
@@ -45,9 +46,10 @@ export function DailyStarlanguageBinder(props: {
const stored = readStoredDailyStarlanguage(accountId);
const controller = new AbortController();
let retryTimer: ReturnType | undefined;
- const cachedCard = stored && stored.day === today && stored.fingerprint === fingerprint
- ? stored.card
- : null;
+ // The in-memory card (BUG-1040) first: a warm return to `/` must not blink
+ // back to the static sentence when localStorage is unavailable.
+ const cachedCard = readHomeWarmDaily({ accountId, subjectId, fingerprint, day: today })
+ ?? (stored && stored.day === today && stored.fingerprint === fingerprint ? stored.card : null);
queueMicrotask(() => {
if (controller.signal.aborted) return;
if (cachedCard) setDailyStarlanguage({ kind: "ready", card: cachedCard });
@@ -62,16 +64,19 @@ export function DailyStarlanguageBinder(props: {
return;
}
if (next.kind === "ready") {
+ rememberHomeWarmDaily({ accountId, subjectId, fingerprint, day: today, card: next.card });
writeStoredDailyStarlanguage(accountId, { day: today, fingerprint, card: next.card });
setDailyStarlanguage(next);
return;
}
if (stored && stored.day === today && stored.fingerprint === fingerprint) return;
+ if (cachedCard) return;
setDailyStarlanguage(next);
})
.catch(() => {
if (controller.signal.aborted) return;
if (stored && stored.day === today && stored.fingerprint === fingerprint) return;
+ if (cachedCard) return;
setDailyStarlanguage({ kind: "unavailable" });
});
};
diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts
index 947919d4..69523334 100644
--- a/frontend/src/hooks/use-profile-onboarding.ts
+++ b/frontend/src/hooks/use-profile-onboarding.ts
@@ -37,6 +37,7 @@ import {
} from "@/lib/home-profile";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import { createStartGreeting } from "@/lib/starter-greeting";
+import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
type Account,
type AccountDialog,
@@ -79,6 +80,8 @@ export type ProfileOnboardingParams = {
setStartGreeting: Dispatch>;
signingOut: boolean;
uiPreview: MutableRefObject;
+ /** Warm return to `/` (BUG-1040): the profile Home starts with instead of empty. */
+ initialProfile?: () => Profile | null;
};
export function useProfileOnboarding(params: ProfileOnboardingParams) {
@@ -106,12 +109,13 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setStartGreeting,
signingOut,
uiPreview,
+ initialProfile,
} = params;
// Owned here since 2026-09-16 (state lowering batch 2). Home reads them back
// from this hook's return instead of declaring them itself.
- const [profile, setProfile] = useState(emptyProfile);
- const [profileDraft, setProfileDraft] = useState(emptyProfile);
+ const [profile, setProfile] = useState(() => initialProfile?.() ?? emptyProfile);
+ const [profileDraft, setProfileDraft] = useState(() => initialProfile?.() ?? emptyProfile);
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
@@ -396,6 +400,9 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setSigningOut(true);
setAccountError("");
try {
+ // Signing out ends this account's warm return to `/` (BUG-1040); a
+ // failed sign-out only costs one loading ring on the next return.
+ clearHomeWarmSnapshot();
await selfHostedOtpActions.signOut();
window.location.assign("/login");
} catch (caught) {
diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts
index ed85c441..db1223c8 100644
--- a/frontend/src/hooks/use-session-management.ts
+++ b/frontend/src/hooks/use-session-management.ts
@@ -234,7 +234,10 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!sessionId || uiPreview.current) return;
if (pendingSessionId === sessionId) return;
if (sessionDetailInFlight.current.has(sessionId)) return;
- const known = sessionsRef.current.find((session) => session.id === sessionId);
+ // A warm return's landing consultation (BUG-1040) is active one frame
+ // before it is in the list; it is local and empty, never a server row.
+ const known = sessionsRef.current.find((session) => session.id === sessionId)
+ ?? (activeSession?.id === sessionId ? activeSession : undefined);
if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return;
sessionDetailInFlight.current.add(sessionId);
setSessionDetailLoadingId(sessionId);
diff --git a/frontend/src/lib/app-navigation.ts b/frontend/src/lib/app-navigation.ts
index 5d9fdb4e..e50bd997 100644
--- a/frontend/src/lib/app-navigation.ts
+++ b/frontend/src/lib/app-navigation.ts
@@ -1,3 +1,5 @@
+import { noteClientNavigationTarget } from "./client-navigation-target.ts";
+
export type AppNavigationHost = {
assign: (href: string) => void;
};
@@ -84,6 +86,7 @@ export function navigateAppPath(
return;
}
if (clientNavigate) {
+ noteClientNavigationTarget(href);
clientNavigate(href);
return;
}
diff --git a/frontend/src/lib/client-navigation-target.ts b/frontend/src/lib/client-navigation-target.ts
new file mode 100644
index 00000000..d0e02da8
--- /dev/null
+++ b/frontend/src/lib/client-navigation-target.ts
@@ -0,0 +1,28 @@
+/**
+ * Where the client navigation in flight is going (BUG-1040).
+ *
+ * Next's app router renders the next page first and writes the address bar
+ * afterwards (HistoryUpdater, an insertion effect). A page that decides its
+ * first frame from the URL — Home's warm return reads `?new=1` / `?c=` — would
+ * otherwise read the page it is leaving. `AppLink` and `navigateAppPath` note
+ * the href here just before the router starts; the page takes it once.
+ *
+ * Memory only, no dependencies (it ships with the shared sidebar).
+ */
+
+const MAX_AGE_MS = 10_000;
+
+let target: { href: string; at: number } | null = null;
+
+export function noteClientNavigationTarget(href: string): void {
+ if (!href.startsWith("/") || href.startsWith("//")) return;
+ target = { href, at: Date.now() };
+}
+
+/** The noted href (then forgotten), or null when none is recent enough to trust. */
+export function takeClientNavigationTarget(now = Date.now()): string | null {
+ const noted = target;
+ target = null;
+ if (!noted || now - noted.at > MAX_AGE_MS) return null;
+ return noted.href;
+}
diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts
index 9a802302..e6c8c7c1 100644
--- a/frontend/src/lib/home-bootstrap-run.ts
+++ b/frontend/src/lib/home-bootstrap-run.ts
@@ -63,6 +63,13 @@ import { dropRectificationStoredPending } from "@/lib/rectification-session-comp
import type { SessionListBoot } from "@/lib/session-list-context";
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
+import {
+ applyWarmLanding,
+ claimWarmLandingCommit,
+ reconcileSessionModels,
+ sameModelCatalog,
+ type HomeWarmStart,
+} from "@/lib/home-warm-start";
export const HOME_BOOTSTRAP_TIMEOUT_MESSAGE = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。";
@@ -155,6 +162,54 @@ function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo {
};
}
+/**
+ * Which consultation is still generating in the background: the account-wide
+ * active status, overridden by this tab's stored pending request. Shared by
+ * the cold bootstrap and the warm return (BUG-1040), so recovery is the same.
+ */
+async function resolveReservedConsultation(input: {
+ io: Pick;
+ storage: HomeBootstrapStorage;
+ storedRaw: string | null;
+ sessions: readonly ChatSession[];
+ activeStatus: ConsultationStatus | null;
+ consultationStatusMissingCount: { current: number };
+ signal: AbortSignal;
+}): Promise<{ reservedConsultation: ConsultationStatus | null; storedPending: StoredPendingConsultation | null }> {
+ let reservedConsultation = input.activeStatus;
+ const storedPending = dropRectificationStoredPending(
+ readStoredPendingConsultation(input.storedRaw, input.sessions.map((session) => session.id)),
+ [...input.sessions],
+ );
+ if (!storedPending) input.storage.removeItem(pendingConsultationStorageKey);
+
+ if (storedPending) {
+ try {
+ const status = await input.io.fetchConsultationStatus(
+ storedPending.sessionId,
+ storedPending.requestId,
+ input.signal,
+ );
+ if (status.status === "reserved") {
+ input.consultationStatusMissingCount.current = 0;
+ reservedConsultation = status;
+ } else {
+ input.storage.removeItem(pendingConsultationStorageKey);
+ reservedConsultation = null;
+ }
+ } catch (caught) {
+ if (caught instanceof Error && caught.name === "AbortError") throw caught;
+ input.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
+ reservedConsultation = {
+ sessionId: storedPending.sessionId,
+ requestId: storedPending.requestId,
+ status: "reserved",
+ };
+ }
+ }
+ return { reservedConsultation, storedPending };
+}
+
export async function runAccountPhase(
deps: HomeBootstrapDeps,
signal: AbortSignal,
@@ -294,37 +349,16 @@ export async function runAccountPhase(
}
nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions);
- let reservedConsultation: ConsultationStatus | null = await activeStatusPromise;
- const storedPending = dropRectificationStoredPending(
- readStoredPendingConsultation(deps.storage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)),
- nextSessions,
- );
- if (!storedPending) deps.storage.removeItem(pendingConsultationStorageKey);
-
- if (storedPending) {
- try {
- const status = await io.fetchConsultationStatus(
- storedPending.sessionId,
- storedPending.requestId,
- signal,
- );
- if (status.status === "reserved") {
- deps.consultationStatusMissingCount.current = 0;
- reservedConsultation = status;
- } else {
- deps.storage.removeItem(pendingConsultationStorageKey);
- reservedConsultation = null;
- }
- } catch (caught) {
- if (caught instanceof Error && caught.name === "AbortError") throw caught;
- deps.consultationStatusMissingCount.current = caught instanceof ConsultationStatusError && caught.status === 404 ? 1 : 0;
- reservedConsultation = {
- sessionId: storedPending.sessionId,
- requestId: storedPending.requestId,
- status: "reserved",
- };
- }
- }
+ const activeStatus = await activeStatusPromise;
+ const { reservedConsultation, storedPending } = await resolveReservedConsultation({
+ io,
+ storage: deps.storage,
+ storedRaw: deps.storage.getItem(pendingConsultationStorageKey),
+ sessions: nextSessions,
+ activeStatus,
+ consultationStatusMissingCount: deps.consultationStatusMissingCount,
+ signal,
+ });
if (signal.aborted) return { kind: "stop" };
return {
@@ -479,3 +513,124 @@ export async function runHomeBootstrap(deps: HomeBootstrapDeps, signal: AbortSig
}
}
}
+
+export type HomeWarmRefreshDeps = {
+ start: HomeWarmStart;
+ storage: HomeBootstrapStorage;
+ consultationStatusMissingCount: { current: number };
+ /** The list as Home holds it now (sessionsRef). */
+ currentSessions: () => ChatSession[];
+ restoreConsultationRecovery: HomeBootstrapIo["restoreConsultationRecovery"];
+ /** Home's rectification auto-open, for a stashed return whose `?c=` is written only now. */
+ resumeRectification: (session: ChatSession) => void;
+ refreshAccount: () => Promise;
+ setComposerNotice: (notice: string) => void;
+ commit: {
+ setActiveSessionId: (id: string) => void;
+ setModelCatalog: (catalog: PublicLanguageModelCatalog) => void;
+ setSessions: (update: (current: ChatSession[]) => ChatSession[]) => void;
+ setSessionsCursor: (cursor: string | null) => void;
+ };
+ io?: Partial;
+};
+
+function applyWarmCatalog(
+ deps: HomeWarmRefreshDeps,
+ io: Pick,
+ catalog: PublicLanguageModelCatalog,
+ signal: AbortSignal,
+) {
+ const fallbackSessionIds = reconcileSessionModels(deps.currentSessions(), catalog).fallbackSessionIds;
+ deps.commit.setSessions((current) => reconcileSessionModels(current, catalog).sessions);
+ // A local chat that was never saved has nothing to patch or to announce.
+ const known = new Set(deps.currentSessions().filter((session) => !isUnsavedEmptyConsultation(session)).map((session) => session.id));
+ const saved = fallbackSessionIds.filter((id) => known.has(id));
+ if (saved.length === 0) return false;
+ deps.setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
+ void Promise.all(saved.map((sessionId) => io.patchSessionModel(sessionId, catalog.defaultModelId, signal))).catch(() => {
+ if (!signal.aborted) deps.setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
+ });
+ return true;
+}
+
+/** Synchronous part of a warm return: the same landing side effects as `resolveLanding`. */
+function commitWarmLanding(
+ deps: HomeWarmRefreshDeps,
+ io: Pick,
+ signal: AbortSignal,
+) {
+ const { landing, modelCatalog, sessionsCursor } = deps.start;
+ const selection = landing.selection;
+ const landed = applyWarmLanding(deps.currentSessions(), landing);
+ deps.commit.setSessions((current) => applyWarmLanding(current, landing));
+ deps.commit.setSessionsCursor(sessionsCursor);
+ if (selection.urlAction === "new-chat") io.writeSessionUrl(null, "replace");
+ if (selection.clearStoredReturn) io.clearLoginSessionReturn();
+ if (selection.urlAction === "replace-clear") io.writeSessionUrl(null, "replace");
+ if (selection.urlAction === "replace-selected") {
+ const selected = landed.find((session) => session.id === selection.sessionId);
+ if (!selected || !isUnsavedEmptyConsultation(selected)) {
+ io.writeSessionUrl(selection.sessionId, "replace");
+ // Home's auto-open effect already ran on this mount, before the URL
+ // named the session; without this a stashed rectification session
+ // would sit locked, as in BUG-1038.
+ if (selected?.sessionType === "birth_time_rectification" && selected.id === landing.activeSessionId) {
+ deps.resumeRectification(selected);
+ }
+ }
+ }
+ const fellBack = applyWarmCatalog(deps, io, modelCatalog, signal);
+ if (!fellBack && selection.missing) deps.setComposerNotice(SESSION_MISSING_NOTICE);
+}
+
+/**
+ * Warm return to `/` (BUG-1040). Home is already painted from the snapshot;
+ * this commits the landing and then refreshes in the background: model
+ * catalog, account, and background-consultation recovery. Results replace
+ * values silently. Nothing here is awaited before the page is usable, and
+ * nothing here returns Home to the loading state. The rectification entry
+ * summary and today's card refresh through their own effects.
+ */
+export async function runHomeWarmRefresh(deps: HomeWarmRefreshDeps, signal: AbortSignal): Promise {
+ const io = { ...defaultIo, ...deps.io };
+ const { start } = deps;
+ if (claimWarmLandingCommit(start)) commitWarmLanding(deps, io, signal);
+ if (signal.aborted) return;
+
+ const catalogTask = io.fetchModelCatalog(signal)
+ .then((next) => {
+ if (signal.aborted || sameModelCatalog(next, start.modelCatalog)) return;
+ deps.commit.setModelCatalog(next);
+ applyWarmCatalog(deps, io, next, signal);
+ })
+ // The snapshot catalog stays; the next document load reads it again.
+ .catch(() => undefined);
+
+ const recoveryTask = (async () => {
+ const activeStatus = await io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => {
+ if (caught instanceof Error && caught.name === "AbortError") throw caught;
+ return null;
+ });
+ const { reservedConsultation, storedPending } = await resolveReservedConsultation({
+ io,
+ storage: deps.storage,
+ storedRaw: start.storedPendingRaw,
+ sessions: deps.currentSessions(),
+ activeStatus,
+ consultationStatusMissingCount: deps.consultationStatusMissingCount,
+ signal,
+ });
+ if (signal.aborted || reservedConsultation?.status !== "reserved") return;
+ const recoverySession = deps.currentSessions().find((session) => session.id === reservedConsultation.sessionId);
+ if (recoverySession) deps.restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending);
+ if (start.landing.selection.urlAction === "new-chat") {
+ // Same rule as the cold path: recovery continues in the background and
+ // does not take over an explicit new-chat landing.
+ deps.commit.setActiveSessionId(start.landing.activeSessionId);
+ deps.setComposerNotice("");
+ }
+ })().catch(() => undefined);
+
+ const accountTask = deps.refreshAccount().catch(() => undefined);
+ await Promise.all([catalogTask, recoveryTask, accountTask]);
+}
diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts
index 7caf422f..1baf4fd7 100644
--- a/frontend/src/lib/home-cloud-sync.ts
+++ b/frontend/src/lib/home-cloud-sync.ts
@@ -1,4 +1,5 @@
import { writeChatSession } from "@/lib/chat-session-write-contract";
+import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
bootstrapSelectionFromLookup,
parseSessionUrlQuery,
@@ -412,6 +413,8 @@ export class LoginRedirectError extends Error {
}
export function redirectToLogin(): never {
+ // Any 401 ends this account's warm return to `/` (BUG-1040).
+ clearHomeWarmSnapshot();
persistLoginSessionReturn();
window.location.replace("/login");
throw new LoginRedirectError();
diff --git a/frontend/src/lib/home-warm-snapshot.ts b/frontend/src/lib/home-warm-snapshot.ts
new file mode 100644
index 00000000..5fd4c77c
--- /dev/null
+++ b/frontend/src/lib/home-warm-snapshot.ts
@@ -0,0 +1,99 @@
+/**
+ * Home warm snapshot (BUG-1040).
+ *
+ * The loading ring plays once per document load, not once per Home mount.
+ * After the first cold bootstrap succeeds, Home keeps here what it would
+ * otherwise re-fetch on every client navigation back to `/`: the model
+ * catalog, the rectification entry summary, the session-page cursor and
+ * today's starlanguage card. Account, profile and the session list are not
+ * copied: they already live in the layout-level SessionListProvider, which
+ * survives client navigation.
+ *
+ * Memory only, one account at a time. Nothing here is written to
+ * localStorage / sessionStorage / IndexedDB; a document load starts empty.
+ * Cleared on account change, sign-out and any 401 login redirect.
+ *
+ * Pure module state, no React. Callers: `home-warm-start.ts` (read),
+ * Home's sync effect (write), `DailyStarlanguageBinder` (daily card),
+ * `redirectToLogin` / `signOut` / the provider's signed-out branch (clear).
+ */
+
+import type { DailyStarlanguageCard } from "./home-types.ts";
+import type { PublicLanguageModelCatalog } from "./public-models.ts";
+import type { RectificationEntrySummary } from "./rectification-entry.ts";
+
+export type HomeWarmSnapshot = Readonly<{
+ accountId: string;
+ modelCatalog: PublicLanguageModelCatalog;
+ entrySummary: RectificationEntrySummary | null;
+ entrySummarySettled: boolean;
+ sessionsCursor: string | null;
+ /**
+ * Identity of the provider's boot object when the cursor was taken. A
+ * provider reload (person switch while away) replaces it, and its own
+ * cursor then wins over ours.
+ */
+ listBoot: object | null;
+}>;
+
+/** Today's card, keyed by person + birth-data fingerprint + calendar day. */
+export type HomeWarmDaily = Readonly<{
+ accountId: string;
+ subjectId: string;
+ fingerprint: string;
+ day: string;
+ card: DailyStarlanguageCard;
+}>;
+
+let snapshot: HomeWarmSnapshot | null = null;
+let daily: HomeWarmDaily | null = null;
+
+export function clearHomeWarmSnapshot(): void {
+ snapshot = null;
+ daily = null;
+}
+
+/** Written after a successful cold bootstrap and kept current while Home is mounted. */
+export function writeHomeWarmSnapshot(next: HomeWarmSnapshot): void {
+ if (!next.accountId) return;
+ if (snapshot && snapshot.accountId !== next.accountId) clearHomeWarmSnapshot();
+ if (daily && daily.accountId !== next.accountId) daily = null;
+ snapshot = next;
+}
+
+/**
+ * The snapshot for this account, or null. Another account's snapshot is
+ * dropped on sight: an account change must never paint the previous
+ * account's catalog, summary or card.
+ */
+export function readHomeWarmSnapshot(accountId: string | null | undefined): HomeWarmSnapshot | null {
+ if (!accountId) return null;
+ if (snapshot && snapshot.accountId !== accountId) {
+ clearHomeWarmSnapshot();
+ return null;
+ }
+ return snapshot;
+}
+
+/** Complete means a warm return can paint without waiting for anything. */
+export function isCompleteHomeWarmSnapshot(value: HomeWarmSnapshot | null): value is HomeWarmSnapshot {
+ return Boolean(value && value.accountId && value.modelCatalog && value.modelCatalog.models.length > 0 && value.entrySummarySettled);
+}
+
+export function rememberHomeWarmDaily(next: HomeWarmDaily): void {
+ if (!next.accountId) return;
+ if (snapshot && snapshot.accountId !== next.accountId) clearHomeWarmSnapshot();
+ daily = next;
+}
+
+/** The remembered card only when person, fingerprint and day all still match. */
+export function readHomeWarmDaily(key: Readonly<{
+ accountId: string;
+ subjectId: string;
+ fingerprint: string;
+ day: string;
+}>): DailyStarlanguageCard | null {
+ if (!daily || daily.accountId !== key.accountId) return null;
+ if (daily.subjectId !== key.subjectId || daily.fingerprint !== key.fingerprint || daily.day !== key.day) return null;
+ return daily.card;
+}
diff --git a/frontend/src/lib/home-warm-start.ts b/frontend/src/lib/home-warm-start.ts
new file mode 100644
index 00000000..17e3d715
--- /dev/null
+++ b/frontend/src/lib/home-warm-start.ts
@@ -0,0 +1,321 @@
+/**
+ * Warm return to `/` (BUG-1040). No React hooks.
+ *
+ * Home's `useState(() => …)` initializers call `takeHomeWarmStart()`. When
+ * the layout-level session list is settled for a signed-in account and this
+ * document already completed one cold bootstrap for that account (a complete
+ * `home-warm-snapshot`), Home starts ready: no loading ring, no awaited
+ * request, landing resolved synchronously with the same rules as the cold
+ * path (`?new=1`, `?c=`, the login-return stash and its person scope from
+ * BUG-1038). Anything missing, or a landing that needs a network lookup,
+ * returns null and Home takes the unchanged cold path (BUG-1021: never a
+ * half-reveal).
+ */
+
+import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation-consent";
+import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
+import {
+ bootstrapSelectionFromLookup,
+ readLoginSessionReturn,
+ resolveBootstrapSessionSelection,
+ type BootstrapSessionSelection,
+} from "@/lib/chat-session-url";
+import { takeClientNavigationTarget } from "@/lib/client-navigation-target";
+import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
+import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage";
+import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation } from "@/lib/home-bootstrap";
+import { createSession, readStoredDailyStarlanguage } from "@/lib/home-cloud-sync";
+import {
+ chartSnapshotForSession,
+ isProfileComplete,
+ missingOtherProfileStep,
+ missingProfileStep,
+ readProfile,
+} from "@/lib/home-profile";
+import {
+ pendingConsultationStorageKey,
+ type Account,
+ type ChartLibraryRecord,
+ type ChatSession,
+ type DailyStarlanguageState,
+ type OnboardingStep,
+ type Profile,
+} from "@/lib/home-types";
+import { isCompleteHomeWarmSnapshot, readHomeWarmDaily, readHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
+import { resolveSessionModelId, type PublicLanguageModelCatalog } from "@/lib/public-models";
+import type { RectificationEntrySummary } from "@/lib/rectification-entry";
+import type { SessionListBoot, SessionListContextValue } from "@/lib/session-list-context";
+import { replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter";
+import { createStartGreeting } from "@/lib/starter-greeting";
+
+export type WarmLanding = Readonly<{
+ activeSessionId: string;
+ /** A local empty consultation Home lands on before it is in the list. */
+ pendingSession: ChatSession | null;
+ selection: BootstrapSessionSelection;
+}>;
+
+export type HomeWarmStart = Readonly<{
+ accountId: string;
+ modelCatalog: PublicLanguageModelCatalog;
+ profile: Profile;
+ startGreeting: string;
+ onboardingStep: OnboardingStep;
+ entrySummary: RectificationEntrySummary | null;
+ daily: DailyStarlanguageState;
+ sessionsCursor: string | null;
+ /**
+ * This tab's pending-consultation record, read at mount: Home's own
+ * pending-request effect clears the key on the first effect pass.
+ */
+ storedPendingRaw: string | null;
+ landing: WarmLanding;
+}>;
+
+export type HomeWarmStartInput = Readonly<{
+ account: Account | null;
+ settled: boolean;
+ signedOut: boolean;
+ boot: SessionListBoot | null;
+ sessions: readonly ChatSession[];
+ /** The query of the `/` being opened; null when it cannot be known yet. */
+ search: string | null;
+ storedReturnId: string | null;
+ storedPendingRaw: string | null;
+ subjectId: string;
+ subjectCatalog: readonly ChartLibraryRecord[] | null;
+ /** Development `?preview=` fixtures always take the cold path. */
+ previewRequested: boolean;
+ now: Date;
+}>;
+
+/**
+ * The key today's card is cached under, or null when the card does not apply
+ * (incomplete person, no birth minute). Mirrors DailyStarlanguageBinder.
+ */
+export function dailyStarlanguageWarmKey(subjectId: string, profile: Profile | null, now: Date): { day: string; fingerprint: string } | null {
+ if (!profile || missingOtherProfileStep(profile) !== null) return null;
+ const route = resolveBirthTimeConsultationRoute(profile, {}, "");
+ if (route.kind !== "consult" || !route.time) return null;
+ return {
+ day: calendarDateInTimeZone(now, profile.timezoneId),
+ fingerprint: `${subjectId}:${dailyStarlanguageProfileKey(profile)}`,
+ };
+}
+
+function subjectProfile(subjectId: string, self: Profile, catalog: readonly ChartLibraryRecord[] | null): Profile | null {
+ if (!subjectId || subjectId === "self") return self;
+ return catalog?.find((record) => record.id === subjectId)?.profile ?? null;
+}
+
+function warmDaily(accountId: string, subjectId: string, profile: Profile | null, now: Date): DailyStarlanguageState {
+ const key = dailyStarlanguageWarmKey(subjectId, profile, now);
+ if (!key) return { kind: "unavailable" };
+ const remembered = readHomeWarmDaily({ accountId, subjectId, ...key });
+ if (remembered) return { kind: "ready", card: remembered };
+ const stored = readStoredDailyStarlanguage(accountId);
+ if (stored && stored.day === key.day && stored.fingerprint === key.fingerprint) return { kind: "ready", card: stored.card };
+ // Another day or another person: the card area keeps its static sentence
+ // and the binder fetches under the new key. It never holds up the page.
+ return { kind: "pending" };
+}
+
+/**
+ * Same landing rules as `resolveLanding`, over the list the provider already
+ * holds. Null when the requested session is not in memory (the cold path
+ * owns lookups, BUG-705).
+ */
+export function resolveWarmLanding(input: {
+ sessions: readonly ChatSession[];
+ search: string;
+ storedReturnId: string | null;
+ inSubjectScope: (session: ChatSession) => boolean;
+ createHomeSession: () => ChatSession;
+}): WarmLanding | null {
+ let pendingSession: ChatSession | null = null;
+ let scoped = input.sessions.filter(input.inSubjectScope);
+ if (scoped.length === 0) {
+ pendingSession = input.createHomeSession();
+ scoped = [pendingSession];
+ }
+ const known = pendingSession ? [pendingSession, ...input.sessions] : [...input.sessions];
+ const defaultSessionId = scoped[0].id;
+ let selection = resolveBootstrapSessionSelection({
+ listedIds: known.map((session) => session.id),
+ defaultSessionId,
+ search: input.search,
+ storedReturnId: input.storedReturnId,
+ });
+ if (selection.urlAction === "lookup") return null;
+ if (selection.urlAction === "replace-selected") {
+ // Only a stashed return lands here. A stash of another person is not a
+ // place to return to (BUG-1038).
+ const stashed = known.find((session) => session.id === selection.sessionId);
+ if (stashed && !input.inSubjectScope(stashed)) {
+ selection = bootstrapSelectionFromLookup("other-subject", stashed.id, defaultSessionId, "stored-return");
+ }
+ }
+ let activeSessionId = resolveStarterHomeLandingSessionId(scoped, selection.sessionId, selection.urlAction);
+ if (starterHomeLandingNeedsConsultation(scoped, activeSessionId, selection.urlAction)) {
+ pendingSession ??= input.createHomeSession();
+ activeSessionId = pendingSession.id;
+ }
+ return { activeSessionId, pendingSession, selection };
+}
+
+export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart | null {
+ if (input.previewRequested || input.search === null) return null;
+ const search = input.search;
+ const account = input.account;
+ if (!input.settled || input.signedOut || !account || !input.boot || input.boot.error) return null;
+ const snapshot = readHomeWarmSnapshot(account.user.id);
+ if (!isCompleteHomeWarmSnapshot(snapshot)) return null;
+ const profile = readProfile(account.profile);
+ // Onboarding keeps the cold path: its streamed questions and birth-time
+ // assessment are not part of the snapshot.
+ if (!isProfileComplete(profile)) return null;
+ const catalog = snapshot.modelCatalog;
+ const landing = resolveWarmLanding({
+ sessions: input.sessions,
+ search,
+ storedReturnId: input.storedReturnId,
+ inSubjectScope: (session) => sessionMatchesSubject(session, input.subjectId),
+ createHomeSession: () => createSession(
+ catalog.defaultModelId,
+ "consultation",
+ chartSnapshotForSession(input.subjectId, [...(input.subjectCatalog ?? [])], profile),
+ ),
+ });
+ if (!landing) return null;
+ return {
+ accountId: account.user.id,
+ modelCatalog: catalog,
+ profile,
+ startGreeting: profile.name.trim() ? createStartGreeting(profile.name) : "",
+ onboardingStep: missingProfileStep(profile) ?? "name",
+ entrySummary: snapshot.entrySummary,
+ daily: warmDaily(account.user.id, input.subjectId, subjectProfile(input.subjectId, profile, input.subjectCatalog), input.now),
+ sessionsCursor: input.boot === snapshot.listBoot ? snapshot.sessionsCursor : input.boot.cursor,
+ storedPendingRaw: input.storedPendingRaw,
+ landing,
+ };
+}
+
+function readSessionStorage(key: string): string | null {
+ try {
+ return globalThis.sessionStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
+
+/** Everything the warm decision reads, taken from the layout-level list and this tab. */
+/**
+ * The query of the `/` this mount opens. During a client navigation Next
+ * renders the page before it writes the address bar, so `location` still
+ * shows the page being left; the href noted by AppLink says where we are
+ * going. Unknown (a navigation that noted nothing) → null → cold path.
+ */
+export function homeSearchForMount(location: Pick, noted: string | null): string | null {
+ if (location.pathname === "/") return location.search;
+ if (!noted) return null;
+ const url = new URL(noted, location.origin);
+ return url.pathname === "/" ? url.search : null;
+}
+
+export function homeWarmStartInput(list: Pick): HomeWarmStartInput {
+ const search = homeSearchForMount(window.location, takeClientNavigationTarget());
+ return {
+ account: list.account,
+ settled: list.settled,
+ signedOut: list.signedOut,
+ boot: list.boot(),
+ sessions: list.sessions,
+ search,
+ storedReturnId: readLoginSessionReturn(),
+ storedPendingRaw: readSessionStorage(pendingConsultationStorageKey),
+ subjectId: readCurrentSubjectId(),
+ subjectCatalog: peekSubjectCatalog(),
+ previewRequested: process.env.NODE_ENV === "development" && new URLSearchParams(window.location.search).has("preview"),
+ now: new Date(),
+ };
+}
+
+/** The rectification shell fields a warm start fills; empty for a cold start. */
+export function warmRectificationEntry(start: HomeWarmStart | null): { entrySummary?: RectificationEntrySummary | null; entrySummarySettled?: boolean } {
+ return start ? { entrySummary: start.entrySummary, entrySummarySettled: true } : {};
+}
+
+let memo: { result: HomeWarmStart | null } | null = null;
+let mounted: HomeWarmStart | null = null;
+let landingCommitted: HomeWarmStart | null = null;
+
+/**
+ * One decision per Home mount. Every initializer in the same render (and
+ * React's StrictMode double call) reads the same result; the memo expires on
+ * the next microtask, so the next mount decides again.
+ */
+export function takeHomeWarmStart(read: () => HomeWarmStartInput): HomeWarmStart | null {
+ if (memo) return memo.result;
+ const result = typeof window === "undefined" ? null : resolveHomeWarmStart(read());
+ memo = { result };
+ mounted = result;
+ queueMicrotask(() => {
+ memo = null;
+ });
+ return result;
+}
+
+/** The warm start of the Home that is mounting now, for its bootstrap effect. */
+export function mountedHomeWarmStart(): HomeWarmStart | null {
+ return mounted;
+}
+
+/**
+ * Before the landing commit puts the pending consultation into the list,
+ * the first frame still needs it as the active session.
+ */
+export function pendingWarmLandingSession(activeSessionId: string): ChatSession | undefined {
+ const pending = mounted?.landing.pendingSession;
+ return pending && pending.id === activeSessionId && landingCommitted !== mounted ? pending : undefined;
+}
+
+/** True once per warm start; StrictMode's second effect run skips the commit. */
+export function claimWarmLandingCommit(start: HomeWarmStart): boolean {
+ if (landingCommitted === start) return false;
+ landingCommitted = start;
+ return true;
+}
+
+/** List with the landing's local consultation in front (replacing older unsaved empties). */
+export function applyWarmLanding(sessions: ChatSession[], landing: WarmLanding): ChatSession[] {
+ const pending = landing.pendingSession;
+ if (!pending || sessions.some((session) => session.id === pending.id)) return sessions;
+ return replaceUnsavedEmptyConsultations(sessions, pending);
+}
+
+/** Same fallback as `readSessions(rows, catalog)`, over sessions already in memory. */
+export function reconcileSessionModels(
+ sessions: ChatSession[],
+ catalog: PublicLanguageModelCatalog,
+): { sessions: ChatSession[]; fallbackSessionIds: string[] } {
+ const fallbackSessionIds: string[] = [];
+ const next = sessions.map((session) => {
+ const selection = resolveSessionModelId(session.modelId, catalog);
+ if (!selection.fellBack) return session;
+ fallbackSessionIds.push(session.id);
+ return { ...session, modelId: selection.modelId };
+ });
+ // Unchanged input keeps its identity, so a no-op reconcile does not re-render.
+ return { sessions: fallbackSessionIds.length > 0 ? next : sessions, fallbackSessionIds };
+}
+
+export function sameModelCatalog(left: PublicLanguageModelCatalog | null, right: PublicLanguageModelCatalog | null): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+export function resetHomeWarmStartForTests(): void {
+ memo = null;
+ mounted = null;
+ landingCommitted = null;
+}
diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx
index e58caed2..b7c0dcc5 100644
--- a/frontend/src/lib/session-list-context.tsx
+++ b/frontend/src/lib/session-list-context.tsx
@@ -18,6 +18,7 @@ import type { SidebarSession } from "@/components/sidebar-session-row";
import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { readSessions } from "@/lib/home-cloud-sync";
+import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import type { Account, ChatSession } from "@/lib/home-types";
import { SESSION_PAGE_SIZE } from "@/lib/session-cursor";
import { isListedSidebarSession } from "@/lib/session-list-filter";
@@ -101,6 +102,7 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi
const accountResult = await readBootAccount();
signal.throwIfAborted();
if (accountResult.status === 401) {
+ clearHomeWarmSnapshot();
invalidateSubjectCatalog();
bindCurrentSubjectAccount("", null);
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
@@ -108,6 +110,8 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi
if (!accountResult.account) throw new Error("account_unavailable");
const accountPayload = accountResult.account;
if (readCurrentSubjectAccountId() !== accountPayload.user.id) {
+ // Another account: nothing of the previous one may paint on `/` (BUG-1040).
+ clearHomeWarmSnapshot();
invalidateSubjectCatalog();
bindCurrentSubjectAccount(accountPayload.user.id, null);
}
@@ -116,6 +120,7 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi
const subjectId = readCurrentSubjectId();
const sessionResponse = await fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" });
if (sessionResponse.status === 401) {
+ clearHomeWarmSnapshot();
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
}
const sessionPayload = await sessionResponse.json().catch(() => null) as {
diff --git a/frontend/tests/home-warm-return-lifecycle.test.tsx b/frontend/tests/home-warm-return-lifecycle.test.tsx
new file mode 100644
index 00000000..459063b7
--- /dev/null
+++ b/frontend/tests/home-warm-return-lifecycle.test.tsx
@@ -0,0 +1,595 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import React, { useInsertionEffect, useLayoutEffect, type ReactNode } from "react";
+
+import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
+
+Object.assign(globalThis, { React });
+
+// BUG-1040. Real React lifecycle: the real Home, the real read-only sidebar and
+// the real SessionListProvider composed like app/(app)/layout.tsx. A client
+// navigation swaps the route child (Home unmounts and remounts) while the
+// provider stays; a document load resets module state and keeps storages.
+// The claim under test: the loading ring plays once per document load, and a
+// client return to `/` is ready in its first committed frame.
+
+// Synthetic ids and profiles only (no real person data).
+const USER = "11111111-1111-4111-8111-111111111111";
+const OTHER_USER = "22222222-2222-4222-8222-222222222222";
+const RECT = "33333333-3333-4333-8333-333333333333";
+const CASE = "44444444-4444-4444-8444-444444444444";
+const CONS = "55555555-5555-4555-8555-555555555555";
+const REQUEST = "77777777-7777-4777-8777-777777777777";
+
+const selfProfile = {
+ name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import",
+ birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "",
+ uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "",
+ countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri",
+};
+const rectRow = {
+ id: RECT, title: "生时校正 · 04:45–05:15", theme: "general", model_id: "deepseek-pro", messages: [],
+ session_type: "birth_time_rectification", rectification_case_id: CASE, chart_profile_id: "self",
+ chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
+ created_at: "2026-09-20T00:00:00Z", updated_at: "2026-09-25T00:00:00Z",
+};
+const consultationRow = {
+ id: CONS, title: "事业方向", theme: "career", model_id: "deepseek-pro",
+ messages: [{ role: "user", text: "虚构问题" }, { role: "assistant", text: "虚构回答" }],
+ session_type: "consultation", rectification_case_id: null, chart_profile_id: "self",
+ chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
+ created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z",
+};
+const catalogPro = { defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] };
+const catalogFlash = { defaultModelId: "deepseek-flash", models: [{ id: "deepseek-flash", label: "DeepSeek V4 Flash", description: "x", creditCost: 1, isDefault: true }] };
+const terminalSummary = { has_resumable_case: false, has_terminal_case_with_time: true, latest_resumable: null, latest_terminal: { case_id: CASE, status: "completed" } };
+const emptySummary = { has_resumable_case: false, has_terminal_case_with_time: false, latest_resumable: null, latest_terminal: null };
+
+type Mode = {
+ /** Paths whose response never arrives (to prove nothing waits for them). */
+ hang: Set;
+ models: unknown;
+ summary: unknown;
+ consultStatus: unknown;
+ accountUser: string;
+};
+
+function installApi() {
+ const originalFetch = globalThis.fetch;
+ const requests: string[] = [];
+ const mode: Mode = { hang: 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");
+ const method = init?.method ?? "GET";
+ const path = url.pathname;
+ requests.push(`${method} ${path}${url.search}`);
+ if (mode.hang.has(path)) return new Promise(() => undefined);
+ if (path === "/api/account") {
+ return json({
+ user: { id: mode.accountUser, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9,
+ isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null,
+ hasConfirmedBirthTime: true, hasUsableBirthTime: true,
+ });
+ }
+ if (path === "/api/chart-profiles") return json({ profiles: [] });
+ if (path === "/api/models") return json(mode.models);
+ if (path === "/api/sessions" && method === "GET") return json({ sessions: [rectRow, consultationRow], nextCursor: null });
+ if (path === `/api/sessions/${RECT}`) return json(method === "GET" ? { session: rectRow } : { ok: true });
+ if (path === `/api/sessions/${CONS}`) return json(method === "GET" ? { session: consultationRow } : { ok: true });
+ if (path.startsWith("/api/sessions")) return json({ ok: true });
+ if (path === "/api/consult/status") {
+ if (!mode.consultStatus) return new Response(null, { status: 204 });
+ return json(mode.consultStatus);
+ }
+ if (path === "/api/rectification/cases/entry-summary") return json(mode.summary);
+ if (path === "/api/rectification/cases/open") {
+ return json({ disposition: "readonly", caseId: CASE, sessionId: RECT, status: "completed", shouldStartOpening: false, skillVersion: "test" });
+ }
+ if (path === `/api/rectification/cases/${CASE}`) return json({ turns: [], snapshot: null });
+ if (path === "/api/daily-starlanguage") return new Response(null, { status: 204 });
+ return json({ error: "not_mocked" }, 404);
+ }) as typeof fetch;
+ return { requests, mode, restore: () => { globalThis.fetch = originalFetch; } };
+}
+
+function memoryStorage(): Storage {
+ const values = new Map();
+ return {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => { values.set(key, String(value)); },
+ removeItem: (key) => { values.delete(key); },
+ clear: () => values.clear(),
+ key: (index) => Array.from(values.keys())[index] ?? null,
+ get length() { return values.size; },
+ };
+}
+
+function createTab() {
+ const session = memoryStorage();
+ const local = memoryStorage();
+ const location = {
+ origin: "http://localhost", protocol: "http:", host: "localhost", hostname: "localhost",
+ href: "http://localhost/", pathname: "/", search: "", hash: "",
+ assigned: [] as string[],
+ assign(href: string) { this.assigned.push(href); },
+ replace(href: string) { this.assigned.push(href); },
+ };
+ const setUrl = (href: string) => {
+ const next = new URL(href, "http://localhost");
+ location.pathname = next.pathname;
+ location.search = next.search;
+ location.href = next.href;
+ };
+ const history = {
+ pushState(_state: unknown, _title: string, url: string) { setUrl(url); },
+ replaceState(_state: unknown, _title: string, url: string) { setUrl(url); },
+ };
+ return { session, local, location, history, setUrl };
+}
+
+type Tab = ReturnType;
+type FirstCommit = { loading: boolean; title: string | null; url: string; requests: number };
+
+async function openDocument(tab: Tab, href: string, requests: string[]) {
+ const h = createClientLifecycleHarness();
+ const win = globalThis.window as unknown as Record;
+ tab.setUrl(href);
+ Object.assign(win, {
+ location: tab.location,
+ history: tab.history,
+ navigator: { onLine: true, userAgent: "node" },
+ sessionStorage: tab.session,
+ localStorage: tab.local,
+ innerWidth: 1440,
+ Element: (globalThis as unknown as { Element: unknown }).Element,
+ Node: (globalThis as unknown as { Element: unknown }).Element,
+ ShadowRoot: class {},
+ matchMedia: (query: string) => ({
+ matches: false,
+ media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
+ }),
+ });
+ const hostProto = (globalThis as unknown as { HTMLElement: { prototype: Record } }).HTMLElement.prototype;
+ Object.assign(hostProto, {
+ querySelector: () => null,
+ querySelectorAll: () => [],
+ closest: () => null,
+ matches: () => false,
+ getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }),
+ getClientRects: () => [],
+ scrollTo() {},
+ scrollIntoView() {},
+ scrollHeight: 0, clientHeight: 0, scrollTop: 0, offsetHeight: 0, offsetTop: 0,
+ });
+ if (!Object.getOwnPropertyDescriptor(hostProto, "children")) {
+ Object.defineProperty(hostProto, "children", { configurable: true, get(this: { childNodes: unknown[] }) { return this.childNodes; } });
+ }
+ const doc = globalThis.document as unknown as Record;
+ const createElement = (doc.createElement as (tag: string) => Record).bind(doc);
+ doc.createElement = (tag: string) => {
+ const node = createElement(tag);
+ node.style = { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" };
+ return node;
+ };
+ doc.getElementsByTagName = () => [doc.head];
+ doc.head = createElement("head");
+ doc.documentElement = Object.assign(createElement("html"), { dataset: {} });
+ doc.body = createElement("body");
+ const previous = {
+ sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
+ localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
+ navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
+ matchMedia: Object.getOwnPropertyDescriptor(globalThis, "matchMedia"),
+ Node: Object.getOwnPropertyDescriptor(globalThis, "Node"),
+ requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
+ cancelAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame"),
+ };
+ Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "localStorage", { value: tab.local, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "navigator", { value: win.navigator, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "matchMedia", { value: win.matchMedia, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "Node", { value: win.Node, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "requestAnimationFrame", { value: win.requestAnimationFrame, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "cancelAnimationFrame", { value: win.cancelAnimationFrame, configurable: true, writable: true });
+ // A document load re-evaluates every module: person store, warm snapshot.
+ const subject = await import("../src/lib/current-subject.ts");
+ subject.resetSubjectCatalogForTests();
+ (await import("../src/lib/home-warm-snapshot.ts")).clearHomeWarmSnapshot();
+ (await import("../src/lib/home-warm-start.ts")).resetHomeWarmStartForTests();
+
+ const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx");
+ const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
+ const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
+
+ function AppShell({ children }: { children: ReactNode }) {
+ const list = useSessionList();
+ const registration = useShellRegistration();
+ const { sessions, account } = sessionListSidebarModel(list, registration);
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ const byClass = (name: string) => h.elements().filter((node) => String(node.props.className ?? "").split(/\s+/).includes(name));
+ const view = () => {
+ const title = byClass("chat-header-title")[0]?.childNodes.find((node) => node.tagName === "STRONG")?.text ?? null;
+ const textarea = h.elements().find((node) => node.tagName === "TEXTAREA");
+ return {
+ url: `${tab.location.pathname}${tab.location.search}`,
+ loading: byClass("app-loading").length > 0 || byClass("app-loading-orbit").length > 0,
+ title,
+ inputDisabled: Boolean(textarea?.props.disabled),
+ rectificationSurface: byClass("is-rectification").length > 0,
+ starter: byClass("composer-wrap-starter").length > 0,
+ transcript: h.elements().some((node) => node.text === "虚构回答"),
+ rectificationCard: (byClass("starter-entry").find((node) => /再次校正|开始新的生时校正/.test(String(node.props["aria-label"] ?? "")))?.props["aria-label"] as string | undefined) ?? null,
+ stopVisible: h.elements().some((node) => node.tagName === "BUTTON" && String(node.props["aria-label"] ?? "").includes("停止回答")),
+ modelLabel: h.elements().find((node) => node.tagName === "BUTTON" && /DeepSeek V4/.test(node.text))?.text ?? null,
+ };
+ };
+
+ // Records the first committed frame of each Home mount: a layout effect of
+ // a sibling mounted after Home runs once Home's host tree is committed and
+ // before the browser would paint.
+ const firstCommits: FirstCommit[] = [];
+ function FirstCommitProbe() {
+ useLayoutEffect(() => {
+ const v = view();
+ firstCommits.push({ loading: v.loading, title: v.title, url: v.url, requests: requests.length });
+ }, []);
+ return null;
+ }
+
+ const Home = (await import("../src/app/(app)/page.tsx")).default;
+ const route = (pathname: string): ReactNode => {
+ if (pathname === "/") return ;
+ return ;
+ };
+ // Next's order, not the test's convenience: the router renders the next
+ // page while the address bar still shows the page being left, and writes
+ // the URL in an insertion effect of that commit (app-router HistoryUpdater).
+ let routePath = tab.location.pathname;
+ let pendingHref: string | null = null;
+ function HistoryUpdater() {
+ useInsertionEffect(() => {
+ if (!pendingHref) return;
+ tab.history.pushState(null, "", pendingHref);
+ pendingHref = null;
+ });
+ return null;
+ }
+ const renderRoute = () => h.render(
+ {route(routePath)},
+ );
+ await renderRoute();
+
+ const link = (label: string) => {
+ // Session rows carry their date after the title; match on the title.
+ const found = h.elements().find((node) => node.tagName === "A"
+ && (node.text.trim() === label || node.props["aria-label"] === label))
+ ?? h.elements().find((node) => node.tagName === "A" && String(node.props.href ?? "").startsWith("/?c=") && node.text.startsWith(label));
+ assert.ok(found, `link ${label} is rendered`);
+ return found;
+ };
+ async function followLink(label: string) {
+ const anchor = link(label);
+ const href = String(anchor.props.href);
+ const own = anchor.props.onClick as ((event: unknown) => void) | undefined;
+ const click = {
+ button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false,
+ preventDefault() { click.defaultPrevented = true; }, stopPropagation() {},
+ currentTarget: anchor, target: anchor, nativeEvent: {},
+ };
+ await h.update(() => { own?.(click); });
+ routePath = new URL(href, "http://localhost").pathname;
+ pendingHref = href;
+ await renderRoute();
+ return href;
+ }
+ async function settle(until: () => boolean, label: string, ms = 12000) {
+ const started = Date.now();
+ while (Date.now() - started < ms) {
+ await h.idle();
+ if (until()) return;
+ }
+ assert.fail(`timed out waiting for ${label}: ${JSON.stringify(view())}`);
+ }
+ async function close() {
+ try { await h.close(); } finally {
+ for (const [key, descriptor] of Object.entries(previous)) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+ }
+ }
+ return { h, view, link, followLink, settle, close, firstCommits, errors: h.errors };
+}
+
+type Doc = Awaited>;
+
+/** Cold document load of `/`, revealed and settled (the snapshot is written). */
+async function coldHome(tab: Tab, api: ReturnType, href = "/"): Promise {
+ const doc = await openDocument(tab, href, api.requests);
+ assert.equal(doc.firstCommits[0]?.loading, true, "a document load starts on the loading ring");
+ const { readHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
+ await doc.settle(() => doc.view().title !== null && readHomeWarmSnapshot(USER) !== null, "the cold reveal and its snapshot");
+ for (let i = 0; i < 5; i += 1) await doc.h.idle();
+ return doc;
+}
+
+const WAITED_PATHS = ["/api/models", "/api/consult/status", "/api/rectification/cases/entry-summary", "/api/daily-starlanguage", "/api/account", "/api/sessions", "/api/chart-profiles"];
+
+for (const page of [
+ { label: "星盘", path: "/chart" },
+ { label: "星历", path: "/ephemeris" },
+ { label: "我的报告", path: "/reports" },
+ { label: "星盘档案", path: "/people" },
+] as const) {
+ test(`${page.path} → 新建对话 paints a ready home in its first frame, with every request still unanswered`, async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api);
+ assert.equal(doc.firstCommits.length, 1);
+ await doc.followLink(page.label);
+ assert.equal(tab.location.pathname, page.path);
+ // From here on nothing answers. A home that waited for any of these
+ // could never leave the loading ring.
+ for (const path of WAITED_PATHS) api.mode.hang.add(path);
+ const before = api.requests.length;
+ const href = await doc.followLink("新建对话");
+ assert.equal(href, "/?new=1");
+ const first = doc.firstCommits[1];
+ assert.ok(first, "home mounted again");
+ assert.equal(first.loading, false, "no loading ring on the warm return");
+ assert.equal(first.title, "新对话", "the new empty chat is active in the first frame");
+ assert.equal(first.requests, before, "the first frame was committed before any request was sent");
+ const view = doc.view();
+ assert.equal(view.loading, false);
+ assert.equal(view.title, "新对话");
+ assert.equal(view.starter, true, "starter home, not a transcript");
+ assert.equal(view.transcript, false);
+ assert.equal(view.inputDisabled, false);
+ assert.equal(view.url, "/", "?new=1 is consumed");
+ assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
+ assert.equal(view.rectificationCard, "再次校正", "the rectification card uses the remembered summary");
+ const background = api.requests.slice(before);
+ assert.ok(background.includes("GET /api/models"), `the catalog refreshes in the background: ${background.join(", ")}`);
+ assert.ok(background.includes("GET /api/consult/status"), "background consultation recovery still checks");
+ for (let i = 0; i < 5; i += 1) await doc.h.idle();
+ assert.equal(doc.view().loading, false, "an unanswered background refresh never brings the ring back");
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+ });
+}
+
+test("/reports → a history row opens that conversation without the loading ring", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api, "/?new=1");
+ assert.equal(doc.view().title, "新对话");
+ await doc.followLink("我的报告");
+ for (const path of WAITED_PATHS) api.mode.hang.add(path);
+ const before = api.requests.length;
+ const href = await doc.followLink("事业方向");
+ assert.equal(href, `/?c=${CONS}`);
+ const first = doc.firstCommits[1];
+ assert.equal(first.loading, false);
+ assert.equal(first.title, "事业方向");
+ assert.equal(first.requests, before);
+ assert.equal(doc.view().url, `/?c=${CONS}`, "?c= stays");
+ await doc.settle(() => doc!.view().transcript, "the conversation's messages");
+ assert.equal(doc.view().loading, false);
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("a full reload of / still plays the loading ring once", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api);
+ await doc.followLink("星盘");
+ await doc.followLink("新建对话");
+ assert.equal(doc.firstCommits[1].loading, false);
+ await doc.close();
+ doc = undefined;
+ // Same tab, new document: module memory is gone, storages survive.
+ doc = await openDocument(tab, "/", api.requests);
+ assert.equal(doc.firstCommits[0].loading, true, "reload starts on the ring");
+ await doc.settle(() => doc!.view().title !== null, "the cold reveal after reload");
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("a snapshot missing a piece falls back to the unchanged cold path", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api);
+ await doc.followLink("星盘");
+ const { clearHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
+ clearHomeWarmSnapshot();
+ api.mode.hang.add("/api/models");
+ await doc.followLink("新建对话");
+ assert.equal(doc.firstCommits[1].loading, true, "no snapshot → the loading ring, never a half-reveal");
+ for (let i = 0; i < 5; i += 1) await doc.h.idle();
+ assert.equal(doc.view().loading, true, "the cold path waits for the catalog as before (BUG-1021)");
+ assert.equal(doc.view().title, null);
+ api.mode.hang.delete("/api/models");
+ // The cold path's own retry is out of scope; a new document proves it recovers.
+ await doc.close();
+ doc = undefined;
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("background refresh: a model that went offline switches to the default with the existing notice", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api, `/?c=${CONS}`);
+ assert.equal(doc.view().title, "事业方向");
+ await doc.followLink("星盘");
+ api.mode.models = catalogFlash;
+ const { toast } = await import("sonner");
+ const toastsBefore = toast.getHistory().length;
+ await doc.followLink("事业方向");
+ assert.equal(doc.firstCommits[1].loading, false);
+ assert.match(doc.firstCommits[1].title ?? "", /事业方向/);
+ await doc.settle(() => /Flash/.test(doc!.view().modelLabel ?? ""), "the default model after the catalog refresh");
+ const notices = toast.getHistory().slice(toastsBefore).map((item) => String((item as { title?: unknown }).title ?? ""));
+ assert.ok(notices.includes("此前选择的模型已下线,已切换为默认模型。"), `notice: ${notices.join(" | ")}`);
+ assert.ok(api.requests.some((line) => line === `PATCH /api/sessions/${CONS}`), "the fallback is synced like the cold path");
+ assert.equal(doc.view().loading, false);
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("background refresh: an unfinished consultation resumes on a warm return to its conversation", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api, `/?c=${CONS}`);
+ await doc.followLink("星盘");
+ // Left while an answer was still generating: the tab's pending record
+ // survives and the server still reports the request as reserved.
+ tab.session.setItem("jyotisha.pending-consultation", JSON.stringify({ sessionId: CONS, requestId: REQUEST, question: "虚构追问", theme: "career", entrypoint: null }));
+ api.mode.consultStatus = { sessionId: CONS, requestId: REQUEST, status: "reserved" };
+ await doc.followLink("事业方向");
+ assert.equal(doc.firstCommits[1].loading, false);
+ await doc.settle(() => doc!.view().stopVisible, "the recovering answer's stop control");
+ assert.equal(doc.view().title, "事业方向");
+ assert.ok(tab.session.getItem("jyotisha.pending-consultation"), "the pending record is kept while recovering");
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("background refresh: a reserved consultation keeps recovering without taking over 新建对话", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api);
+ await doc.followLink("星盘");
+ tab.session.setItem("jyotisha.pending-consultation", JSON.stringify({ sessionId: CONS, requestId: REQUEST, question: "虚构追问", theme: "career", entrypoint: null }));
+ api.mode.consultStatus = { sessionId: CONS, requestId: REQUEST, status: "reserved" };
+ await doc.followLink("新建对话");
+ await doc.settle(() => Boolean(tab.session.getItem("jyotisha.pending-consultation")) && api.requests.some((line) => line.startsWith(`GET /api/consult/status?sessionId=${CONS}`)), "the stored request to be checked");
+ for (let i = 0; i < 5; i += 1) await doc.h.idle();
+ assert.equal(doc.view().title, "新对话", "the explicit new chat stays active");
+ assert.equal(doc.view().starter, true);
+ assert.ok(tab.session.getItem("jyotisha.pending-consultation"), "the older answer is still being recovered");
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("background refresh: a changed rectification entry summary replaces the card label silently", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api, "/?new=1");
+ assert.equal(doc.view().rectificationCard, "再次校正");
+ await doc.followLink("星盘");
+ api.mode.summary = emptySummary;
+ await doc.followLink("新建对话");
+ assert.equal(doc.firstCommits[1].loading, false);
+ await doc.settle(() => doc!.view().rectificationCard === "开始新的生时校正", "the refreshed summary");
+ assert.equal(doc.view().loading, false);
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("a stashed return to a rectification session reopens it on a warm return, with its URL", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api, `/?c=${RECT}`);
+ await doc.settle(() => doc!.view().rectificationSurface, "the rectification surface");
+ await doc.followLink("星盘");
+ assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT);
+ // The read-only footer links to bare `/`: the stash decides the landing.
+ await doc.followLink("林遥,打开账户");
+ assert.equal(doc.firstCommits[1].loading, false);
+ assert.equal(doc.view().url, `/?c=${RECT}`, "the stashed ?c= is written back");
+ await doc.settle(() => doc!.view().rectificationSurface, "the stashed rectification session to open (not a locked page, BUG-1038)");
+ assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
+
+test("another account in the same tab never paints the previous account's snapshot", async () => {
+ const tab = createTab();
+ const api = installApi();
+ let doc: Doc | undefined;
+ try {
+ doc = await coldHome(tab, api);
+ const snapshot = await import("../src/lib/home-warm-snapshot.ts");
+ assert.ok(snapshot.readHomeWarmSnapshot(USER), "the cold reveal wrote a snapshot");
+ // The provider reloads (as after a person switch) and now sees another account.
+ api.mode.accountUser = OTHER_USER;
+ const subject = await import("../src/lib/current-subject.ts");
+ await doc.followLink("星盘");
+ await doc.h.update(() => { subject.setCurrentSubject("self"); subject.invalidateAccountFlight(); });
+ await doc.h.update(() => { subject.setCurrentSubject("someone-else"); });
+ await doc.settle(() => snapshot.readHomeWarmSnapshot(USER) === null, "the other account to clear the snapshot");
+ assert.equal(snapshot.readHomeWarmSnapshot(OTHER_USER), null);
+ assert.deepEqual(doc.errors, []);
+ } finally {
+ await doc?.close();
+ api.restore();
+ }
+});
diff --git a/frontend/tests/home-warm-snapshot.test.ts b/frontend/tests/home-warm-snapshot.test.ts
new file mode 100644
index 00000000..a668977e
--- /dev/null
+++ b/frontend/tests/home-warm-snapshot.test.ts
@@ -0,0 +1,274 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ clearHomeWarmSnapshot,
+ isCompleteHomeWarmSnapshot,
+ readHomeWarmDaily,
+ readHomeWarmSnapshot,
+ rememberHomeWarmDaily,
+ writeHomeWarmSnapshot,
+ type HomeWarmSnapshot,
+} from "../src/lib/home-warm-snapshot.ts";
+import {
+ applyWarmLanding,
+ dailyStarlanguageWarmKey,
+ reconcileSessionModels,
+ resolveHomeWarmStart,
+ resolveWarmLanding,
+ type HomeWarmStartInput,
+} from "../src/lib/home-warm-start.ts";
+import type { Account, ChatSession, Profile } from "../src/lib/home-types.ts";
+
+// BUG-1040 T1: the warm snapshot store and the warm-start decision, as pure
+// functions. Synthetic ids and profiles only.
+
+const USER = "11111111-1111-4111-8111-111111111111";
+const OTHER_USER = "22222222-2222-4222-8222-222222222222";
+const CONS = "55555555-5555-4555-8555-555555555555";
+const RECT = "33333333-3333-4333-8333-333333333333";
+const PERSON = "66666666-6666-4666-8666-666666666666";
+const catalog = { defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] };
+const card = { trend: "虚构趋势", action: "虚构行动", caution: "虚构提醒" };
+const selfProfile = {
+ name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import",
+ birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "",
+ uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "",
+ countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri",
+};
+
+function snapshotFor(accountId: string, patch: Partial = {}): HomeWarmSnapshot {
+ return { accountId, modelCatalog: catalog, entrySummary: null, entrySummarySettled: true, sessionsCursor: null, listBoot: null, ...patch };
+}
+
+function session(id: string, patch: Partial = {}): ChatSession {
+ return {
+ id, title: "事业方向", theme: "career", modelId: "deepseek-pro", messages: [{ role: "user", text: "虚构问题" }],
+ createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, pinned: false, archivedAt: null,
+ messagesHydrated: true, chartProfileId: "self", chartProfileName: "林遥", chartProfileRole: "self", ...patch,
+ };
+}
+
+function account(id = USER, profile: unknown = selfProfile): Account {
+ return {
+ user: { id, email: "fictional@example.test" }, profile, avatar: null, credits: 9, isAdmin: false, adminUrl: null,
+ rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: true, hasUsableBirthTime: true,
+ } as unknown as Account;
+}
+
+function input(patch: Partial = {}): HomeWarmStartInput {
+ const boot = { sessions: [], rawRows: [], cursor: null, account: account(), signedOut: false };
+ return {
+ account: account(), settled: true, signedOut: false, boot, sessions: [session(CONS)], search: "?new=1",
+ storedReturnId: null, storedPendingRaw: null, subjectId: "self", subjectCatalog: [], previewRequested: false,
+ now: new Date("2026-09-26T04:00:00Z"), ...patch,
+ };
+}
+
+function withStorage(run: () => T): T {
+ const values = new Map();
+ const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
+ const previous = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
+ Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true, writable: true });
+ try { return run(); } finally {
+ if (previous) Object.defineProperty(globalThis, "localStorage", previous);
+ else Reflect.deleteProperty(globalThis, "localStorage");
+ }
+}
+
+test("write → read returns the snapshot for the same account", () => {
+ clearHomeWarmSnapshot();
+ writeHomeWarmSnapshot(snapshotFor(USER));
+ assert.equal(readHomeWarmSnapshot(USER)?.accountId, USER);
+ assert.equal(isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(USER)), true);
+});
+
+test("another account clears the snapshot instead of returning it", () => {
+ clearHomeWarmSnapshot();
+ writeHomeWarmSnapshot(snapshotFor(USER));
+ rememberHomeWarmDaily({ accountId: USER, subjectId: "self", fingerprint: "f", day: "2026-09-26", card });
+ assert.equal(readHomeWarmSnapshot(OTHER_USER), null);
+ assert.equal(readHomeWarmSnapshot(USER), null, "the previous account's snapshot is gone, not just hidden");
+ assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: "self", fingerprint: "f", day: "2026-09-26" }), null);
+ writeHomeWarmSnapshot(snapshotFor(USER));
+ writeHomeWarmSnapshot(snapshotFor(OTHER_USER));
+ assert.equal(readHomeWarmSnapshot(USER), null);
+});
+
+test("a 401 login redirect clears the snapshot", async () => {
+ clearHomeWarmSnapshot();
+ writeHomeWarmSnapshot(snapshotFor(USER));
+ const { redirectToLogin, LoginRedirectError } = await import("../src/lib/home-cloud-sync.ts");
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
+ const previousSession = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
+ const replaced: string[] = [];
+ Object.defineProperty(globalThis, "window", { value: { location: { search: "", replace: (href: string) => replaced.push(href) } }, configurable: true, writable: true });
+ Object.defineProperty(globalThis, "sessionStorage", { value: { setItem() {} }, configurable: true, writable: true });
+ try {
+ assert.throws(() => redirectToLogin(), LoginRedirectError);
+ } finally {
+ if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
+ else Reflect.deleteProperty(globalThis, "window");
+ if (previousSession) Object.defineProperty(globalThis, "sessionStorage", previousSession);
+ else Reflect.deleteProperty(globalThis, "sessionStorage");
+ }
+ assert.deepEqual(replaced, ["/login"]);
+ assert.equal(readHomeWarmSnapshot(USER), null);
+});
+
+test("sign-out and the provider's signed-out and account-change branches clear the snapshot", () => {
+ const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
+ assert.match(onboarding, /clearHomeWarmSnapshot\(\);\n\s+await selfHostedOtpActions\.signOut\(\);\n\s+window\.location\.assign\("\/login"\);/);
+ const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
+ assert.match(provider, /accountResult\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/);
+ assert.match(provider, /sessionResponse\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/);
+ assert.match(provider, /readCurrentSubjectAccountId\(\) !== accountPayload\.user\.id\) \{\n(?:\s+\/\/.*\n)*\s+clearHomeWarmSnapshot\(\);/);
+});
+
+test("the snapshot lives in memory only", () => {
+ const source = readFileSync(new URL("../src/lib/home-warm-snapshot.ts", import.meta.url), "utf8");
+ const code = source.replace(/\/\*\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
+ assert.doesNotMatch(code, /localStorage|sessionStorage|indexedDB|document\.cookie/);
+});
+
+test("an incomplete snapshot is not a warm start", () => {
+ assert.equal(isCompleteHomeWarmSnapshot(null), false);
+ assert.equal(isCompleteHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false })), false);
+ assert.equal(isCompleteHomeWarmSnapshot(snapshotFor(USER, { modelCatalog: { defaultModelId: "", models: [] } })), false);
+ assert.equal(isCompleteHomeWarmSnapshot(snapshotFor("")), false);
+});
+
+test("today's card is keyed by person, fingerprint and day: another day or person refetches", () => {
+ clearHomeWarmSnapshot();
+ const profile = selfProfile as unknown as Profile;
+ const today = dailyStarlanguageWarmKey("self", profile, new Date("2026-09-26T04:00:00Z"));
+ assert.ok(today);
+ rememberHomeWarmDaily({ accountId: USER, subjectId: "self", ...today, card });
+ assert.deepEqual(readHomeWarmDaily({ accountId: USER, subjectId: "self", ...today }), card);
+ const tomorrow = dailyStarlanguageWarmKey("self", profile, new Date("2026-09-27T04:00:00Z"));
+ assert.ok(tomorrow);
+ assert.notEqual(tomorrow.day, today.day);
+ assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: "self", ...tomorrow }), null, "a new day refetches");
+ const other = dailyStarlanguageWarmKey(PERSON, profile, new Date("2026-09-26T04:00:00Z"));
+ assert.ok(other);
+ assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: PERSON, ...other }), null, "another person refetches");
+ assert.equal(readHomeWarmDaily({ accountId: OTHER_USER, subjectId: "self", ...today }), null);
+ assert.equal(dailyStarlanguageWarmKey("self", { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, new Date()), null, "no birth minute → no card key");
+});
+
+test("warm start: complete snapshot + settled list → ready with a new empty chat for ?new=1", () => {
+ clearHomeWarmSnapshot();
+ writeHomeWarmSnapshot(snapshotFor(USER, { entrySummary: null, sessionsCursor: "cursor-a" }));
+ const start = withStorage(() => resolveHomeWarmStart(input()));
+ assert.ok(start, "warm start");
+ assert.equal(start.modelCatalog, catalog);
+ assert.equal(start.profile.name, "林遥");
+ assert.equal(start.onboardingStep, "name");
+ assert.ok(start.startGreeting.length > 0);
+ assert.equal(start.landing.selection.urlAction, "new-chat");
+ assert.ok(start.landing.pendingSession, "a local empty consultation");
+ assert.equal(start.landing.activeSessionId, start.landing.pendingSession.id);
+ assert.equal(start.landing.pendingSession.messages.length, 0);
+ assert.equal(start.landing.pendingSession.modelId, "deepseek-pro");
+ assert.equal(start.daily.kind, "pending", "no remembered card → static sentence, not a wait");
+ assert.equal(start.sessionsCursor, null, "the provider reloaded since the snapshot: its cursor wins");
+ const boot = { sessions: [], rawRows: [], cursor: "cursor-boot", account: account(), signedOut: false };
+ writeHomeWarmSnapshot(snapshotFor(USER, { sessionsCursor: "cursor-after-load-more", listBoot: boot }));
+ const same = withStorage(() => resolveHomeWarmStart(input({ boot })));
+ assert.equal(same?.sessionsCursor, "cursor-after-load-more", "same provider boot: the cursor Home last held");
+});
+
+test("warm start is refused whenever a piece is missing", () => {
+ clearHomeWarmSnapshot();
+ withStorage(() => {
+ assert.equal(resolveHomeWarmStart(input()), null, "no snapshot");
+ writeHomeWarmSnapshot(snapshotFor(USER));
+ assert.ok(resolveHomeWarmStart(input()));
+ assert.equal(resolveHomeWarmStart(input({ settled: false })), null, "list not settled");
+ assert.equal(resolveHomeWarmStart(input({ signedOut: true })), null, "signed out");
+ assert.equal(resolveHomeWarmStart(input({ account: null })), null, "no account");
+ assert.equal(resolveHomeWarmStart(input({ boot: { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" } })), null, "list error");
+ assert.equal(resolveHomeWarmStart(input({ previewRequested: true })), null, "development preview");
+ assert.equal(resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }) })), null, "onboarding profile");
+ assert.equal(resolveHomeWarmStart(input({ search: "?c=99999999-9999-4999-8999-999999999999" })), null, "a ?c= not in memory needs the cold lookup");
+ writeHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false }));
+ assert.equal(resolveHomeWarmStart(input()), null, "summary never settled");
+ writeHomeWarmSnapshot(snapshotFor(USER));
+ assert.equal(resolveHomeWarmStart(input({ account: account(OTHER_USER) })), null, "another account");
+ });
+});
+
+test("warm landing follows the cold rules: ?c= keeps, stash writes back, other person's stash is dropped", () => {
+ const scope = (subject: string) => (item: ChatSession) => (subject === "self" ? item.chartProfileId === "self" : item.chartProfileId === subject);
+ const create = () => session("new-local", { messages: [], title: "新对话" });
+ const rect = session(RECT, { sessionType: "birth_time_rectification", messages: [] });
+ const other = session(PERSON, { chartProfileId: PERSON, chartProfileRole: "other" });
+ const list = [rect, session(CONS), other];
+
+ const keep = resolveWarmLanding({ sessions: list, search: `?c=${CONS}`, storedReturnId: null, inSubjectScope: scope("self"), createHomeSession: create });
+ assert.equal(keep?.activeSessionId, CONS);
+ assert.equal(keep?.selection.urlAction, "keep");
+ assert.equal(keep?.pendingSession, null);
+
+ const stash = resolveWarmLanding({ sessions: list, search: "", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create });
+ assert.equal(stash?.activeSessionId, RECT);
+ assert.equal(stash?.selection.urlAction, "replace-selected");
+ assert.equal(stash?.selection.clearStoredReturn, true);
+
+ const foreign = resolveWarmLanding({ sessions: list, search: "", storedReturnId: PERSON, inSubjectScope: scope("self"), createHomeSession: create });
+ assert.notEqual(foreign?.activeSessionId, PERSON, "another person's stash is not a place to return to (BUG-1038)");
+ assert.equal(foreign?.selection.urlAction, "none");
+ assert.equal(foreign?.selection.clearStoredReturn, true);
+
+ const bare = resolveWarmLanding({ sessions: list, search: "", storedReturnId: null, inSubjectScope: scope("self"), createHomeSession: create });
+ assert.equal(bare?.activeSessionId, CONS, "bare / on a rectification head lands on a consultation, as cold");
+
+ const newChat = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create });
+ assert.equal(newChat?.activeSessionId, "new-local");
+ assert.equal(newChat?.selection.clearStoredReturn, true, "the stash is ignored and cleared by a new chat");
+
+ const emptyPerson = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: null, inSubjectScope: scope("nobody"), createHomeSession: create });
+ assert.equal(emptyPerson?.activeSessionId, "new-local", "a person with no sessions gets exactly one local chat");
+
+ assert.equal(resolveWarmLanding({ sessions: list, search: "", storedReturnId: "99999999-9999-4999-8999-999999999999", inSubjectScope: scope("self"), createHomeSession: create }), null, "a stash not in memory needs the cold lookup");
+});
+
+test("applying the landing replaces older unsaved empty chats; model reconcile mirrors readSessions", () => {
+ const oldEmpty = session("old-empty", { messages: [] });
+ const pending = session("new-local", { messages: [] });
+ const landing = { activeSessionId: pending.id, pendingSession: pending, selection: { sessionId: pending.id, urlAction: "new-chat" as const, missing: false, clearStoredReturn: true } };
+ const next = applyWarmLanding([oldEmpty, session(CONS)], landing);
+ assert.deepEqual(next.map((item) => item.id), ["new-local", CONS]);
+ const same = [pending, session(CONS)];
+ assert.equal(applyWarmLanding(same, landing), same, "already applied → unchanged");
+
+ const unchanged = [session(CONS)];
+ const kept = reconcileSessionModels(unchanged, catalog);
+ assert.equal(kept.sessions, unchanged, "no fallback keeps identity (no re-render)");
+ const offline = reconcileSessionModels([session(CONS, { modelId: "retired-model" })], catalog);
+ assert.deepEqual(offline.fallbackSessionIds, [CONS]);
+ assert.equal(offline.sessions[0].modelId, "deepseek-pro");
+});
+
+test("the query of the / being opened: location when already there, else the noted AppLink href, else unknown", async () => {
+ const { homeSearchForMount } = await import("../src/lib/home-warm-start.ts");
+ const { noteClientNavigationTarget, takeClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts");
+ const origin = "http://localhost";
+ // Popstate / document load: the address bar already shows `/`.
+ assert.equal(homeSearchForMount({ pathname: "/", search: "?c=abc", origin }, "/chart"), "?c=abc");
+ // Client navigation from /chart: Next writes the URL after rendering.
+ assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/?new=1"), "?new=1");
+ assert.equal(homeSearchForMount({ pathname: "/reports", search: "", origin }, `/?c=${CONS}`), `?c=${CONS}`);
+ assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/"), "");
+ assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, null), null, "nothing noted → cold path");
+ assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/ephemeris"), null);
+
+ noteClientNavigationTarget("/?new=1");
+ assert.equal(takeClientNavigationTarget(), "/?new=1");
+ assert.equal(takeClientNavigationTarget(), null, "taken once");
+ noteClientNavigationTarget("/?new=1");
+ assert.equal(takeClientNavigationTarget(Date.now() + 60_000), null, "a stale note is not trusted");
+ noteClientNavigationTarget("https://example.test/");
+ assert.equal(takeClientNavigationTarget(), null, "only same-origin paths");
+});
diff --git a/frontend/tests/new-chat-from-people-lifecycle.test.tsx b/frontend/tests/new-chat-from-people-lifecycle.test.tsx
index f7d2862d..dc851c96 100644
--- a/frontend/tests/new-chat-from-people-lifecycle.test.tsx
+++ b/frontend/tests/new-chat-from-people-lifecycle.test.tsx
@@ -193,6 +193,10 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
// (it decides which list the provider reads); it re-reads localStorage.
const subject = await import("../src/lib/current-subject.ts");
subject.resetSubjectCatalogForTests();
+ // BUG-1040: the home warm snapshot is module memory too; a document load
+ // starts without it. (Setup only; no assertion in this file changes.)
+ (await import("../src/lib/home-warm-snapshot.ts")).clearHomeWarmSnapshot();
+ (await import("../src/lib/home-warm-start.ts")).resetHomeWarmStartForTests();
const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx");
const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");