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");