diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2aad6c..778321c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-09-27 — 登录后、直接打开网址都进空白首页,不再落回上一次对话(待验收) + +- 以前登录后(尤其是停在某条对话里时登录过期)、或在地址栏直接打开网站,会回到上一次的对话,甚至是上一次的生时校正;用户以为在首页提问,其实问进了旧对话(BUG-1052)。 +- 现在:登录后、地址栏 / 书签 / 刷新不带对话链接的首页,一律是空白首页(开场问候 + 输入框);已有一个没发过消息的新对话就直接用它,不会多出空对话。 +- 带对话链接(`?c=`)打开仍进那条对话:分享链接、侧栏点历史对话、在对话里刷新都不变;「新建对话」不变。 +- 删除「登录过期后回到原对话」的记忆;旧浏览器里残留的记录打开首页时自动清掉。 +- 首页打开时若有一条旧回答还在后台生成,照常在后台恢复,但不再把首页切走(与「新建对话」同一规则)。 +- Skill 版本不 bump。不改数据库。 + ## 2026-09-26 — 普通对话:回答写到一半被掐断时不再算完成、不再扣点(待验收) - 以前排盘计算和写回答共用 110 秒,计算慢时回答会停在半句上,却显示完成并扣点(BUG-1051,复发自 BUG-305)。现在写回答有自己的 70 秒,从开始写时才计时;最长总等待约 3 分钟。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index eeafd4c9..9ad7b503 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -14127,3 +14127,32 @@ - 相关记录:BUG-305(原记录)、BUG-944(「每个 stream 自己的预算」未落到 compose)、BUG-612(同一 tripwire / 110 秒形状)、BUG-280(回答重试没有独立时间预算,本单一并挂到答案时钟上)。 - 复发自:BUG-305(半截回答被当成功并扣点;原防线只拦「抛出的超时」与 `length`,没拦 Mastra 不抛错的 abort)。 - 修复版本:`1530a0dd`,随 `e8195783` 部署 staging(run 2954)。 + +## BUG-1052 | 登录后 / 打开裸 `/` 落在上一次对话里(真机:以为在首页提问,其实在旧生时校正会话里) + +- 状态:investigating(执行方已修复并本地验证,待 Claude 独立验收与 staging 部署;真机清单 `docs/testing/home-landing-blank-20260927.md` 由产品走) +- 首次发现 / 最近更新:2026-09-27 / 2026-09-27 +- 影响面:首页整页加载落点(`frontend/src/lib/chat-session-url.ts` `resolveBootstrapSessionSelection` / `bootstrapSelectionFromLookup`、`frontend/src/lib/home-bootstrap.ts` `resolveStarterHomeLandingSessionId` / `starterHomeLandingNeedsConsultation`、`frontend/src/lib/home-bootstrap-run.ts` `resolveLanding` 与暖返回 `commitWarmLanding` / 恢复段、`frontend/src/lib/home-warm-start.ts` `resolveWarmLanding`、`frontend/src/lib/home-cloud-sync.ts` `redirectToLogin` / `resolveLookupBootstrap`)、侧栏次级页与「去登录」链接(`app-sidebar.tsx` `leaveChat`)、首页内新建对话(`use-session-management.ts` `startNewChat` / `openChatBoundToProfile`)。 +- 用户现象:staging `e8195783` 真机:用户登录后在他以为的首页输入「我和父母关系如何」,实际是在上一次的生时校正会话里,Agent 回答说不能用父母这件事去核对高考那一条。 +- 触发条件(两条路径,代码逐条核实): + 1. 登录返回存根:同一标签页里停在 `/?c=<校正会话>` 时发生任何 401(`redirectToLogin()`),或点侧栏次级页 / 「去登录」链接(`leaveChat()`),都会把当前 `?c=` 写进 `sessionStorage["jyotisha.session-url-return"]`。邮箱验证码登录成功后 `email-otp-login.tsx` 跳 `successPath = "/"`,启动时 `resolveBootstrapSessionSelection` 在没有 `?c=` 时读存根 → `replace-selected` → 写回 `?c=` 并自动打开这条校正会话。真机「落在旧校正里」只能来自这条路径。 + 2. 默认落点:没有 `?c=` 也没有存根时取会话列表第一条(最近一条),`resolveStarterHomeLandingSessionId` 只在它是生时校正时换成「空咨询或任意一条咨询」,只有一条咨询都没有时才新建空白首页。所以裸 `/`(登录、地址栏、书签、刷新裸 `/`)通常打开最近那条旧咨询,而不是空白首页。 + 暖返回(BUG-1040 `resolveWarmLanding`)逐条镜像同一套规则(含存根与人物范围),同样会落回旧会话。 +- 根因:BUG-1038 为「从次级页回来能回到原会话」保留并强化了登录返回存根(找到即写回 `?c=` 并打开),BUG-599 只规定裸 `/` 不自动打开生时校正、却默认落最近一条普通咨询。两条规则叠加,使「登录后」和「裸 `/`」都被当成「回到上次」,而产品期望的是空白首页。诊断与任务单一致,无更正;补充一点:列表行消息未加载(`messagesHydrated: false`)时 `messages` 也是空数组,旧的「空咨询」判定只看 `messages.length === 0`,理论上会把这类已保存会话当成空草稿,本轮一并收紧。 +- 决策记录(产品负责人 2026-09-27,推翻 BUG-1038 的登录返回存根与 BUG-599 / BUG-1038 以来「裸 `/` 默认落最近会话」的落点): + - D1 登录后、以及任何不带 `?c=` 打开 `/`(地址栏、书签、历史、刷新裸 `/`)一律落**空白首页**(空咨询 + 开场问候),不落旧对话;已有同一人物的空草稿就复用,不重复新建(与 `?new=1` / `startNewChat` 同一个去重函数,首问前不落库沿用 BUG-989)。 + - D2 `/?c=` 仍打开该对话(分享链接、侧栏链接、站内跳转不变);`?new=1` 不变。 + - D3 删除登录返回存根:401 / 重新登录不再回到旧对话;删掉不再使用的写入与读取,只在启动时清一次旧浏览器残留的值;人物档案的范围规则(空白首页绑定当前人物)保持。 + - D4 在对话里刷新:地址栏已有 `?c=`(`writeSessionUrl` 写入),刷新留在该对话——核实仍成立。 + - 执行方解读(请验收时确认):裸 `/` 启动时若有一条旧咨询的回答仍在后台生成,按 D1「不落旧对话」处理成与 BUG-1015 `?new=1` 相同:恢复照常在后台进行,但不抢空白首页的落点;只有地址栏点名了会话(`?c=` keep / lookup)时恢复才会切过去。 +- 修复: + - `resolveBootstrapSessionSelection` 去掉 `storedReturnId` 入参与 `clearStoredReturn` 字段:`?new=1` → new-chat;`?c=` → keep / lookup / replace-clear;其余 → none。`replace-selected` 只服务存根,随之删除;`bootstrapSelectionFromLookup` 去掉来源参数与 `other-subject`。 + - 落点:`resolveStarterHomeLandingSessionId` 对 none / replace-clear 只选当前人物的空草稿(新 `isStarterHomeDraft`:consultation、未归档、消息已加载且为空),否则 `starterHomeLandingNeedsConsultation` 返回 true → 本地新建空咨询;冷启动用 `replaceUnsavedEmptyConsultations` 前插(不留重复草稿)。暖返回同一套函数。 + - 恢复:新 `landingYieldsToRecovery(urlAction)`,冷、暖两处把「new-chat 不被恢复抢落点」推广到所有非 keep / lookup 落点。 + - 存根:删除 `persistLoginSessionReturn` / `readLoginSessionReturn`;`redirectToLogin`、`leaveChat` 不再写;`startNewChat` / `openChatBoundToProfile` 不再清;冷启动 `resolveLanding` 无条件 `clearLoginSessionReturn()` 清旧值;暖返回为存根准备的 `resumeRectification` 依赖一并删除(`page.tsx` 少一行,`Home()` useState / useRef 数不变)。 +- 验证:`frontend/tests/new-chat-from-people-lifecycle.test.tsx` 新增 5 条真实 React 生命周期用例(真实 `Home` + `AppSidebar` + `SessionListProvider`,文档加载重置模块状态、保留 storage):裸 `/` 最近是有消息的普通咨询 → 空白首页;最近是生时校正 → 空白首页且不开 Case;`/?c=` 打开并刷新保留;校正会话内刷新保留;校正会话内 401 → 重新登录 `/` → 空白首页。改写 1 条为「旧存根被忽略并清除」。`home-warm-return-lifecycle.test.tsx` 新增冷启动裸 `/` 空白首页、改写暖返回裸 `/` 空白首页(首帧无加载环)。纯函数:`home-bootstrap-reveal`(未加载消息的行不算草稿、恢复让步规则)、`home-warm-snapshot`(暖落点复用本人空草稿、不复用他人草稿)、`new-chat-recovery`(裸 `/` 不被恢复抢、`?c=` 仍被恢复接管)。新增 / 改写的生命周期用例在 `origin/staging` 代码上 12 条失败,修复后全绿。全量、Python、构建、gzip 与本地 Chrome 截图见 `docs/tasks/PROGRESS-home-landing-blank-20260927.md`。 +- 防复发:生命周期测试锁「登录 / 裸 `/` → 空白首页」「`?c=` 与刷新保留」;源码合同反向锁 `persistLoginSessionReturn` / `readLoginSessionReturn` / `storedReturnId` / `replace-selected` / `stored-return` 不再出现、`leaveChat` 只关抽屉、`redirectToLogin` 不写存储。BUG-1038 的测试把「回到原会话」当成要守的行为(存根写入、找到即写回 `?c=`),BUG-599 的测试锁「裸 `/` 打开所选普通咨询」,所以没有任何测试会拦「登录后落在旧会话」;这两处断言已按三栏说明改为新行为。 +- 未改:站内浏览器「返回」退到一个不带 `?c=` 的历史条目(popstate,`applySessionPopStateRef`)仍按原规则选列表里第一条非校正会话;这不是整页打开 `/`,不在 D1 列举范围内,如需同样落空白首页另开单。 +- 相关记录:BUG-1038(登录返回存根与 `bootstrapSelectionFromLookup` 来源参数,本条推翻其存根部分)、BUG-599(裸 `/` 不自动开校正、默认落咨询,本条收紧为空白首页)、BUG-1015(`?new=1` 新建意图与恢复不抢落点,规则被推广)、BUG-1040(暖返回,同规则、首帧无加载环不变)、BUG-989 / BUG-929 / BUG-1001(首问前不落库、空草稿不进列表)、BUG-705(`?c=` lookup 不变)、BUG-936(首屏兜底不变)。 +- 复发自:无(产品规则变更 + BUG-1038 / BUG-599 规则叠加的组合路径)。 +- 修复版本:`codex/home-landing-blank-20260927`(本地提交,未推送)。 diff --git a/docs/tasks/PROGRESS-home-landing-blank-20260927.md b/docs/tasks/PROGRESS-home-landing-blank-20260927.md new file mode 100644 index 00000000..f9b57008 --- /dev/null +++ b/docs/tasks/PROGRESS-home-landing-blank-20260927.md @@ -0,0 +1,92 @@ +# PROGRESS · 登录后 / 裸 `/` 落空白首页,删除登录返回存根(BUG-1052)· 2026-09-27 + +- 执行方式:直接执行(产品负责人授权子代理执行;Claude 事后独立验收) +- 基线:`origin/staging` `5f2e007f` 开工;提交前变基到 `76924e33`(只多一份任务书文档,代码与测试结果不变) +- 分支:`codex/home-landing-blank-20260927`(本地提交,未推送) +- BUG 编号:1052(开工时最大号 1051) +- 决策:产品 2026-09-27 D1–D4,推翻 BUG-1038 登录返回存根与「裸 `/` 默认落最近会话」,写在 `docs/BUG_HISTORY.md` BUG-1052 决策记录 + +## 结论 + +| 项 | 结果 | +| --- | --- | +| 根因 | 与任务单诊断一致:① 登录返回存根(401 / 侧栏次级页与「去登录」链接写入,登录后 `replace-selected` 写回 `?c=` 并打开)——真机「落在旧校正里」只能来自这条;② 默认落点取最近一条,只从生时校正换到「空咨询或任意咨询」,所以裸 `/` 通常开最近一条旧咨询。暖返回逐条镜像。补充:旧空咨询判定只看 `messages.length`,未加载消息的列表行也会被当草稿,已收紧 | +| D1 裸 `/` → 空白首页 | 完成。none / replace-clear 只复用当前人物的空草稿(`isStarterHomeDraft`),否则本地新建;冷启动用 `replaceUnsavedEmptyConsultations` 前插不留重复;暖返回同一套函数 | +| D2 `?c=` / `?new=1` | 不变,有生命周期用例锁住 | +| D3 删除存根 | 完成。删 `persistLoginSessionReturn` / `readLoginSessionReturn`、`storedReturnId`、`clearStoredReturn`、`replace-selected`、lookup 来源参数与 `other-subject`、暖返回 `resumeRectification` 依赖;`redirectToLogin` / `leaveChat` 不写、`startNewChat` / `openChatBoundToProfile` 不清;冷启动无条件 `clearLoginSessionReturn()` 清旧值。人物范围:空白首页绑定当前人物、只复用本人草稿 | +| D4 对话内刷新 | 核实成立(URL 带 `?c=` → keep),生命周期用例 + Chrome 截图 | +| 执行方解读(待验收确认) | 裸 `/` 时若有旧回答在后台生成:恢复继续,但不抢空白首页(`landingYieldsToRecovery`:只有 keep / lookup 让步),与 BUG-1015 `?new=1` 同规则 | +| 未改 | 站内浏览器「返回」退到不带 `?c=` 的历史条目(popstate)仍选列表第一条非校正会话;不是整页打开 `/`,不在 D1 列举内 | + +## 改动文件 + +| 文件 | 改动 | +| --- | --- | +| `frontend/src/lib/chat-session-url.ts` | 选择函数去存根;lookup 结果只剩 found / missing / unavailable;删写 / 读存根,保留清理 | +| `frontend/src/lib/home-bootstrap.ts` | `isStarterHomeDraft`、新落点规则、`landingYieldsToRecovery`;`LandingSession` 加可选 `messagesHydrated` / `archivedAt` | +| `frontend/src/lib/home-bootstrap-run.ts` | 冷落点按当前人物范围、去重前插、无条件清旧存根、恢复让步规则;暖提交删存根分支与 `resumeRectification` | +| `frontend/src/lib/home-warm-start.ts` | 去 `storedReturnId` 与存根分支 | +| `frontend/src/lib/home-cloud-sync.ts` | `redirectToLogin` 不写存根;`resolveLookupBootstrap` 去存根来源 | +| `frontend/src/components/app-sidebar.tsx` | `leaveChat` 只关抽屉 | +| `frontend/src/hooks/use-session-management.ts` | 新建对话不再清存根 | +| `frontend/src/app/(app)/page.tsx` | 删暖刷新的 `resumeRectification` 一行(1370 → 1369 行;`Home()` useState 33 / useRef 37 不变) | + +## 改动的既有断言(原值 / 新值 / 原因均已写在测试行内注释) + +| 文件 | 用例(原标题 → 新标题) | 摘要 | +| --- | --- | --- | +| `chat-session-url.test.ts` | new chat intent overrides … plus login return → … invalid c | 去 `storedReturnId` / `clearStoredReturn` | +| 同上 | new chat bootstrap consumes the URL … | `clearStoredReturn` 条件清 → 无条件清;直接前插 → `replaceUnsavedEmptyConsultations` | +| 同上 | bootstrap reads a listed ?c= session and ignores login storage → bootstrap reads a listed ?c= session | 去存根字段 | +| 同上 | bootstrap restores a stored login return only when the URL has no c → bare / after login never returns to a stored conversation (BUG-1052) | `replace-selected` → `none`,源码反向锁 | +| 同上 | bootstrap clears an illegal or unknown session query | 去存根字段(2 处) | +| 同上 | default bootstrap selection does not write a session URL | 去存根字段;`replace-selected` 正向 → 反向 | +| 同上 | 401 login redirect stashes the current session id … → 401 login redirect stashes nothing … | 正向 `persistLoginSessionReturn` → 反向 | +| 同上 | login return storage only accepts a UUID session id → a leftover login-return stash from an older build is only ever cleared | 写 / 读 → 只清、隐私模式不抛 | +| `chat-navigation-a11y-contract.test.ts` | auth redirects stay hard document loads … | `persistLoginSessionReturn();\n replace` → `clearHomeWarmSnapshot();\n replace` + 反向 | +| `sidebar-contract.test.ts` | reaches chart, ephemeris and reports with links … | `leaveChat` 先存根 → 只关抽屉 + 反向 | +| `home-bootstrap-reveal.test.ts` | default landing replaces a latest rectification session … | `replace-selected` → `lookup`;none 选普通咨询 → 选空草稿 | +| 同上 | bare home still opens the selected ordinary chat without creating another → bare home with a latest ordinary chat lands on a new blank starter home (BUG-1052) | NeedsConsultation false → true | +| `session-lookup-unlisted.test.ts` | lookup results … | 去 `clearStoredReturn` | +| 同上 | a looked-up stashed return writes its URL back … → only a ?c= is ever looked up … (BUG-1052) | 来源参数删除,源码反向锁 | +| `home-warm-snapshot.test.ts` | warm landing follows the cold rules: ?c= keeps, stash writes back, … → … bare / is the blank starter home, ?new=1 is a new chat | bare CONS → 新建空对话;新增复用本人草稿 / 不复用他人草稿 | +| `new-chat-recovery.test.ts` | ordinary home landing still activates and announces … → bare / keeps the blank starter home while … recovers (BUG-1052) | active 旧会话 → 空白首页、notice 清空、storedCleared true;另加 `?c=` 仍被接管 | +| `new-chat-from-people-lifecycle.test.tsx` | 侧栏新建对话 ×5、他人存根不落锁死页 | 离开时存根 RECT → null(落点断言不变) | +| 同上 | a stashed session of the same person found beyond the first page opens … → a leftover stash … is ignored and cleared … (BUG-1052) | 打开校正并写回 `?c=` → 空白首页、不 lookup、清旧值 | +| 同上 | an in-page new chat drops the stashed return … → an in-page new chat leaves no return trail … | 不预置存根,断言新建不写 | +| `home-warm-return-lifecycle.test.tsx` | a stashed return to a rectification session reopens it … → a warm return to bare / after a rectification session lands on the blank starter home … | 写回 `?c=` 打开校正 → 空白首页、首帧无加载环 | +| fixtures(非断言) | `home-bootstrap-run` / `session-list-lifecycle` / `consultation-recovery` / `new-chat-recovery` / `home-warm-snapshot` | 去掉 selection 里的 `clearStoredReturn` 字段 | + +新增:生命周期 5 条(裸 `/` 最近普通咨询 / 最近生时校正、`?c=` + 刷新、校正内刷新、校正内 401 → 重登)、暖返回冷启动裸 `/` 1 条、纯函数 2 条(未加载消息的行不算草稿、恢复让步规则)、`?c=` 恢复接管 1 条。改写与新增的生命周期用例放到 `origin/staging` 代码上跑:12 条失败(`?c=` 与刷新两条本就应通过,作 D2 / D4 护栏);修复后全绿。 + +## 验证 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(128 warning,改动文件无新增与本单标识符相关的 warning) | +| 全量 `npm test`(Node 22.14) | 4078 / 24 fail / 28 skip;基线 4069 / 24 / 28。失败名单与基线逐条一致(全部 Docker/DB);消失的 12 个用例名全部是改名,三栏说明在行内,新增 21 个名 | +| Python 门禁集合 | 948 passed / 1 skipped(与基线一致) | +| `npm run build -- --webpack` | `/`、`/chart`、`/ephemeris`、`/people` 均 ○ Static;已删构建产生的 `frontend/frontend/` | +| 首屏 gzip(`rootMainFiles` 4 个文件 zlib 默认级别求和) | 131,253 B;基线 130,933 B,+0.24%,在 ±2% 内 | +| 定向 13 个文件 | 167 / 0 fail | + +## 本地 Chrome(headless,CDP 拦截 `/api/*` 返回虚构数据) + +`next start`(本单构建)+ Chrome `--headless=new`,截图在 `docs/testing/home-landing-blank-20260927/`: + +| 截图 | 场景 | 结果 | +| --- | --- | --- | +| `1-bare-latest-rectification.png` | 列表 = [生时校正(最近), 普通咨询],打开 `/` | 空白首页「新对话」、地址 `/`、无转录、未开校正 | +| `2-bare-latest-consultation.png` | 列表 = [普通咨询(最近,有消息), 生时校正],打开 `/` | 空白首页 | +| `3-bare-leftover-stash.png` | sessionStorage 预置旧存根 = 校正会话,打开 `/` | 空白首页,存根被清 | +| `4-c-link-consultation.png` | 打开 `/?c=<咨询>` | 进该对话、有问答 | +| `5-c-link-rectification.png` | 打开 `/?c=<校正>` | 进校正界面 | +| `6-refresh-in-rectification.png` | 在 5 上刷新 | 仍在校正界面、地址不变 | + +`POST /api/rectification/cases/open` 共 2 次,只出现在 5 / 6。右下角「合盘历史暂时无法读取」提示是本 harness 未 mock `/api/synastry-reports`(404)所致,与本单无关。 + +## 环境缺口 + +- 无受控登录账号:真实邮箱验证码登录、真实 401 过期后重登、iPhone Safari 走查留给 `docs/testing/home-landing-blank-20260927.md`。 +- 无 Docker:24 条 DB/部署测试失败,与基线名单逐条一致。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 420aa0e2..e1e5c5c6 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -126,6 +126,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| — (产品 09-27 拍板 D1–D4,直接执行) | [PROGRESS](PROGRESS-home-landing-blank-20260927.md) | **登录后 / 裸 `/` 落空白首页**:真机登录后在「首页」提问其实问进了上一次生时校正。删登录返回存根(401 / 次级页链接写、登录后写回 `?c=` 打开),裸 `/` 不再落最近会话,一律当前人物的空白首页(复用空草稿);`?c=` / `?new=1` / 对话内刷新不变;推翻 BUG-1038 存根与 BUG-599 默认落点 | 待验收 | `codex/home-landing-blank-20260927`(BUG-1052,本地未推) | | — (产品 09-26 口头拍板 D1–D3,直接执行) | `PROGRESS-consult-answer-truncation-20260926.md` | **普通咨询回答写到一半被掐断仍扣点(BUG-1051,复发自 BUG-305)**:工具循环与写回答共用 110 秒 signal;Mastra 1.50 超时不抛错(`abort` 块 + `finish(tripwire)` 后正常关流),结算只认抛错与 `length`。D1 写回答自有 70 秒时钟(首用起算,续写 / 回答重试共用,最坏 180 秒,`maxDuration` 240);D2 写回答的最后一个流不是 `stop` 且有正文 → `answer_truncated`、不扣点、记 abort 步、不冲半句,`length` 续写不变;D3 观测加 `composeFinishReason` / `composeAborted` / `answerVisibleChars` | 已验收(真机欠) | `codex/consult-answer-truncation-20260926`(本地,未推送);新回归 15 条用真实 Mastra `Agent`(修复前 11 条红);全量失败名单 0 新增;Python 948/1;`/` ○、gzip 0%;真机清单 `docs/testing/consult-answer-truncation-20260926.md` | | `TASK-consult-evidence-card-research-20260927.md` | `PROGRESS-consult-evidence-card-research-20260927.md` | **普通对话数据卡调研**:引擎输出逐项分五类计量(现约 4 万 token、父母问题相关约 3.5%);四处领域→技法来源对账并起草各领域数据卡(家庭拆父母/子女);卡体量与逐字一致性;按卡算的提速空间;反馈迭代埋点方案。只调研不改线上 | 待领取 | 分支 `codex/consult-evidence-card-research-20260927`;与 BUG-1053 实现单并行,不碰 consult route / stream-agent-response | | `TASK-scroll-anchor-hook-fixes-20260926.md` | `PROGRESS-scroll-anchor-hook-fixes-20260926.md` | **滚动锚两处老问题**:直接打开已有会话时监听未挂上(BUG-1043)、校正长回答钉顶后因 96px 阈值被拉到底(BUG-1044)。排在 BUG-1042 合入后。挂载改由容器元素本身驱动(每次提交比对元素 / active / resetKey);钉顶只由用户滚动手势解除 | 已验收(Claude 09-26 直接执行:子代理复现两处根因并修复;Claude 独立复验 tsc/lint 0、全量 3970 条失败名单与基线逐条一致、四路由 ○、gzip 不变;iOS 惯性滚动留真机清单) | `da2613ff`(随 `f1d16405` 部署,health 一致) | diff --git a/docs/testing/home-landing-blank-20260927.md b/docs/testing/home-landing-blank-20260927.md new file mode 100644 index 00000000..6c49fc69 --- /dev/null +++ b/docs/testing/home-landing-blank-20260927.md @@ -0,0 +1,62 @@ +# 真机清单 · 登录后与打开网址都进空白首页(BUG-1052)· 2026-09-27 + +部署含 `codex/home-landing-blank-20260927` 的 staging 后照做。先确认 `https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 是这次部署的提交。 + +## 这次改了什么(给自己看的一句话) + +以前登录后、或直接打开网址,会回到上一次的对话(甚至是上一次的生时校正),看起来像首页其实不是。现在:不带对话链接打开网站,永远是空白首页;带对话链接(地址里有 `?c=`)才进那条对话。 + +准备:用一个已经有至少一条普通对话、一条生时校正记录的账号(本机用 iPhone Safari 或电脑 Chrome 都可以)。 + +## 1. 退出再登录,落在空白首页 + +1. 先点开侧栏里那条**生时校正**,确认进到校正界面(地址栏里有 `?c=`)。 +2. 左下角账户菜单 →「退出登录」。 +3. 用邮箱验证码重新登录。 + +预期: + +- 登录后看到的是**空白首页**:标题「新对话」,中间是「最近还好吗,…?」问候、输入框占位「想聊什么都可以」、下面两个入口按钮。 +- 地址栏是 `https://staging.jyotisha.chat/`,**没有** `?c=`。 +- 侧栏里那条生时校正仍在,只是没有被选中。 +- 在输入框问一句普通问题(例如「我和父母关系如何」),回答是普通对话,**不会**提到高考、生时校正或「不能用这件事核对」。 + +## 2. 登录过期后重新登录,也不回旧对话(能做就做) + +1. 打开那条生时校正(地址栏有 `?c=`),停在这里。 +2. 在另一个标签页退出登录(或等登录过期),回到第一个标签页随便点一下,让它跳到登录页。 +3. 重新登录。 + +预期:落在空白首页,地址栏没有 `?c=`,不是刚才那条生时校正。 + +## 3. 地址栏直接打开网址 / 书签,都是空白首页 + +1. 在任意一条旧对话里,把地址栏改成 `https://staging.jyotisha.chat/`(去掉后面的 `?c=…`)回车。 +2. 从书签或浏览器历史里打开 `https://staging.jyotisha.chat/`。 +3. 在空白首页上直接按刷新。 + +预期:三次都是空白首页「新对话」,不是最近那条对话;刷新后也没有多出空对话(侧栏里不会冒出一排「新对话」)。 + +## 4. 对话链接照常打开 + +1. 在侧栏点一条旧的普通对话,地址栏出现 `?c=…`。复制这个地址。 +2. 新开一个标签页,粘贴打开。 + +预期:直接进这条对话,能看到原来的问答,标题是这条对话的标题。 + +## 5. 在对话里刷新,留在这条对话 + +1. 在一条普通对话里按刷新:仍在这条对话,问答都在。 +2. 在那条生时校正里按刷新:仍在生时校正界面,标题、时间轴都在。 + +## 6. 从其他页面回首页(不应退步) + +1. 点侧栏「星盘」,再点「新建对话」:马上是空白首页,没有加载转圈。 +2. 点侧栏「星盘档案」,再点左下角账户头像那一块(回首页):马上是空白首页,没有加载转圈,也不是刚才那条对话。 +3. 在「星盘」页点侧栏里的某条历史对话:直接进那条对话。 + +## 发现问题时请记 + +- 截图(整屏,带地址栏)。 +- 做到第几步、点了什么。 +- 地址栏里有没有 `?c=`。 diff --git a/docs/testing/home-landing-blank-20260927/1-bare-latest-rectification.png b/docs/testing/home-landing-blank-20260927/1-bare-latest-rectification.png new file mode 100644 index 00000000..2a96a022 Binary files /dev/null and b/docs/testing/home-landing-blank-20260927/1-bare-latest-rectification.png differ diff --git a/docs/testing/home-landing-blank-20260927/2-bare-latest-consultation.png b/docs/testing/home-landing-blank-20260927/2-bare-latest-consultation.png new file mode 100644 index 00000000..845de38c Binary files /dev/null and b/docs/testing/home-landing-blank-20260927/2-bare-latest-consultation.png differ diff --git a/docs/testing/home-landing-blank-20260927/3-bare-leftover-stash.png b/docs/testing/home-landing-blank-20260927/3-bare-leftover-stash.png new file mode 100644 index 00000000..a503fa66 Binary files /dev/null and b/docs/testing/home-landing-blank-20260927/3-bare-leftover-stash.png differ diff --git a/docs/testing/home-landing-blank-20260927/4-c-link-consultation.png b/docs/testing/home-landing-blank-20260927/4-c-link-consultation.png new file mode 100644 index 00000000..5e607f99 Binary files /dev/null and b/docs/testing/home-landing-blank-20260927/4-c-link-consultation.png differ diff --git a/docs/testing/home-landing-blank-20260927/5-c-link-rectification.png b/docs/testing/home-landing-blank-20260927/5-c-link-rectification.png new file mode 100644 index 00000000..39f16cd8 Binary files /dev/null and b/docs/testing/home-landing-blank-20260927/5-c-link-rectification.png differ diff --git a/docs/testing/home-landing-blank-20260927/6-refresh-in-rectification.png b/docs/testing/home-landing-blank-20260927/6-refresh-in-rectification.png new file mode 100644 index 00000000..39f16cd8 Binary files /dev/null and b/docs/testing/home-landing-blank-20260927/6-refresh-in-rectification.png differ diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index bdafaf60..34f1997f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -737,7 +737,9 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小 校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。打字回答的 live 行从发出那一帧起就是阶段句「收到,正在对照你的档案…」,按服务端 `turn.progress` 换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」,属于流式生成中(BUG-1047)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `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 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。 +**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户页脚或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、其余一律当前人物的空白首页,同 BUG-1052),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。 + +**打开 `/` 落在哪里(2026-09-27,BUG-1052)。** 地址栏带 `?c=` 就进那条对话(分享链接、侧栏历史、在对话里刷新都靠它);带 `?new=1` 是新建对话;其余——登录后、手输网址、书签、刷新裸 `/`——一律是当前人物的空白首页(开场问候 + 输入框),已有一个没发过消息的新对话就复用它。不再记「上次在哪条对话」:离开首页、登录过期都不存任何返回记录,后台还在恢复的旧回答也不把空白首页切走。 首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 311789ed..150cfead 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -780,7 +780,6 @@ export default function Home() { consultationStatusMissingCount, currentSessions: () => sessionsRef.current, restoreConsultationRecovery, - resumeRectification: (session) => resumeRectificationSession.current(session), refreshAccount, setComposerNotice, commit: { setActiveSessionId, setModelCatalog, setSessions, setSessionsCursor }, diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index a772f0d3..c96b7276 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -19,7 +19,7 @@ import { usePathname } from "next/navigation"; import { useEffect, useRef } from "react"; import type { Ref } from "react"; import { AppLink } from "@/components/app-link"; -import { newChatHref, persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; +import { newChatHref, sessionHref } from "@/lib/chat-session-url"; import { prefetchSecondaryPage } from "@/lib/secondary-page-data"; import { prefetchBillingPanel } from "@/lib/billing-panel-loader"; import { prefetchPaymentPackages } from "@/lib/payment-packages-cache"; @@ -182,10 +182,10 @@ export function AppSidebar({ /* Real links. Leaving `/` used to be a full document load: the whole React tree, the session list and the account went with it, so coming back re-ran the entire bootstrap from zero. `/login` keeps the hard exit — - it crosses an auth boundary — and `persistLoginSessionReturn()` still - stashes the `?c=` first, so the way back to this session survives. */ + it crosses an auth boundary. Nothing is stashed on the way out: a bare + `/` lands on the blank starter home, and the way back to a conversation + is its `?c=` link (BUG-1052). */ function leaveChat() { - persistLoginSessionReturn(); closeDrawer(); } diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index db1223c8..9b0c3f11 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -8,7 +8,6 @@ import { writeChatSession } from "@/lib/chat-session-write-contract"; import { SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE, - clearLoginSessionReturn, parseSessionUrlQuery, writeSessionUrl, } from "@/lib/chat-session-url"; @@ -352,9 +351,6 @@ export function useSessionManagement(params: SessionManagementParams) { pendingCreateById.current.set(nextSession.id, { continuedFromSessionId: options?.continuedFromSessionId, }); - // A new chat is not a place to come back to (BUG-1038): drop the stashed - // `?c=` a secondary-page link left, or the next bare `/` reopens it. - clearLoginSessionReturn(); setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession)); setActiveSessionId(nextSession.id); setDraft(""); @@ -624,7 +620,6 @@ export function useSessionManagement(params: SessionManagementParams) { if (isUnsavedEmptyConsultation(existing)) pendingCreateById.current.delete(existing.id); } pendingCreateById.current.set(nextSession.id, {}); - clearLoginSessionReturn(); setSessions((current) => replaceUnsavedEmptyConsultations(current, nextSession)); setActiveSessionId(nextSession.id); setDraft(""); diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts index 392380c7..6ea18e4b 100644 --- a/frontend/src/lib/chat-session-url.ts +++ b/frontend/src/lib/chat-session-url.ts @@ -1,5 +1,10 @@ export const SESSION_URL_QUERY_KEY = "c"; export const NEW_CHAT_QUERY_KEY = "new"; +/** + * Legacy key only. Until BUG-1052 a 401 or a secondary-page link stashed the + * current `?c=` here and a bare `/` reopened it. Nothing writes it any more; + * bootstrap removes a leftover value so an old tab cannot keep a stale id. + */ export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return"; export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除"; export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。"; @@ -12,16 +17,15 @@ export type SessionUrlQuery = { export type BootstrapSessionSelection = { readonly sessionId: string; - readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat"; + readonly urlAction: "keep" | "replace-clear" | "none" | "lookup" | "new-chat"; readonly missing: boolean; - readonly clearStoredReturn: boolean; }; /** * The one new-chat intent. The read-only sidebar and /people「和 TA 对话」 both * link here; the person rides on the current-subject store, not the URL. - * Bootstrap turns it into a local empty consultation and ignores (then clears) - * the stashed `?c=` return, so no older session is reopened (BUG-1015/1038). + * Bootstrap turns it into a local empty consultation, so no older session is + * reopened (BUG-1015/1038). */ export function newChatHref(): string { return "/?new=1"; @@ -63,25 +67,7 @@ export function writeSessionUrl(sessionId: string | null, mode: "push" | "replac else history.replaceState(null, "", next); } -export function persistLoginSessionReturn() { - try { - const sessionId = parseSessionUrlQuery(globalThis.window.location.search).sessionId; - if (sessionId) globalThis.sessionStorage.setItem(SESSION_URL_RETURN_STORAGE_KEY, sessionId); - } catch { - // sessionStorage can throw in private mode; login still proceeds. - } -} - -export function readLoginSessionReturn(): string | null { - try { - const raw = globalThis.sessionStorage.getItem(SESSION_URL_RETURN_STORAGE_KEY); - if (!raw || !SESSION_URL_ID_PATTERN.test(raw)) return null; - return raw; - } catch { - return null; - } -} - +/** Removes a stash an older build left behind (BUG-1052). Never read back. */ export function clearLoginSessionReturn() { try { globalThis.sessionStorage.removeItem(SESSION_URL_RETURN_STORAGE_KEY); @@ -90,111 +76,41 @@ export function clearLoginSessionReturn() { } } +/** + * Where a full load of `/` lands (BUG-1052): `?new=1` → a new chat; `?c=` → + * that conversation (shared links, sidebar links, refresh inside a + * conversation); anything else — login, a typed address, a bookmark, a + * refresh of bare `/` — → the blank starter home. There is no stored + * "return to the last conversation" any more. + */ export function resolveBootstrapSessionSelection(input: { readonly listedIds: readonly string[]; readonly defaultSessionId: string; readonly search: string; - readonly storedReturnId: string | null; }): BootstrapSessionSelection { if (parseNewChatIntent(input.search)) { - return { - sessionId: input.defaultSessionId, - urlAction: "new-chat", - missing: false, - clearStoredReturn: true, - }; + return { sessionId: input.defaultSessionId, urlAction: "new-chat", missing: false }; } const query = parseSessionUrlQuery(input.search); if (query.present) { if (query.sessionId && input.listedIds.includes(query.sessionId)) { - return { - sessionId: query.sessionId, - urlAction: "keep", - missing: false, - clearStoredReturn: true, - }; + return { sessionId: query.sessionId, urlAction: "keep", missing: false }; } if (query.sessionId) { - return { - sessionId: query.sessionId, - urlAction: "lookup", - missing: false, - clearStoredReturn: false, - }; + return { sessionId: query.sessionId, urlAction: "lookup", missing: false }; } - return { - sessionId: input.defaultSessionId, - urlAction: "replace-clear", - missing: true, - clearStoredReturn: true, - }; + return { sessionId: input.defaultSessionId, urlAction: "replace-clear", missing: true }; } - if (input.storedReturnId) { - if (input.listedIds.includes(input.storedReturnId)) { - return { - sessionId: input.storedReturnId, - urlAction: "replace-selected", - missing: false, - clearStoredReturn: true, - }; - } - return { - sessionId: input.storedReturnId, - urlAction: "lookup", - missing: false, - clearStoredReturn: false, - }; - } - return { - sessionId: input.defaultSessionId, - urlAction: "none", - missing: false, - clearStoredReturn: false, - }; + return { sessionId: input.defaultSessionId, urlAction: "none", missing: false }; } -/** - * `origin` says where the looked-up id came from. A `?c=` is already in the - * address bar, so a found session keeps it. A stashed return (the `?c=` a - * secondary-page link left behind) is not: landing on it must write it back, - * or the page shows that session without the URL that opens it — for a - * rectification session that is a locked composer that never opens (BUG-1038). - * A stashed session of another person is not a place to return to at all. - */ +/** Outcome of looking up a `?c=` id that is not in the loaded page (BUG-705). */ export function bootstrapSelectionFromLookup( - status: "found" | "missing" | "unavailable" | "other-subject", + status: "found" | "missing" | "unavailable", requestedId: string, defaultSessionId: string, - origin: "url" | "stored-return" = "url", ): BootstrapSessionSelection { - if (status === "found") { - return { - sessionId: requestedId, - urlAction: origin === "stored-return" ? "replace-selected" : "keep", - missing: false, - clearStoredReturn: true, - }; - } - if (status === "other-subject") { - return { - sessionId: defaultSessionId, - urlAction: "none", - missing: false, - clearStoredReturn: true, - }; - } - if (status === "missing") { - return { - sessionId: defaultSessionId, - urlAction: "replace-clear", - missing: true, - clearStoredReturn: true, - }; - } - return { - sessionId: defaultSessionId, - urlAction: "none", - missing: false, - clearStoredReturn: false, - }; + if (status === "found") return { sessionId: requestedId, urlAction: "keep", missing: false }; + if (status === "missing") return { sessionId: defaultSessionId, urlAction: "replace-clear", missing: true }; + return { sessionId: defaultSessionId, urlAction: "none", missing: false }; } diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index e6c8c7c1..3a27922f 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -22,6 +22,7 @@ import { import { createStartGreeting } from "@/lib/starter-greeting"; import { HOME_BOOTSTRAP_SLOW_MS, + landingYieldsToRecovery, resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation, type BootstrapPhase, @@ -61,7 +62,7 @@ import { import type { PublicLanguageModelCatalog } from "@/lib/public-models"; import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard"; import type { SessionListBoot } from "@/lib/session-list-context"; -import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; +import { isUnsavedEmptyConsultation, replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; import { applyWarmLanding, @@ -386,22 +387,24 @@ export async function resolveLanding( catalog: ready.nextModelCatalog, defaultSessionId: ready.nextSessions[0].id, signal, - inSubjectScope: (session) => sessionMatchesSubject(session, readCurrentSubjectId()), }); let nextSessions = lookedUp.sessions; const bootstrapSelection: BootstrapSessionSelection = lookedUp.selection; + // Bare `/` lands on the blank starter home of the current person (BUG-1052): + // an empty draft of that person is reused, otherwise one is created locally. + const inSubjectScope = nextSessions.filter((session) => sessionMatchesSubject(session, readCurrentSubjectId())); let landingSessionId = resolveStarterHomeLandingSessionId( - nextSessions, + inSubjectScope, bootstrapSelection.sessionId, bootstrapSelection.urlAction, ); - if (starterHomeLandingNeedsConsultation(nextSessions, landingSessionId, bootstrapSelection.urlAction)) { + if (starterHomeLandingNeedsConsultation(inSubjectScope, landingSessionId, bootstrapSelection.urlAction)) { const homeSession = createSession( ready.nextModelCatalog?.defaultModelId ?? "", "consultation", chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], ready.nextProfile), ); - nextSessions = [homeSession, ...nextSessions]; + nextSessions = replaceUnsavedEmptyConsultations(nextSessions, homeSession); landingSessionId = homeSession.id; } const activeListed = nextSessions.find((session) => session.id === landingSessionId) @@ -429,20 +432,17 @@ export async function resolveLanding( deps.commit.setSessions(nextSessions); if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace"); deps.commit.setActiveSessionId(landingSessionId); - if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); + // An older build stashed the last `?c=` here; drop it so it never matters. + clearLoginSessionReturn(); if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); - if (bootstrapSelection.urlAction === "replace-selected") { - const selected = nextSessions.find((session) => session.id === bootstrapSelection.sessionId); - if (!selected || !isUnsavedEmptyConsultation(selected)) { - writeSessionUrl(bootstrapSelection.sessionId, "replace"); - } - } const reservedConsultation = ready.reservedConsultation; const storedPending = ready.storedPending; if (reservedConsultation?.status === "reserved") { const recoverySession = nextSessions.find((session) => session.id === reservedConsultation.sessionId); if (recoverySession) restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending); - if (bootstrapSelection.urlAction === "new-chat") { + // The blank home and an explicit new chat are not taken over; the older + // answer keeps recovering in the background (BUG-1015/1052). + if (!landingYieldsToRecovery(bootstrapSelection.urlAction)) { deps.commit.setActiveSessionId(landingSessionId); deps.setComposerNotice(""); } @@ -521,8 +521,6 @@ export type HomeWarmRefreshDeps = { /** 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: { @@ -556,29 +554,15 @@ function applyWarmCatalog( /** Synchronous part of a warm return: the same landing side effects as `resolveLanding`. */ function commitWarmLanding( deps: HomeWarmRefreshDeps, - io: Pick, + 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); } @@ -623,9 +607,9 @@ export async function runHomeWarmRefresh(deps: HomeWarmRefreshDeps, signal: Abor 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") { + if (!landingYieldsToRecovery(start.landing.selection.urlAction)) { // Same rule as the cold path: recovery continues in the background and - // does not take over an explicit new-chat landing. + // does not take over the blank home or an explicit new chat. deps.commit.setActiveSessionId(start.landing.activeSessionId); deps.setComposerNotice(""); } diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index e672d938..747ff362 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -16,14 +16,17 @@ export type LandingSession = Readonly<{ id: string; sessionType: string; messages: readonly unknown[]; + /** False for a list row whose messages were not loaded; absent means loaded. */ + messagesHydrated?: boolean; + archivedAt?: string | null; }>; -export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat"; +export type LandingUrlAction = "keep" | "replace-clear" | "none" | "lookup" | "new-chat"; /** * A rectification Case is opened only when the address bar names that session - * (`?c=`), including refresh and a login return that restored the URL. Bare `/` - * after login must not open the latest history item in the background. + * (`?c=`), including refresh. Bare `/` after login must not open the latest + * history item in the background. */ export function shouldAutoOpenRectificationSession(input: { readonly sessionType: string | undefined; @@ -34,26 +37,29 @@ export function shouldAutoOpenRectificationSession(input: { return parseSessionUrlQuery(input.search).sessionId === input.sessionId; } +/** An unsaved empty consultation: the blank starter home, not a conversation. */ +export function isStarterHomeDraft(session: LandingSession): boolean { + return session.sessionType === "consultation" + && !session.archivedAt + && session.messagesHydrated !== false + && session.messages.length === 0; +} + /** - * Default `/` landing prefers an empty consultation so the starter home can - * center. Deep links and login-return URLs keep the requested session. + * Bare `/` (and a dead `?c=`) lands on the blank starter home, never on an old + * conversation (BUG-1052; replaces the BUG-599 rule that only swapped away + * from a rectification session and otherwise opened the latest chat). An + * empty draft already in memory is reused; otherwise + * `starterHomeLandingNeedsConsultation` asks for a new local one. `?c=`, + * its lookup and `?new=1` keep the requested session. */ export function resolveStarterHomeLandingSessionId( sessions: readonly LandingSession[], selectedId: string, urlAction: LandingUrlAction, ): string { - if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup" || urlAction === "new-chat") { - return selectedId; - } - const selected = sessions.find((session) => session.id === selectedId); - if (selected?.sessionType !== "birth_time_rectification") return selectedId; - const emptyConsultation = sessions.find( - (session) => session.sessionType === "consultation" && session.messages.length === 0, - ); - if (emptyConsultation) return emptyConsultation.id; - const anyConsultation = sessions.find((session) => session.sessionType === "consultation"); - return anyConsultation?.id ?? selectedId; + if (urlAction === "keep" || urlAction === "lookup" || urlAction === "new-chat") return selectedId; + return sessions.find(isStarterHomeDraft)?.id ?? selectedId; } export function starterHomeLandingNeedsConsultation( @@ -62,9 +68,18 @@ export function starterHomeLandingNeedsConsultation( urlAction: LandingUrlAction, ): boolean { if (urlAction === "new-chat") return true; - if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") return false; + if (urlAction === "keep" || urlAction === "lookup") return false; const landing = sessions.find((session) => session.id === landingId); - return !landing || landing.sessionType === "birth_time_rectification"; + return !landing || !isStarterHomeDraft(landing); +} + +/** + * Whether a background answer being recovered may take the screen. Only when + * the address bar named a conversation; the blank home and an explicit new + * chat stay put while the older answer keeps recovering (BUG-1015/1052). + */ +export function landingYieldsToRecovery(urlAction: LandingUrlAction): boolean { + return urlAction === "keep" || urlAction === "lookup"; } export const HOME_BOOTSTRAP_SLOW_MS = 20_000; diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 1baf4fd7..2329f37b 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -2,9 +2,6 @@ import { writeChatSession } from "@/lib/chat-session-write-contract"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { bootstrapSelectionFromLookup, - parseSessionUrlQuery, - persistLoginSessionReturn, - readLoginSessionReturn, resolveBootstrapSessionSelection, SESSION_LOOKUP_FAILED_NOTICE, type BootstrapSessionSelection, @@ -413,9 +410,10 @@ export class LoginRedirectError extends Error { } export function redirectToLogin(): never { - // Any 401 ends this account's warm return to `/` (BUG-1040). + // Any 401 ends this account's warm return to `/` (BUG-1040). Signing in + // again lands on the blank starter home, not on the conversation that was + // open when the session expired (BUG-1052), so nothing is stashed here. clearHomeWarmSnapshot(); - persistLoginSessionReturn(); window.location.replace("/login"); throw new LoginRedirectError(); } @@ -518,8 +516,6 @@ export async function resolveLookupBootstrap(input: { defaultSessionId: string; catalog: PublicLanguageModelCatalog | null; signal?: AbortSignal; - /** Current person's scope. A stashed return outside it is dropped, not reopened. */ - inSubjectScope?: (session: ChatSession) => boolean; }): Promise<{ selection: BootstrapSessionSelection; sessions: ChatSession[]; @@ -530,24 +526,15 @@ export async function resolveLookupBootstrap(input: { listedIds: input.sessions.map((session) => session.id), defaultSessionId: input.defaultSessionId, search: window.location.search, - storedReturnId: readLoginSessionReturn(), }); if (selection.urlAction !== "lookup") { return { selection, sessions: input.sessions, notice: null }; } - const origin = parseSessionUrlQuery(window.location.search).present ? "url" : "stored-return"; const looked = await lookupSessionById(selection.sessionId, input.catalog, input.signal); throwIfAborted(input.signal); if (looked.status === "found") { - if (origin === "stored-return" && input.inSubjectScope && !input.inSubjectScope(looked.session)) { - return { - selection: bootstrapSelectionFromLookup("other-subject", looked.session.id, input.defaultSessionId, origin), - sessions: input.sessions, - notice: null, - }; - } return { - selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId, origin), + selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId), sessions: mergeHydratedSession(input.sessions, looked.session), notice: null, }; diff --git a/frontend/src/lib/home-warm-start.ts b/frontend/src/lib/home-warm-start.ts index 17e3d715..b6f1ebb3 100644 --- a/frontend/src/lib/home-warm-start.ts +++ b/frontend/src/lib/home-warm-start.ts @@ -6,8 +6,8 @@ * 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, + * path (`?new=1`, `?c=`, otherwise the blank starter home of the current + * person, BUG-1052). 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). */ @@ -15,8 +15,6 @@ 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"; @@ -80,7 +78,6 @@ export type HomeWarmStartInput = Readonly<{ 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; @@ -128,7 +125,6 @@ function warmDaily(accountId: string, subjectId: string, profile: Profile | null export function resolveWarmLanding(input: { sessions: readonly ChatSession[]; search: string; - storedReturnId: string | null; inSubjectScope: (session: ChatSession) => boolean; createHomeSession: () => ChatSession; }): WarmLanding | null { @@ -140,21 +136,12 @@ export function resolveWarmLanding(input: { } const known = pendingSession ? [pendingSession, ...input.sessions] : [...input.sessions]; const defaultSessionId = scoped[0].id; - let selection = resolveBootstrapSessionSelection({ + const 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(); @@ -178,7 +165,6 @@ export function resolveHomeWarmStart(input: HomeWarmStartInput): HomeWarmStart | const landing = resolveWarmLanding({ sessions: input.sessions, search, - storedReturnId: input.storedReturnId, inSubjectScope: (session) => sessionMatchesSubject(session, input.subjectId), createHomeSession: () => createSession( catalog.defaultModelId, @@ -232,7 +218,6 @@ export function homeWarmStartInput(list: Pick { assert.equal(parseNewChatIntent("?renew=1"), false); }); -test("new chat intent overrides listed, unlisted and invalid c plus login return", () => { +test("new chat intent overrides listed, unlisted and invalid c", () => { for (const requestedId of [sessionB, sessionC, "not-a-uuid"]) { + // 原值: 入参带 storedReturnId: sessionB,期望对象含 clearStoredReturn: true。 + // 新值: 没有 storedReturnId 入参,期望对象没有 clearStoredReturn 字段;new-chat 优先级不变。 + // 原因: BUG-1052 D3 删除登录返回存根,选择函数不再读它,也不再决定是否清它(启动一律清旧值)。 assert.deepEqual(resolveBootstrapSessionSelection({ listedIds: [sessionA, sessionB], defaultSessionId: sessionA, search: `?new=1&c=${requestedId}`, - storedReturnId: sessionB, }), { sessionId: sessionA, urlAction: "new-chat", missing: false, - clearStoredReturn: true, }); } }); @@ -79,10 +79,16 @@ test("new chat bootstrap consumes the URL before activation and reuses local-onl const clearIntent = 'if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");'; assert.ok(bootstrap.includes(clearIntent)); assert.ok(bootstrap.indexOf(clearIntent) < bootstrap.indexOf("setActiveSessionId(landingSessionId)")); - assert.match(bootstrap, /if \(bootstrapSelection\.clearStoredReturn\) clearLoginSessionReturn\(\)/); + // 原值: /if \(bootstrapSelection\.clearStoredReturn\) clearLoginSessionReturn\(\)/(按选择结果决定清存根)。 + // 新值: 无条件 `clearLoginSessionReturn();`,只为清掉旧版本留下的值。 + // 原因: BUG-1052 D3 存根不再写入也不再读取,启动时清一次旧值即可。 + assert.match(bootstrap, /\n clearLoginSessionReturn\(\);\n/); const landing = sourceBetween(bootstrap, "if (starterHomeLandingNeedsConsultation(", "const activeListed"); assert.equal((landing.match(/createSession\(/g) ?? []).length, 1); - assert.match(landing, /nextSessions = \[homeSession, \.\.\.nextSessions\]/); + // 原值: /nextSessions = \[homeSession, \.\.\.nextSessions\]/(直接前插)。 + // 新值: replaceUnsavedEmptyConsultations(nextSessions, homeSession)(前插并去掉其他未落库空对话)。 + // 原因: BUG-1052 D1 复用/不重复空草稿,与 startNewChat 同一个去重函数;仍只本地创建一条。 + assert.match(landing, /nextSessions = replaceUnsavedEmptyConsultations\(nextSessions, homeSession\)/); assert.match(landing, /landingSessionId = homeSession\.id/); assert.doesNotMatch(landing, /persistSession|writeChatSession|fetch\(|writeSessionUrl/); }); @@ -99,38 +105,43 @@ test("there is one new-chat intent: /people「和 TA 对话」 links through new } }); -test("bootstrap reads a listed ?c= session and ignores login storage", () => { +test("bootstrap reads a listed ?c= session", () => { + // 原值: 入参 storedReturnId: sessionC,期望含 clearStoredReturn: true;标题 "…and ignores login storage"。 + // 新值: 无 storedReturnId 入参与 clearStoredReturn 字段;?c= 仍 keep。 + // 原因: BUG-1052 D2 `?c=` 行为不变;D3 存根已删除,没有可忽略的存储。 assert.deepEqual( resolveBootstrapSessionSelection({ listedIds: [sessionA, sessionB], defaultSessionId: sessionA, search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`, - storedReturnId: sessionC, }), { sessionId: sessionB, urlAction: "keep", missing: false, - clearStoredReturn: true, }, ); }); -test("bootstrap restores a stored login return only when the URL has no c", () => { +test("bare / after login never returns to a stored conversation (BUG-1052)", () => { + // 原值(BUG-1038 起): 标题 "bootstrap restores a stored login return only when the URL has no c", + // storedReturnId: sessionB + search "" → { sessionId: sessionB, urlAction: "replace-selected", clearStoredReturn: true }。 + // 新值: 选择函数没有存根入参;裸 `/` 一律 { sessionId: 默认, urlAction: "none" },落点再换成空白首页。 + // 原因: 产品 2026-09-27 决策 D3——401 / 重新登录不再回到旧对话(真机:登录后落在旧生时校正里提问)。 assert.deepEqual( resolveBootstrapSessionSelection({ listedIds: [sessionA, sessionB], defaultSessionId: sessionA, search: "", - storedReturnId: sessionB, }), { - sessionId: sessionB, - urlAction: "replace-selected", + sessionId: sessionA, + urlAction: "none", missing: false, - clearStoredReturn: true, }, ); + const lib = readFileSync(new URL("../src/lib/chat-session-url.ts", import.meta.url), "utf8"); + assert.doesNotMatch(lib, /persistLoginSessionReturn|readLoginSessionReturn|storedReturnId|replace-selected/); }); test("bootstrap clears an illegal or unknown session query", () => { @@ -144,13 +155,14 @@ test("bootstrap clears an illegal or unknown session query", () => { listedIds: [sessionA], defaultSessionId: sessionA, search: `?${SESSION_URL_QUERY_KEY}=not-a-uuid`, - storedReturnId: sessionB, }), + // 原值: 入参带 storedReturnId: sessionB,期望含 clearStoredReturn: true。 + // 新值: 去掉这两项;非法 ?c= 仍 replace-clear + missing。 + // 原因: BUG-1052 D3 删除存根字段。 { sessionId: sessionA, urlAction: "replace-clear", missing: true, - clearStoredReturn: true, }, ); // 原值: 未出现在已加载页的合法 UUID 直接 missing + replace-clear @@ -161,13 +173,12 @@ test("bootstrap clears an illegal or unknown session query", () => { listedIds: [sessionA], defaultSessionId: sessionA, search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`, - storedReturnId: null, }), + // 原值: 入参 storedReturnId: null,期望含 clearStoredReturn: false。新值: 去掉这两项。原因: BUG-1052 D3。 { sessionId: sessionB, urlAction: "lookup", missing: false, - clearStoredReturn: false, }, ); assert.equal(SESSION_LOOKUP_FAILED_NOTICE.includes("暂时读不到"), true); @@ -179,13 +190,12 @@ test("default bootstrap selection does not write a session URL", () => { listedIds: [sessionA, sessionB], defaultSessionId: sessionA, search: "", - storedReturnId: null, }), + // 原值: 入参 storedReturnId: null,期望含 clearStoredReturn: false。新值: 去掉这两项。原因: BUG-1052 D3。 { sessionId: sessionA, urlAction: "none", missing: false, - clearStoredReturn: false, }, ); @@ -196,7 +206,10 @@ test("default bootstrap selection does not write a session URL", () => { assert.match(bootstrap, /defaultSessionId: ready\.nextSessions\[0\]\.id/); assert.match(bootstrap, /setActiveSessionId\(landingSessionId\)/); assert.match(bootstrap, /urlAction === "replace-clear"/); - assert.match(bootstrap, /urlAction === "replace-selected"/); + // 原值: assert.match(bootstrap, /urlAction === "replace-selected"/)(存根落点写回 ?c=)。 + // 新值: 反向断言不再出现。 + // 原因: BUG-1052 D3 存根删除后 replace-selected 只剩存根一个来源,已随之删除。 + assert.doesNotMatch(bootstrap, /replace-selected/); assert.doesNotMatch(bootstrap, /writeSessionUrl\([^)]*, "push"\)/); assert.match(bootstrap, /setComposerNotice\(SESSION_MISSING_NOTICE\)/); assert.match(bootstrap, /resolveLookupBootstrap/); @@ -275,12 +288,12 @@ test("creating and leaving a session keep the address bar in sync", () => { assert.doesNotMatch(recovery, /writeSessionUrl/); }); -test("401 login redirect stashes the current session id without changing the login page", () => { +test("401 login redirect stashes nothing and the login page still returns to bare /", () => { const redirect = sourceBetween(page, "function redirectToLogin(): never {", "function waitForUndoWindow"); - // Former value: the body was only `window.location.replace("/login")`. - // 401 now stashes a UUID ?c= so returning to `/` can restore the session - // without adding a `next` query to the login page. - assert.match(redirect, /persistLoginSessionReturn\(\);/); + // 原值: 标题 "401 login redirect stashes the current session id …",assert.match(redirect, /persistLoginSessionReturn\(\);/)。 + // 新值: 反向断言 redirect 里不再有任何存根写入;登录页仍只回 `/`。 + // 原因: BUG-1052 D3——重新登录落在空白首页,不再回到 401 时所在的对话。 + assert.doesNotMatch(redirect, /persistLoginSessionReturn|sessionStorage/); assert.match(redirect, /window\.location\.replace\("\/login"\);/); assert.doesNotMatch(login, /sessionStorage|SESSION_URL_RETURN|searchParams\.get\("next"\)/); assert.match(login, /successPath\?: "\/" \| "\/admin"/); @@ -319,8 +332,12 @@ test("home stays a client-read query on a static route", () => { assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除"); }); -test("login return storage only accepts a UUID session id", () => { - const memory = new Map(); +test("a leftover login-return stash from an older build is only ever cleared", () => { + // 原值: 标题 "login return storage only accepts a UUID session id":persistLoginSessionReturn() 写入后 + // readLoginSessionReturn() === sessionA,非法值读回 null。 + // 新值: 写 / 读两个函数已删除;旧浏览器里残留的值由 clearLoginSessionReturn() 清掉,且清除在隐私模式抛错时不影响启动。 + // 原因: BUG-1052 D3——删除存根写入与读取,只保留一次性清理旧值。 + const memory = new Map([[SESSION_URL_RETURN_STORAGE_KEY, sessionA]]); const previousWindow = globalThis.window; Object.defineProperty(globalThis, "window", { configurable: true, @@ -337,10 +354,13 @@ test("login return storage only accepts a UUID session id", () => { }, }); try { - persistLoginSessionReturn(); - assert.equal(readLoginSessionReturn(), sessionA); - memory.set(SESSION_URL_RETURN_STORAGE_KEY, "not-a-uuid"); - assert.equal(readLoginSessionReturn(), null); + clearLoginSessionReturn(); + assert.equal(memory.has(SESSION_URL_RETURN_STORAGE_KEY), false); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: { removeItem() { throw new Error("private mode"); } }, + }); + assert.doesNotThrow(() => clearLoginSessionReturn()); } finally { if (previousWindow === undefined) { Reflect.deleteProperty(globalThis, "window"); diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index 79aef329..e3facbae 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -217,7 +217,7 @@ test("tab-local pending ids drive strict bootstrap recovery before the global fa status: "reserved", }), resolveLookupBootstrap: async (input) => ({ - selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false }, + selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null, }), diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index f5c2a0e5..26644d17 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -9,6 +9,7 @@ import { bootstrapLoadingCopy, bootstrapPrepareSettled, bootstrapRevealDelayMs, + landingYieldsToRecovery, resolveStarterHomeLandingSessionId, sessionIdsToPrefetch, shouldAutoOpenRectificationSession, @@ -64,8 +65,14 @@ test("default landing replaces a latest rectification session with an empty cons assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "none"), emptyConsultationId); assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-clear"), emptyConsultationId); assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "keep"), rectificationId); - assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-selected"), rectificationId); - assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId); + // 原值: resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-selected") === rectificationId。 + // 新值: 该 urlAction 已删除,改为断言 lookup(`?c=` 查到后)同样保留所选会话。 + // 原因: BUG-1052 D3 删除登录返回存根,replace-selected 只服务存根。 + assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "lookup"), rectificationId); + // 原值: resolveStarterHomeLandingSessionId(sessions, consultationId, "none") === consultationId(裸 `/` 打开最近普通对话)。 + // 新值: === emptyConsultationId(裸 `/` 落空白首页,复用已有空草稿)。 + // 原因: BUG-1052 D1 产品决策——裸 `/` 永远是空白首页,不落旧对话。 + assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), emptyConsultationId); assert.equal( starterHomeLandingNeedsConsultation( [{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] }], @@ -103,10 +110,40 @@ test("new chat landing requests a fresh consultation even with an existing ordin assert.equal(starterHomeLandingNeedsConsultation([], consultationId, "new-chat"), true); }); -test("bare home still opens the selected ordinary chat without creating another", () => { +test("bare home with a latest ordinary chat lands on a new blank starter home (BUG-1052)", () => { + // 原值: 标题 "bare home still opens the selected ordinary chat without creating another"; + // none → consultationId,NeedsConsultation === false。 + // 新值: none → 仍返回 consultationId(没有空草稿可复用),但 NeedsConsultation === true,启动会本地新建空对话落地。 + // 原因: BUG-1052 D1——登录后 / 地址栏 / 书签 / 刷新裸 `/` 都落空白首页,不再打开最近一条旧对话。 const sessions = [{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }]; assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId); - assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), false); + assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), true); +}); + +test("a list row whose messages were not loaded is not mistaken for the blank home", () => { + // The session list can omit messages (messagesHydrated false). Such a row is + // a saved conversation, not an empty draft, so bare `/` must not land on it. + const sessions = [ + { id: consultationId, sessionType: "consultation", messages: [], messagesHydrated: false }, + { id: rectificationId, sessionType: "birth_time_rectification", messages: [] }, + ]; + assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId); + assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), true); + assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "none"), rectificationId); + assert.equal(starterHomeLandingNeedsConsultation(sessions, rectificationId, "none"), true); + // An archived empty row is not a draft either. + const archived = [{ id: emptyConsultationId, sessionType: "consultation", messages: [], archivedAt: "2026-09-01T00:00:00Z" }]; + assert.equal(starterHomeLandingNeedsConsultation(archived, emptyConsultationId, "none"), true); + // A dead `?c=` (replace-clear) lands on the blank home too. + assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "replace-clear"), true); +}); + +test("a background answer takes the screen only when the address bar named a conversation", () => { + assert.equal(landingYieldsToRecovery("keep"), true); + assert.equal(landingYieldsToRecovery("lookup"), true); + assert.equal(landingYieldsToRecovery("none"), false); + assert.equal(landingYieldsToRecovery("replace-clear"), false); + assert.equal(landingYieldsToRecovery("new-chat"), false); }); test("prepare phase settles only when every applicable item has an answer", () => { diff --git a/frontend/tests/home-bootstrap-run.test.ts b/frontend/tests/home-bootstrap-run.test.ts index e0aee8a7..037fdc5e 100644 --- a/frontend/tests/home-bootstrap-run.test.ts +++ b/frontend/tests/home-bootstrap-run.test.ts @@ -138,7 +138,6 @@ function createHarness(options: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, - clearStoredReturn: false, }, sessions: input.sessions, notice: null, @@ -173,7 +172,6 @@ test("a successful bootstrap fills the home once, after every read has settled", sessionId: input.defaultSessionId, urlAction: "none", missing: false, - clearStoredReturn: false, }, sessions: input.sessions, notice: null, @@ -350,7 +348,7 @@ test("a catalog outage is a recoverable bootstrap error, never a login redirect" test("standard new=1 bootstrap creates a conversation bound to the selected person", async () => { const { deps, state } = createHarness({ sessions: [], io: { resolveLookupBootstrap: async input => ({ - selection: { sessionId: input.defaultSessionId, urlAction: "new-chat", missing: false, clearStoredReturn: false }, + selection: { sessionId: input.defaultSessionId, urlAction: "new-chat", missing: false }, sessions: input.sessions, notice: null, }), writeSessionUrl: () => {}, clearLoginSessionReturn: () => {}, diff --git a/frontend/tests/home-warm-return-lifecycle.test.tsx b/frontend/tests/home-warm-return-lifecycle.test.tsx index 459063b7..bdff969d 100644 --- a/frontend/tests/home-warm-return-lifecycle.test.tsx +++ b/frontend/tests/home-warm-return-lifecycle.test.tsx @@ -549,7 +549,11 @@ test("background refresh: a changed rectification entry summary replaces the car } }); -test("a stashed return to a rectification session reopens it on a warm return, with its URL", async () => { +test("a warm return to bare / after a rectification session lands on the blank starter home, not back in it", async () => { + // 原值(BUG-1038/1040): 标题 "a stashed return to a rectification session reopens it on a warm return, with its URL"; + // 离开时存根 === RECT → 账户页脚回裸 `/` → 地址栏写回 `/?c=RECT`、校正面重新打开、存根清空。 + // 新值: 离开时不存根(null)→ 账户页脚回裸 `/` → 首帧无加载环、就是空白首页「新对话」,地址栏仍是 `/`,不打开校正面。 + // 原因: BUG-1052 D1(裸 `/` 永远空白首页)+ D3(删除存根);暖路径与冷路径同规则,BUG-1040「无加载环」照旧锁住。 const tab = createTab(); const api = installApi(); let doc: Doc | undefined; @@ -557,13 +561,36 @@ test("a stashed return to a rectification session reopens it on a warm return, w 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. + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null); + // The read-only footer links to bare `/`. 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.equal(doc.firstCommits[1].title, "新对话", "the blank starter home is the first frame"); + for (let i = 0; i < 5; i += 1) await doc.h.idle(); + const view = doc.view(); + assert.equal(view.url, "/"); + assert.equal(view.title, "新对话"); + assert.equal(view.starter, true); + assert.equal(view.rectificationSurface, false); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + api.restore(); + } +}); + +test("a cold load of bare / with an old conversation and a rectification in the list shows the blank starter home", async () => { + const tab = createTab(); + const api = installApi(); + let doc: Doc | undefined; + try { + doc = await coldHome(tab, api); + const view = doc.view(); + assert.equal(view.title, "新对话"); + assert.equal(view.starter, true); + assert.equal(view.transcript, false); + assert.equal(view.url, "/"); + assert.equal(view.rectificationSurface, false); assert.deepEqual(doc.errors, []); } finally { await doc?.close(); diff --git a/frontend/tests/home-warm-snapshot.test.ts b/frontend/tests/home-warm-snapshot.test.ts index a668977e..6f29b9b1 100644 --- a/frontend/tests/home-warm-snapshot.test.ts +++ b/frontend/tests/home-warm-snapshot.test.ts @@ -61,7 +61,7 @@ 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, + storedPendingRaw: null, subjectId: "self", subjectCatalog: [], previewRequested: false, now: new Date("2026-09-26T04:00:00Z"), ...patch, }; } @@ -199,45 +199,58 @@ test("warm start is refused whenever a piece is missing", () => { }); }); -test("warm landing follows the cold rules: ?c= keeps, stash writes back, other person's stash is dropped", () => { +test("warm landing follows the cold rules: ?c= keeps, bare / is the blank starter home, ?new=1 is a new chat", () => { + // 原值(BUG-1038/1040): 标题 "warm landing follows the cold rules: ?c= keeps, stash writes back, other person's stash is dropped"; + // stash(RECT) → activeSessionId RECT + replace-selected + clearStoredReturn;foreign stash → none + clearStoredReturn; + // bare → CONS("bare / on a rectification head lands on a consultation, as cold"); + // newChat 带 stash → clearStoredReturn true;不在内存的 stash → null(回冷启动查)。 + // 新值: resolveWarmLanding 没有 storedReturnId 入参;bare → 本地新建空对话(pendingSession),不是 CONS / RECT; + // 已有同一人物的空草稿时复用它、不新建;另一人物的空草稿不复用;?c= keep 与 ?new=1 不变。 + // 原因: BUG-1052 D1(裸 `/` 永远空白首页、复用空草稿)+ D3(删除登录返回存根);暖路径与冷路径同一套规则。 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 }); + const keep = resolveWarmLanding({ sessions: list, search: `?c=${CONS}`, 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 keepRect = resolveWarmLanding({ sessions: list, search: `?c=${RECT}`, inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(keepRect?.activeSessionId, RECT, "a ?c= rectification link still opens it (D2)"); - 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: "", inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(bare?.activeSessionId, "new-local", "bare / lands on the blank starter home, not the latest chat"); + assert.equal(bare?.pendingSession?.id, "new-local"); + assert.equal(bare?.selection.urlAction, "none"); - 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 consHead = resolveWarmLanding({ sessions: [session(CONS), rect], search: "", inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(consHead?.activeSessionId, "new-local", "a latest ordinary chat with messages is not the landing either"); - const newChat = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create }); + const draft = session("self-draft", { messages: [], title: "新对话" }); + const foreignDraft = session("other-draft", { messages: [], title: "新对话", chartProfileId: PERSON, chartProfileRole: "other" }); + const reuse = resolveWarmLanding({ sessions: [foreignDraft, rect, draft, session(CONS)], search: "", inSubjectScope: scope("self"), createHomeSession: create }); + assert.equal(reuse?.activeSessionId, "self-draft", "an empty draft of the current person is reused"); + assert.equal(reuse?.pendingSession, null, "no duplicate draft is created"); + + const newChat = resolveWarmLanding({ sessions: list, search: "?new=1", 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"); + assert.equal(newChat?.selection.urlAction, "new-chat"); - const emptyPerson = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: null, inSubjectScope: scope("nobody"), createHomeSession: create }); + const emptyPerson = resolveWarmLanding({ sessions: list, search: "?new=1", inSubjectScope: scope("nobody"), createHomeSession: create }); assert.equal(emptyPerson?.activeSessionId, "new-local", "a person with no sessions gets exactly one local chat"); + const emptyPersonBare = resolveWarmLanding({ sessions: list, search: "", inSubjectScope: scope("nobody"), createHomeSession: create }); + assert.equal(emptyPersonBare?.activeSessionId, "new-local"); - 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"); + assert.equal(resolveWarmLanding({ sessions: list, search: "?c=99999999-9999-4999-8999-999999999999", inSubjectScope: scope("self"), createHomeSession: create }), null, "a ?c= 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 landing = { activeSessionId: pending.id, pendingSession: pending, selection: { sessionId: pending.id, urlAction: "new-chat" as const, missing: false } }; const next = applyWarmLanding([oldEmpty, session(CONS)], landing); assert.deepEqual(next.map((item) => item.id), ["new-local", CONS]); const same = [pending, session(CONS)]; diff --git a/frontend/tests/new-chat-from-people-lifecycle.test.tsx b/frontend/tests/new-chat-from-people-lifecycle.test.tsx index dc851c96..1c848df2 100644 --- a/frontend/tests/new-chat-from-people-lifecycle.test.tsx +++ b/frontend/tests/new-chat-from-people-lifecycle.test.tsx @@ -8,8 +8,9 @@ Object.assign(globalThis, { React }); // BUG-1038. Real React lifecycle, not source assertions: the real Home page, // the real sidebar and the real /people page, composed like app/(app)/layout. -// The state that survives between pages is the sessionStorage login-return -// stash a secondary-page link writes (`jyotisha.session-url-return`). +// Until BUG-1052 the state that survived between pages was the sessionStorage +// login-return stash a secondary-page link wrote (`jyotisha.session-url-return`); +// that stash is gone and a bare `/` lands on the blank starter home. // Synthetic ids and profiles only (no real person data). const USER = "11111111-1111-4111-8111-111111111111"; @@ -343,9 +344,10 @@ for (const page of [ if (page.mobile) await doc.openDrawer(); await doc.followLink(page.label); assert.equal(tab.location.pathname, page.path); - // The secondary link stashes the session it left; this is the state that - // must not survive into the new chat. - assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT); + // 原值: assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT)(次级页链接存下离开的会话)。 + // 新值: === null,离开首页什么都不存。 + // 原因: BUG-1052 D3 删除登录返回存根;本用例要守的「新建对话不回旧校正」由下方断言继续锁住。 + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null); if (page.path === "/people") { await doc.settle(() => doc!.h.elements().some((node) => node.text.includes("和 TA 对话")), "the people page"); } @@ -419,7 +421,8 @@ test("a stashed rectification session of the person you left is not reopened as let doc: Awaited> | undefined; try { doc = await openRectificationThenGoTo(tab, api, "星盘档案"); - assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT); + // 原值: === RECT(离开时存根)。新值: === null。原因: BUG-1052 D3 不再存根;落点断言不变。 + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null); await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "BUTTON" && node.text.includes("阿澈")), "the people list"); await doc.h.event(doc.button("阿澈")); await doc.h.event(doc.button("看星盘")); @@ -441,7 +444,7 @@ test("a stashed rectification session of the person you left is not reopened as assert.equal(view.title, "新对话"); assert.ok(view.chartName.includes("阿澈"), `lands in the current person's chat: ${view.chartName}`); assert.equal(api.opens(), opensBefore, "the other person's rectification session is not opened"); - assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "the out-of-scope stash is dropped"); + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "nothing is stashed"); assert.deepEqual(doc.errors, []); } finally { await doc?.close(); @@ -449,21 +452,26 @@ test("a stashed rectification session of the person you left is not reopened as } }); -test("a stashed session of the same person found beyond the first page opens with its URL, not as a locked page", async () => { - // The stash id is not in the listed page, so bootstrap looks it up. Found - // used to mean "keep" — but the address bar had no ?c=, so the - // rectification auto-open never fired and the composer stayed locked. +test("a leftover stash from an older build is ignored and cleared: bare / lands on the blank starter home (BUG-1052)", async () => { + // 原值(BUG-1038): 标题 "a stashed session of the same person found beyond the first page opens with its URL, not as a locked page"; + // 存根 RECT 不在首屏列表 → lookup 找到 → 打开校正面、地址栏写回 `/?c=RECT`、opens === 1、存根清空。 + // 新值: 同样的残留存根 + 裸 `/` → 空白首页「新对话」,不查这条会话、不打开校正,地址栏仍是 `/`,残留值被清掉。 + // 原因: BUG-1052 D3 删除登录返回存根(旧浏览器里的残留值只清不读);D1 裸 `/` 永远空白首页。 const tab = createTab(); tab.session.setItem("jyotisha.session-url-return", RECT); const { api, restore } = installApi({ listRectification: false }); let doc: Awaited> | undefined; try { doc = await openDocument(tab, "/"); - await doc.settle(() => doc!.view().rectificationSurface, "the stashed rectification session to open"); + await doc.settle(() => doc!.view().title !== null, "home to reveal"); + for (let i = 0; i < 10; i += 1) await doc.h.idle(); const view = doc.view(); - assert.equal(view.title, "生时校正 · 04:45–05:15"); - assert.equal(view.url, `/?c=${RECT}`); - assert.equal(api.opens(), 1); + assert.equal(view.title, "新对话"); + assert.equal(view.url, "/"); + assert.equal(view.rectificationSurface, false); + assert.equal(view.inputDisabled, false); + assert.equal(api.opens(), 0); + assert.equal(api.requests.includes(`GET /api/sessions/${RECT}`), false, "the stashed id is not looked up"); assert.equal(tab.session.getItem("jyotisha.session-url-return"), null); assert.deepEqual(doc.errors, []); } finally { @@ -472,13 +480,15 @@ test("a stashed session of the same person found beyond the first page opens wit } }); -test("an in-page new chat drops the stashed return, so a later bare / does not reopen the old session", async () => { +test("an in-page new chat leaves no return trail, so a later bare / cannot reopen the old session", async () => { + // 原值: 标题 "an in-page new chat drops the stashed return, …";预置存根 RECT,startNewChat 后断言存根被清成 null。 + // 新值: 不预置存根,断言 startNewChat 前后这把钥匙都不存在(新建对话不写任何返回记录)。 + // 原因: BUG-1052 D3 删除存根后 startNewChat 不再负责清它(启动时统一清旧值,见上一条用例)。 const { useRef, useState } = React; const { useSessionManagement } = await import("../src/hooks/use-session-management.ts"); const { previewModelCatalog, emptyProfile } = await import("../src/lib/home-types.ts"); type Params = Parameters[0]; const tab = createTab(); - tab.session.setItem("jyotisha.session-url-return", RECT); const h = createClientLifecycleHarness(); const previous = { sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"), @@ -520,3 +530,116 @@ test("an in-page new chat drops the stashed return, so a later bare / does not r } } }); + +// BUG-1052 (product decision 2026-09-27). Real-device incident: after signing +// in, a user typed a question into what looked like the home page but was the +// previous birth-time rectification conversation. A full load of `/` without +// `?c=` now always lands on the blank starter home; `?c=` still opens its +// conversation, and a refresh inside a conversation keeps it. + +const hasText = (doc: Awaited>, text: string) => doc.h.elements().some((node) => node.text.includes(text)); + +for (const variant of [ + { label: "an ordinary conversation with messages", listRectification: false }, + { label: "a birth-time rectification", listRectification: true }, +]) { + test(`bare / whose most recent session is ${variant.label} lands on the blank starter home`, async () => { + const tab = createTab(); + const { api, restore } = installApi({ listRectification: variant.listRectification }); + let doc: Awaited> | undefined; + try { + doc = await openDocument(tab, "/"); + await doc.settle(() => doc!.view().title !== null, "home to reveal"); + for (let i = 0; i < 10; i += 1) await doc.h.idle(); + const view = doc.view(); + assert.equal(view.title, "新对话"); + assert.equal(view.url, "/", "no old session id is written into the URL"); + assert.equal(view.rectificationSurface, false); + assert.equal(view.inputDisabled, false); + assert.notEqual(view.placeholder, "正在打开生时校正…"); + assert.equal(hasText(doc, "虚构回答"), false, "the old conversation's transcript is not shown"); + assert.equal(api.opens(), 0, "no rectification case is opened"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + restore(); + } + }); +} + +test("/?c= still opens that conversation, and a refresh keeps it", async () => { + const tab = createTab(); + const { api, restore } = installApi(); + let doc: Awaited> | undefined; + try { + doc = await openDocument(tab, `/?c=${CONS}`); + await doc.settle(() => doc!.view().title === "事业方向" && hasText(doc!, "虚构回答"), "the linked conversation"); + assert.equal(doc.view().url, `/?c=${CONS}`); + await doc.close(); + doc = undefined; + // Refresh: same tab, same address, a new document. + doc = await openDocument(tab, `/?c=${CONS}`); + await doc.settle(() => doc!.view().title === "事业方向" && hasText(doc!, "虚构回答"), "the same conversation after refresh"); + assert.equal(doc.view().url, `/?c=${CONS}`); + assert.equal(api.opens(), 0); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + restore(); + } +}); + +test("a refresh inside a rectification conversation stays in it (its ?c= is in the address bar)", async () => { + const tab = createTab(); + const { api, restore } = installApi(); + let doc: Awaited> | undefined; + try { + doc = await openDocument(tab, `/?c=${RECT}`); + await doc.settle(() => doc!.view().rectificationSurface, "the rectification session to open"); + const address = doc.view().url; + assert.equal(address, `/?c=${RECT}`); + await doc.close(); + doc = undefined; + doc = await openDocument(tab, address); + await doc.settle(() => doc!.view().rectificationSurface, "the rectification session after refresh"); + assert.equal(doc.view().title, "生时校正 · 04:45–05:15"); + assert.equal(doc.view().url, `/?c=${RECT}`); + assert.equal(api.opens(), 2); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + restore(); + } +}); + +test("a 401 inside a rectification conversation, then signing in again, lands on the blank starter home", async () => { + const tab = createTab(); + const { api, restore } = installApi(); + let doc: Awaited> | undefined; + try { + doc = await openDocument(tab, `/?c=${RECT}`); + await doc.settle(() => doc!.view().rectificationSurface, "the rectification session to open"); + // The session expires: any 401 goes through redirectToLogin. + const { redirectToLogin, LoginRedirectError } = await import("../src/lib/home-cloud-sync.ts"); + assert.throws(() => redirectToLogin(), LoginRedirectError); + assert.equal(tab.location.assigned.at(-1), "/login"); + assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "nothing is stashed for the way back"); + await doc.close(); + doc = undefined; + const opensBefore = api.opens(); + // email-otp-login's successPath is "/": a document load of bare `/`. + doc = await openDocument(tab, "/"); + await doc.settle(() => doc!.view().title !== null, "home to reveal after sign-in"); + for (let i = 0; i < 10; i += 1) await doc.h.idle(); + const view = doc.view(); + assert.equal(view.title, "新对话"); + assert.equal(view.url, "/"); + assert.equal(view.rectificationSurface, false); + assert.equal(view.inputDisabled, false); + assert.equal(api.opens(), opensBefore, "the rectification conversation is not reopened"); + assert.deepEqual(doc.errors, []); + } finally { + await doc?.close(); + restore(); + } +}); diff --git a/frontend/tests/new-chat-recovery.test.ts b/frontend/tests/new-chat-recovery.test.ts index 8405fe1c..16ef1b0c 100644 --- a/frontend/tests/new-chat-recovery.test.ts +++ b/frontend/tests/new-chat-recovery.test.ts @@ -61,7 +61,7 @@ function account(): Account { }; } -async function bootstrapWithRecovery(urlAction: "new-chat" | "none") { +async function bootstrapWithRecovery(urlAction: "new-chat" | "none" | "keep") { // 原值:把 page.tsx 激活段和 hook 里的 restore 一起塞进 vm。 // 新值:直接调用 runHomeBootstrap,restore 仍是 hook 里的真实函数。 // 原因:启动流程已搬出 page.tsx;调用整段函数还覆盖 new-chat 会新建空咨询。 @@ -155,7 +155,6 @@ async function bootstrapWithRecovery(urlAction: "new-chat" | "none") { sessionId: localSession.id, urlAction, missing: false, - clearStoredReturn: urlAction === "new-chat", }, sessions: input.sessions, notice: null, @@ -185,11 +184,27 @@ test("new chat keeps its local landing while a reserved older consultation recov assert.equal(pending.current?.phase, "recovering"); }); -test("ordinary home landing still activates and announces the reserved consultation", async () => { - const { state, pending, writes, oldSession } = await bootstrapWithRecovery("none"); +test("bare / keeps the blank starter home while a reserved older consultation recovers (BUG-1052)", async () => { + // 原值: 标题 "ordinary home landing still activates and announces the reserved consultation"; + // none → state.active === oldSession.id,notice「回答仍在后台生成,正在自动恢复。」,storedCleared === false。 + // 新值: none → 停在空白首页(本地空对话 localSession),notice 清空,恢复照常在后台进行;storedCleared === true。 + // 原因: BUG-1052 D1——裸 `/` 永远落空白首页、不落旧对话,与 BUG-1015 的 ?new=1 同一条「不抢落点」规则; + // D3——启动一律清掉旧版本留下的存根值。「地址栏点名会话时恢复可以接管」改由下一条 keep 用例锁住。 + const { state, pending, writes, localSession, oldSession } = await bootstrapWithRecovery("none"); + assert.equal(state.active, localSession.id); + assert.equal(isUnsavedEmptyConsultation(state.sessions.find((item) => item.id === state.active)!), true); + assert.equal(state.notice, ""); + assert.equal(state.storedCleared, true); + assert.deepEqual(writes, []); + assert.equal(state.pendingSession, oldSession.id); + assert.equal(state.phase, "recovering"); + assert.equal(pending.current?.sessionId, oldSession.id); +}); + +test("a ?c= landing still activates and announces the reserved consultation", async () => { + const { state, pending, writes, oldSession } = await bootstrapWithRecovery("keep"); assert.equal(state.active, oldSession.id); assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。"); - assert.equal(state.storedCleared, false); assert.deepEqual(writes, []); assert.equal(pending.current?.sessionId, oldSession.id); }); diff --git a/frontend/tests/session-list-lifecycle.test.tsx b/frontend/tests/session-list-lifecycle.test.tsx index 1b24911b..9ed6d8cf 100644 --- a/frontend/tests/session-list-lifecycle.test.tsx +++ b/frontend/tests/session-list-lifecycle.test.tsx @@ -273,7 +273,7 @@ test("Home connects to the real provider current generation during person-switch fetchModelCatalog: async () => previewModelCatalog, fetchActiveConsultationStatus: async () => null, redirectToLogin: () => { redirects += 1; throw new Error("unexpected redirect"); }, resolveLookupBootstrap: async input => ({ - selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false }, + selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null, }), writeSessionUrl: noop, clearLoginSessionReturn: noop, clearStaleClientReload: noop, diff --git a/frontend/tests/session-lookup-unlisted.test.ts b/frontend/tests/session-lookup-unlisted.test.ts index 85b3b83a..17a41a88 100644 --- a/frontend/tests/session-lookup-unlisted.test.ts +++ b/frontend/tests/session-lookup-unlisted.test.ts @@ -19,7 +19,6 @@ test("an unlisted ?c= UUID must look up the server instead of announcing deletio listedIds: [sessionA], defaultSessionId: sessionA, search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`, - storedReturnId: null, }); assert.equal(selection.urlAction, "lookup"); assert.equal(selection.missing, false); @@ -43,42 +42,40 @@ test("an unlisted ?c= UUID must look up the server instead of announcing deletio }); test("lookup results: found keeps URL, 404 deletes, 5xx keeps URL", () => { + // 原值: 三个期望对象各带 clearStoredReturn(true / true / false)。 + // 新值: 去掉 clearStoredReturn,其余字段不变。 + // 原因: BUG-1052 D3 删除登录返回存根,选择结果不再携带清存根标记。 assert.deepEqual(bootstrapSelectionFromLookup("found", sessionB, sessionA), { sessionId: sessionB, urlAction: "keep", missing: false, - clearStoredReturn: true, }); assert.deepEqual(bootstrapSelectionFromLookup("missing", sessionB, sessionA), { sessionId: sessionA, urlAction: "replace-clear", missing: true, - clearStoredReturn: true, }); assert.deepEqual(bootstrapSelectionFromLookup("unavailable", sessionB, sessionA), { sessionId: sessionA, urlAction: "none", missing: false, - clearStoredReturn: false, }); assert.notEqual(SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE); }); -test("a looked-up stashed return writes its URL back; another person's stash is dropped (BUG-1038)", () => { - assert.deepEqual(bootstrapSelectionFromLookup("found", sessionB, sessionA, "stored-return"), { - sessionId: sessionB, - urlAction: "replace-selected", - missing: false, - clearStoredReturn: true, - }); - assert.deepEqual(bootstrapSelectionFromLookup("other-subject", sessionB, sessionA, "stored-return"), { - sessionId: sessionA, - urlAction: "none", - missing: false, - clearStoredReturn: true, - }); - // The ?c= origin is unchanged: the address bar already carries the id. - assert.equal(bootstrapSelectionFromLookup("found", sessionB, sessionA, "url").urlAction, "keep"); +test("only a ?c= is ever looked up; there is no stashed-return origin any more (BUG-1052)", () => { + // 原值(BUG-1038): 标题 "a looked-up stashed return writes its URL back; another person's stash is dropped"; + // bootstrapSelectionFromLookup(…, "stored-return") found → replace-selected,"other-subject" → none + 清存根; + // "url" 来源 found → keep。 + // 新值: 函数只剩 (status, requestedId, defaultSessionId) 三参;found 恒为 keep;源码里不再有来源参数与 other-subject。 + // 原因: BUG-1052 D3 删除登录返回存根,lookup 只剩地址栏 `?c=` 一个来源(其 keep 语义不变,D2)。 + assert.equal(bootstrapSelectionFromLookup.length, 3); + assert.equal(bootstrapSelectionFromLookup("found", sessionB, sessionA).urlAction, "keep"); + const lib = readFileSync(new URL("../src/lib/chat-session-url.ts", import.meta.url), "utf8"); + const cloud = readFileSync(new URL("../src/lib/home-cloud-sync.ts", import.meta.url), "utf8"); + for (const source of [lib, cloud]) { + assert.doesNotMatch(source, /stored-return|other-subject|readLoginSessionReturn/); + } }); test("cloud lookup and session management call the server before clearing ?c=", () => { diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index b302439d..0e45cadf 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -206,7 +206,11 @@ test("reaches chart, ephemeris and reports with links, not a document load", () // ——所以产品拍板换成客户端跳转,React 树与内存缓存因此能活过这一跳。 // `?c=` 的保存(`persistLoginSessionReturn`)一步没少,仍在跳转之前。 // `/login` 仍是硬跳转,由 chat-navigation-a11y-contract 守着。 - assert.match(appSidebar, /function leaveChat\(\) \{\s*persistLoginSessionReturn\(\);/); + // 原值: assert.match(appSidebar, /function leaveChat\(\) \{\s*persistLoginSessionReturn\(\);/)。 + // 新值: leaveChat 只关抽屉,侧栏源码里不再出现 persistLoginSessionReturn。 + // 原因: BUG-1052 D3——离开首页不再存 `?c=`,回裸 `/` 落空白首页;回到某条对话走它的 `?c=` 链接。 + assert.match(appSidebar, /function leaveChat\(\) \{\s*closeDrawer\(\);\s*\}/); + assert.doesNotMatch(appSidebar, /persistLoginSessionReturn/); assert.match(appSidebar, /\{ href: "\/chart", label: "星盘"/); assert.match(appSidebar, /\{ href: "\/ephemeris", label: "星历"/); assert.match(appSidebar, /\{ href: "\/reports", label: "我的报告"/);