fix(home): one new-chat intent and a scoped login-return stash (BUG-1038)
Coming back to / from /people could show the previous rectification session
as a locked page: its title in the header, the composer stuck on
"正在打开生时校正…", and the new-chat greeting in the middle.
The state that survived between pages is the sessionStorage login-return
stash that secondary-page sidebar links write from the current ?c=:
- /people「和 TA 对话」used a second intent (?newChat=1) parsed by a
component mounted inside Home after bootstrap, so the bootstrap new-chat
branch never ran and the stash won.
- A stash id not in the current person's loaded list was looked up and
landed with urlAction "keep", which assumes ?c= is already in the address
bar. It was not, so the rectification auto-open never fired. The stash
also ignored which person was current.
- An in-page new chat left the stash in place.
Fix: delete NewChatDeepLink / ?newChat and route「和 TA 对话」through
newChatHref(); a looked-up stash writes ?c= back (replace-selected) and is
dropped when it belongs to another person; startNewChat and
openChatBoundToProfile clear the stash. ?c= deep links, BUG-989 and BUG-705
are unchanged.
Tests: new real-lifecycle suite mounting the real Home, sidebar and people
page (10 cases: four secondary pages + mobile drawer, 和 TA 对话 for self and
another person, out-of-scope stash, same-person stash beyond the first page,
in-page new chat), plus two contract/unit tests. Six fail on origin/staging,
all pass here. Full suite 3928 / 61 failing, failure names identical to the
0ab061b9 baseline (3916 / 61).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f4b8f26e15
commit
0a8350cc01
@@ -1,5 +1,12 @@
|
||||
# BLOCKED
|
||||
|
||||
## TASK-new-chat-from-people:登录态真机、Node 22 与 DB 待验(2026-09-26)
|
||||
|
||||
- 无受控登录账号:修复只在本地 `next start` + Chrome 151 上验过(CDP 拦截 `/api/*` 返回虚构账户、会话与人物,未提交脚本)。iPhone Safari 真机、真实 staging 数据按 `docs/testing/secondary-new-chat-20260923.md` 追加节 A1–A6 待产品走。
|
||||
- 产品描述的入口(`/people` 点侧栏「新建对话」)本机未复现;复现且与截图逐项一致的入口是「在档案页换成他人后经不带新建意图的 `/` 回首页」和「对本人点和 TA 对话」。需产品真机确认当时的点击路径。
|
||||
- 本机只有 Node v20.19.2:`mock.module` 类测试失败(基线 61 条);全量失败名单与基线逐条一致,Linux Node 22 全量待协调方复跑。无 Docker,`npm run test:db` 未跑(本单不动表)。
|
||||
- 未部署;BUG-1038 保持 investigating。
|
||||
|
||||
## TASK-people-ephemeris-ui:登录态真机与 Node 22 全量待验(2026-09-26)
|
||||
|
||||
- 本机只有 Node v20.19.2:`mock.module` 类测试以 ERR_MODULE_NOT_FOUND 失败(基线即 61 条)。本轮全量 3915 条,失败 61 条,与基线测试名逐条一致、新增失败 0、无消失测试名;Linux Node 22 全量待协调方复跑。
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-26 — 从星盘档案页回首页不再卡在上一次的生时校正(待验收)
|
||||
|
||||
- 从校正会话去过次级页、在星盘档案里换了人,再回首页时,不再出现「标题是旧校正、输入框写着正在打开生时校正却一直打不开、中间是新对话开场」的画面;落到当前这个人的新对话。
|
||||
- 星盘档案「和 TA 对话」和侧栏「新建对话」走同一个新建入口:打开的是干净的新对话,名字标在标题旁边,不会过两秒又翻回旧校正。
|
||||
- 回到同一个人的旧会话时,地址栏会带上这条会话,校正会话会正常打开,不再停在锁住的输入框。首页里点「新建对话」后,之后回首页也不会再落回更早的会话。
|
||||
- Skill 版本不 bump。不改接口、数据库和样式;登录态真机与部署待验收。
|
||||
|
||||
## 2026-09-26 — 星盘档案页补上样式,星历页先写中文(待验收)
|
||||
|
||||
- 星盘档案页:手机上先列表、点人进详情(「‹ 返回」和浏览器返回都回列表);桌面左列表右详情。列表行有首字头像、「本人」标签和一行「出生日期 · 排盘时间 · 城市」,当前人物高亮;详情有填报 / 排盘两格时间卡(校正过的标「已校正」)、中文岁差名和三个快捷入口。删除他人挪到编辑页底部,本人仍不能删除。
|
||||
|
||||
@@ -13885,6 +13885,21 @@
|
||||
- 相关记录:BUG-1028。
|
||||
- 修复版本:`codex/round-0925-followup-20260925`,未部署。
|
||||
|
||||
## BUG-1038 | 回首页落到上一次生时校正:标题是旧校正、输入框「正在打开生时校正…」,中间却是新对话开场
|
||||
|
||||
- 状态:investigating(已修复待部署;登录态真机未复测)
|
||||
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
|
||||
- 影响面:首页启动落点(`home-bootstrap-run.ts` `resolveLanding`、`home-cloud-sync.ts` `resolveLookupBootstrap`、`chat-session-url.ts`)、`/people`「和 TA 对话」、只读侧栏的次级页链接与账户页脚、首页内「新建对话」。
|
||||
- 用户现象:产品 09-26 iPhone Safari:从星盘档案页回到首页后,标题栏是上一次的「生时校正 · 时段」,输入框占位「正在打开生时校正…」且发送禁用,页面中间却是新对话的开场问候和两个入口按钮,一直不打开。
|
||||
- 触发条件(本机 Chrome 与真实 React 生命周期测试均复现):先打开一条生时校正会话 → 点侧栏次级页链接(星盘 / 星历 / 我的报告 / 星盘档案都会把当前 `?c=` 存进 sessionStorage 的登录返回存根)→ ① 在 `/people` 让当前人物变成另一个人(选中他人后「看星盘」「和 TA 对话」或顶栏切人),再经不带新建意图的 `/` 回首页(只读侧栏账户页脚、刷新):画面与产品截图逐项一致,永久停住;② 在 `/people` 对本人点「和 TA 对话」:启动按存根落到旧校正并把 `?c=` 写回地址栏,后台自动打开旧校正,约 2 秒后从新对话翻回旧校正界面。侧栏「新建对话」(`/?new=1`)在本机浏览器 4 种变体(默认 / 慢网 / 慢打开 / 只有校正会话)和 lifecycle 测试的四个次级页 + 手机抽屉下都没有复现:新建分支本来就忽略并清掉存根。产品描述的入口与复现入口不同,需产品真机确认。
|
||||
- 根因:跨页存活的是登录返回存根 `sessionStorage["jyotisha.session-url-return"]`(侧栏次级页链接的 `leaveChat()` → `persistLoginSessionReturn()` 写入)。三处缺口:1) `/people`「和 TA 对话」用第二套意图 `?newChat=1`,由首页里挂载的 `NewChatDeepLink` 在启动之后才解析,启动的 new-chat 分支从未生效,存根照常胜出(违反 BUG-1015 红线 3);2) 存根 id 不在当前人物已加载的列表里时,启动走 lookup,找到后返回 `urlAction: "keep"`——这个语义只为地址栏已有 `?c=` 设计,存根来源时地址栏没有 `?c=`,`shouldAutoOpenRectificationSession` 永远不成立,校正会话成了活动会话却不打开,`composerLocksAsRectification` 锁住输入框;存根也不区分人物,别人的会话照样被拿来落地;3) 首页内新建对话不清存根,之后回裸 `/` 仍可能落回旧会话。
|
||||
- 修复:删除 `NewChatDeepLink` 与 `?newChat`,「和 TA 对话」改走 `newChatHref()`(人物由 current-subject 存储带过去,启动的新建分支按当前人物建本地空咨询);`bootstrapSelectionFromLookup` 增加来源参数:存根来源找到时用 `replace-selected`(写回 `?c=`,校正会话照常自动打开),存根会话不属于当前人物时(`other-subject`)落默认并清存根;`resolveLanding` 把当前人物判定传给 lookup;`startNewChat` / `openChatBoundToProfile` 清存根。`?c=` 深链的 `keep`、BUG-989 首问前不落库、BUG-705 lookup 均不变。
|
||||
- 验证:新增 `frontend/tests/new-chat-from-people-lifecycle.test.tsx`(挂载真实 `Home` + 真实 `AppSidebar` + 真实 `PeoplePage`,与 `(app)/layout.tsx` 同构,客户端导航换路由、文档加载重置模块状态保留 storage):四个次级页 + 手机抽屉的侧栏新建、「和 TA 对话」本人 / 他人、他人存根不落锁死页、同人存根带 URL 打开、首页内新建清存根,共 10 条;另加 2 条纯函数 / 源码合同。修复前在 `origin/staging` 上 4 条行为测试 + 2 条合同失败(锁死页那条正是占位「正在打开生时校正…」),修复后全绿。本地 `next start` + Chrome 151(CDP 拦截 `/api/*` 返回虚构数据)修复前后截图见 `docs/testing/new-chat-from-people-20260926-*.png`。全量与失败名单对照见 PROGRESS。
|
||||
- 防复发:lifecycle 测试锁「先开校正 → 客户端导航到次级页(写存根)→ 回首页」的组合;源码合同锁「只剩一套新建意图」。BUG-1015 当时的测试只执行 URL 纯函数和启动函数(参数优先级、reserved 恢复),没有真实组件生命周期,也没有把次级页链接写存根、第二套 `?newChat` 入口、存根 lookup 分支连起来,所以没拦住。
|
||||
- 相关记录:BUG-1015(同一新建意图链路)、BUG-705(lookup 分支)、BUG-989(首问前不落库)、BUG-1030 / BUG-1035(不打开别人的会话)。
|
||||
- 复发自:BUG-1015 的新建意图链路,组合路径遗漏;不是同一根因。
|
||||
- 修复版本:`codex/new-chat-from-people-20260926`,未部署。
|
||||
|
||||
## BUG-1039 | 星盘档案页 `/people` 没有任何样式
|
||||
|
||||
- 状态:resolved(Claude 2026-09-26 独立复验通过;门禁 run 2908、迁移 2909、部署 2910 成功,`/api/health` `gitCommit` = `0ab061b9`;登录态真机清单 `docs/testing/people-ephemeris-ui-20260926.md` 由产品走)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# PROGRESS · 从星盘档案页回首页落到上一次生时校正(2026-09-26)
|
||||
|
||||
任务书:`docs/tasks/TASK-new-chat-from-people-20260926.md`。分支 `codex/new-chat-from-people-20260926`,worktree `.worktrees/new-chat-from-people-20260926`。执行方:Claude 子代理(产品授权直接执行)。未推送、未部署。BUG-1038(开工时最大 BUG-1037;并行单已用 BUG-1039,1038 仍空)。
|
||||
|
||||
## 基线
|
||||
|
||||
- 开工基线 `65260ca7`;实现期间 `origin/staging` 前进到 `0ab061b9`(BUG-1039 档案页样式 + 星历改版),本分支已 rebase 到 `0ab061b9`。`people-page.tsx` 冲突一处:保留 staging 的新布局,只把「和 TA 对话」那一行的跳转改成 `newChatHref()`。
|
||||
- 基线全量(`0ab061b9`,同一台机器、Node v20.19.2、Linux):3916 条,61 失败,27 跳过。61 条都是环境缺口(Node 20 没有 `mock.module` 等)。
|
||||
|
||||
## 复现与根因
|
||||
|
||||
### 怎么复现的
|
||||
|
||||
两套手段,结论一致:
|
||||
|
||||
1. **真实浏览器**:本地 `next build --webpack` + `next start`,Chrome 151 无头,用 CDP `Fetch` 拦截 `/api/*` 返回虚构账户(林遥)、一条生时校正会话(「生时校正 · 04:45–05:15」)、一条普通咨询、一个他人(阿澈)。脚本在 scratchpad,未提交。
|
||||
2. **真实 React 生命周期测试**(新增 `frontend/tests/new-chat-from-people-lifecycle.test.tsx`):挂载真实 `Home`(`app/(app)/page.tsx` 默认导出)、真实 `SessionListProvider` + `AppSidebar` + `SidebarInset`(与 `(app)/layout.tsx` 同构;layout 本身引 CSS,不能在 tsx 里 import)、真实 `PeoplePage`。客户端导航 = 保留 provider 与侧栏、换路由子树;文档加载(`window.location.assign`)= 关掉 root、重置 current-subject 模块状态、保留 sessionStorage / localStorage。
|
||||
|
||||
| 路径 | 修复前(`origin/staging`) | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 开校正 → 侧栏「星盘档案」→ 选他人「看星盘」→ `/chart` 左下角账户(回裸 `/`) | **标题「生时校正 · 04:45–05:15」、占位「正在打开生时校正…」、发送禁用、中间「又见面了,林遥」+ 今日星语 / 生时校正两个入口,永久停住** | 与产品截图逐项一致,见 `docs/testing/new-chat-from-people-20260926-before.png` |
|
||||
| 开校正 → 「星盘档案」→ 本人「和 TA 对话」(`/?newChat=1`) | 地址被写成 `/?c=<校正>`,后台再 POST 一次 open,约 2 秒后从新对话翻回旧校正界面 | 慢打开(6 s)时可见先新对话、后翻回 |
|
||||
| 开校正 → 「星盘档案」→ 他人「和 TA 对话」 | 最终是新对话(`NewChatDeepLink` 抢回),但启动先按存根 lookup 了旧校正 | 时序侥幸 |
|
||||
| 开校正 → 「星盘档案」→ 侧栏「新建对话」(`/?new=1`) | **未复现**:新对话、地址 `/`、不再 open | 浏览器 4 种变体(默认 / 慢网 500 ms / 慢打开 6 s / 只有校正会话)+ lifecycle 四个次级页与手机抽屉 |
|
||||
|
||||
产品描述的入口(`/people` 点侧栏「新建对话」)本机没有复现;和截图一模一样的画面出现在「在档案页换成他人后经不带新建意图的 `/` 回首页」。已写进 BLOCKED,请产品真机确认当时的点击路径(`docs/testing/secondary-new-chat-20260923.md` 追加节 A1–A6)。
|
||||
|
||||
### 残留状态是哪一个(D3)
|
||||
|
||||
**`sessionStorage["jyotisha.session-url-return"]`(登录返回存根)**。只读侧栏的次级页链接 `onClick={leaveChat}` → `persistLoginSessionReturn()` 把当前 `?c=`(那条校正会话)存进去;它跨路由、跨文档加载存活,下一次首页启动在「没有 `new`、没有 `c`」时拿它当落点。
|
||||
|
||||
三处缺口:
|
||||
|
||||
1. **第二套新建意图**:「和 TA 对话」用 `/?newChat=1`,由首页里挂载的 `NewChatDeepLink` 在启动**之后**解析。启动的 new-chat 分支(会忽略并清掉存根)从未走到,存根照常胜出。
|
||||
2. **存根 lookup 落地不写 `?c=`**:存根 id 不在当前人物已加载的列表里时,`resolveLookupBootstrap` 去服务端找,找到返回 `urlAction: "keep"`。`keep` 是给「地址栏已经带 `?c=`」设计的;存根来源时地址栏没有 `?c=`,`shouldAutoOpenRectificationSession` 永远不成立 → 校正会话成了活动会话却不打开 → `composerLocksAsRectification` 锁住输入框、占位「正在打开生时校正…」,中间因为该会话没有消息显示开场问候。存根还不区分人物:当前人物换成他人后,本人的校正会话照样被拿来落地。
|
||||
3. **首页内新建不清存根**:lookup 暂时失败(`unavailable`)时存根保留;之后在首页点「新建对话」,再去次级页、回裸 `/`,仍会落回旧会话。
|
||||
|
||||
任务书候选里的「外壳注册 `openingSessionId`」「校正面模块级缓存」「current-subject emit 触发 `activateFallbackSession`」都查过:外壳注册在 Home 卸载时 `registerShellControls(null)`;校正面没有模块级状态;`activateFallbackSession` 只在删除会话时调用。都不是。
|
||||
|
||||
## 改了什么
|
||||
|
||||
| 任务 | 文件 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| T2 / D1 | `frontend/src/components/people-home-links.tsx` | 删除 `NewChatDeepLink`(和它唯一用到的 `useCurrentSubjectReady` import)。 |
|
||||
| T2 / D1 | `frontend/src/app/(app)/page.tsx` | 去掉 `NewChatDeepLink` 的 import 与挂载(-1 行净;`Home()` 的 useState / useRef 数不变)。 |
|
||||
| T2 / D1 | `frontend/src/components/people/people-page.tsx` | 「和 TA 对话」`openPerson(selected.id, newChatHref())`。人物仍由 `setCurrentSubject(id)` 带过去(写 localStorage,文档加载后由 `bindCurrentSubjectAccount` 读回),启动新建分支按当前人物建本地空咨询,所以 `newChatHref` 不需要带 `subjectId` 参数。 |
|
||||
| T2 | `frontend/src/lib/chat-session-url.ts` | `newChatHref` 注释写明它是唯一新建意图;`bootstrapSelectionFromLookup` 加 `origin` 参数(默认 `"url"`,行为不变):`stored-return` + found → `replace-selected`(写回 `?c=`);新状态 `other-subject` → 默认落点、清存根。 |
|
||||
| T2 / D3 | `frontend/src/lib/home-cloud-sync.ts` | `resolveLookupBootstrap` 判断来源(地址栏有无 `?c=`),加可选 `inSubjectScope` 判定;存根会话不属于当前人物时走 `other-subject`,不合并进列表。 |
|
||||
| T2 / D3 | `frontend/src/lib/home-bootstrap-run.ts` | `resolveLanding` 传 `inSubjectScope: sessionMatchesSubject(session, readCurrentSubjectId())`(放这里而不是 home-cloud-sync,避免 home-cloud-sync ↔ current-subject 循环依赖)。 |
|
||||
| T2 / D3 | `frontend/src/hooks/use-session-management.ts` | `startNewChat`、`openChatBoundToProfile` 调 `clearLoginSessionReturn()`。 |
|
||||
| T1 / T3 | `frontend/tests/new-chat-from-people-lifecycle.test.tsx`(新) | 10 条真实生命周期测试,见下。 |
|
||||
| T3 | `frontend/tests/chat-session-url.test.ts` | 新增 1 条源码合同:「和 TA 对话」走 `newChatHref()`,people-page / people-home-links / page.tsx 里不再有 `newChat=`、`"newChat"`、`NewChatDeepLink`。 |
|
||||
| T2 | `frontend/tests/session-lookup-unlisted.test.ts` | 新增 1 条:`stored-return` found → `replace-selected`;`other-subject` → 默认 + 清存根;`url` found 仍 `keep`。 |
|
||||
| T4 | `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`BLOCKED.md`、`docs/tasks/README.md`、`docs/testing/secondary-new-chat-20260923.md`(追加节 A1–A6)、`docs/testing/new-chat-from-people-20260926-{before,after,after-talk}.png` | 记录。不改 UI 样式与文案,`frontend/DESIGN.md` / `VOICE.md` 不动。 |
|
||||
|
||||
不变的口径:`?c=` 深链找到仍 `keep`;BUG-989 首问前不落库、不写 `?c=`;BUG-705 lookup 与 404 / 5xx 处理;无参数 `/` 回到同一人物的上一条会话(现在会把 `?c=` 写回,校正会话照常打开)。
|
||||
|
||||
## 新增测试(lifecycle 10 条)
|
||||
|
||||
| # | 名称(节选) | 修复前 `0ab061b9` | 修复后 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1–4 | from /chart · /ephemeris · /reports · /people,侧栏「新建对话」 | 通过 | 通过 |
|
||||
| 5 | from /people (mobile drawer),侧栏「新建对话」,抽屉关闭 | 通过 | 通过 |
|
||||
| 6 | 「和 TA 对话」本人 | **失败**(地址 `/?c=<校正>`、多一次 open) | 通过 |
|
||||
| 7 | 「和 TA 对话」他人,标题旁是 TA 的名字 | 通过(时序侥幸) | 通过 |
|
||||
| 8 | 他人存根不落锁死页 | **失败**(占位「正在打开生时校正…」) | 通过 |
|
||||
| 9 | 同人存根超出首页列表:带 `?c=` 打开校正界面 | **失败**(锁死页) | 通过 |
|
||||
| 10 | 首页内新建清存根(真实 `useSessionManagement`) | **失败** | 通过 |
|
||||
|
||||
加上两条纯函数 / 源码合同(修复前都失败),共新增 12 条。1–5 在修复前就通过:它们锁的是侧栏新建入口不回退(D2),不是复现用例。
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error,126 warning(基线 127:删掉的 `NewChatDeepLink` 带走一条 exhaustive-deps) |
|
||||
| 定向 `tsx --test` lifecycle + chat-session-url + session-lookup-unlisted + people-archive-view + new-chat-recovery + home-bootstrap-run + sidebar-contract + session-list-lifecycle | 全部通过 |
|
||||
| 全量 `npm test`(Node 20) | 3928 条,61 失败,27 跳过(基线 3916 / 61 / 27) |
|
||||
| 失败名单对照 | 逐条相同,新增失败 0 |
|
||||
| 测试名对照 | 消失 0,新增 12 |
|
||||
| `next build --webpack` | `/` ○、`/chart` ○、`/ephemeris` ○、`/people` ○、`/reports` ƒ、`/reports/[reportId]` ƒ,与基线一致 |
|
||||
| 首屏 gzip(`/` 预渲染 HTML 引用的 36 个 js/css,gzip -9 求和) | 基线 656,329 B → 654,283 B(-0.31%) |
|
||||
|
||||
失败名单对照命令:
|
||||
|
||||
```bash
|
||||
grep -E '^not ok' base.log | sed 's/^not ok [0-9]* - //; s/ # .*//' | sort > fail-base.txt
|
||||
grep -E '^not ok' new.log | sed 's/^not ok [0-9]* - //; s/ # .*//' | sort > fail-new.txt
|
||||
diff fail-base.txt fail-new.txt && echo SAME-FAILS # → SAME-FAILS
|
||||
grep -E "^(not )?ok [0-9]+ - " base.log | sed -E 's/^(not )?ok [0-9]+ - //; s/ # .*//' | sort > names-base.txt
|
||||
grep -E "^(not )?ok [0-9]+ - " new.log | sed -E 's/^(not )?ok [0-9]+ - //; s/ # .*//' | sort > names-new.txt
|
||||
comm -23 names-base.txt names-new.txt # → 空(无消失)
|
||||
comm -13 names-base.txt names-new.txt | wc -l # → 12
|
||||
```
|
||||
|
||||
## 改动的既有断言
|
||||
|
||||
无。`chat-session-url.test.ts`、`session-lookup-unlisted.test.ts` 只新增测试,没有改、删任何既有断言;`sidebar-contract.test.ts` 锁的新建链接 `href={newChatHref()} … onClick={closeDrawer}` 未动。
|
||||
|
||||
| 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| — | — | 本轮没有修改既有断言 |
|
||||
|
||||
## 浏览器证据
|
||||
|
||||
本地 `next start`(rebase 后的构建)+ Chrome 151,1280×900,虚构数据:
|
||||
|
||||
| 路径 | 修复后结果 |
|
||||
| --- | --- |
|
||||
| 他人「看星盘」→ 左下角账户回 `/` | 标题「新对话 阿澈」,占位「想聊什么都可以」,可输入,无第二次 open(`new-chat-from-people-20260926-after.png`) |
|
||||
| 本人「和 TA 对话」 | 地址 `/`,新对话,5 秒内不翻回,无第二次 open |
|
||||
| 他人「和 TA 对话」 | 标题「新对话 阿澈」(`new-chat-from-people-20260926-after-talk.png`) |
|
||||
| 侧栏「新建对话」(默认 / 慢网 500 ms + 慢打开 6 s) | 新对话,地址 `/`,无第二次 open |
|
||||
|
||||
修复前同路径截图:`new-chat-from-people-20260926-before.png`(基线构建)。右下角「合盘历史暂时无法读取」提示是 CDP 桩没有模拟 `/api/synastry-reports` 造成的,与本单无关。
|
||||
|
||||
## 环境缺口(未写成通过)
|
||||
|
||||
- **无受控登录账号**:没有在 staging 或 iPhone Safari 上走;真机清单见 `docs/testing/secondary-new-chat-20260923.md` 追加节 A1–A6。BUG-1038 保持 investigating。
|
||||
- **产品描述的入口未复现**:见上。需要产品真机确认。
|
||||
- **浏览器里没做手机抽屉**:CDP 脚本找抽屉触发器没对上;手机抽屉由 lifecycle 测试 #5(innerWidth 390,真实 `SidebarProvider` / `AppSidebar`)覆盖。
|
||||
- **Node 22**:本机只有 Node 20,全量以失败名单逐条对照代替,Linux Node 22 全量待协调方复跑。
|
||||
- **Docker**:无,`npm run test:db` 未跑;本单不动表。
|
||||
@@ -121,7 +121,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-starter-home-polish-20260926.md` | — | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后 | 待领取 | — |
|
||||
| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 待领取 | — |
|
||||
| `TASK-new-chat-from-people-20260926.md` | — | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 待领取 | — |
|
||||
| `TASK-new-chat-from-people-20260926.md` | `PROGRESS-new-chat-from-people-20260926.md` | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 已实现待验收(Claude 子代理直接执行;残留状态 = 次级页链接写入的登录返回存根,存根 lookup 落地不写 `?c=` 致锁死页;未推送) | `codex/new-chat-from-people-20260926` |
|
||||
| `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 已验收(Claude 09-25:两 P0、两 P1、七 P2 通过;新 P2 删后跳错人、人物接口故障连累聊天 → 收尾单) | `4d801e53`(已部署,health 核对一致) |
|
||||
| `TASK-home-page-split-20260925.md` | `PROGRESS-home-page-split-20260925.md` | **首页 page.tsx 拆分(只搬不改)**:启动流程约 285 行抽成 lib 纯函数 `runHomeBootstrap`、恢复轮询约 100 行抽出、错误 / 引导屏抽组件,目标 ≤1300 行并下调增长合同基线;两个按源码切片的测试改为直接调用函数;还原 824647f2 的 import 挤行。排在 chart-ephemeris 之后、people-archive P1 之前 | 已验收(行为逐行一致;1417 行达让步线、合同基线已下调;流程违规:未跑全量致门禁红 3 次) | `da35b3e4`(已部署) |
|
||||
| `TASK-staging-gate-fix-20260924.md` | — | **修复单(阻塞 staging 部署)**:慢网首页 `0e4dd0f4` 起 5 条源码合同测试未跟上(硬跳转计数、重试 reload 正则、两处按已删语句切片)→ 门禁红,`0e4dd0f4..f125fae0` 全部未部署;另 page.tsx +9 行、订阅支付不刷新余额、原始附录三文件残留。只修测试与收尾,不改已验收行为;失败名单须与 `edc9c22c` 逐条一致 | 已验收(Claude 09-25:5 条转绿、失败名单与 edc9c22c 逐条一致 56/56、门禁 run 2877 绿;import 挤行凑数为 P3,由 home-page-split D5 还原;原始附录三文件仍未删) | `651c75e7`/`7a001456`/`824647f2`;deploy run 2879 因 ACR 网络失败,待同 SHA 重跑 |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -28,3 +28,20 @@
|
||||
- 刚注册、没有历史的受控账号进入新建入口仍只有一个可见空会话,首问前不写数据库、不显示空列表行。启动内部已有的空列表兜底与本单新建分支可能分别产生本地空对象;两者都必须隐藏且不持久化。
|
||||
- 普通刷新 `/` 不承诺“始终空白”,本单不更改默认落点规则。
|
||||
- 需要模型凭据才能完成步骤 2 的真实回复;若不可用,只记录创建链的实际证据,将回复部分标环境缺口,不伪写通过。
|
||||
|
||||
## 追加:先开一条生时校正再从档案页回首页(BUG-1038,2026-09-26)
|
||||
|
||||
关联:`TASK-new-chat-from-people-20260926.md`、`PROGRESS-new-chat-from-people-20260926.md`。本节尚未执行;本机只用虚构数据在本地 `next start` 上验过,截图见 `new-chat-from-people-20260926-before.png`(修复前)、`-after.png`、`-after-talk.png`(修复后)。
|
||||
|
||||
前置:受控测试账号至少有一条生时校正会话、一条普通咨询,星盘档案里至少有一个他人。每步先从侧栏打开那条生时校正会话,确认校正界面已打开(地址带 `?c=`),再点侧栏「星盘档案」进入 `/people`。
|
||||
|
||||
| 步骤 | 操作 | 预期 |
|
||||
| --- | --- | --- |
|
||||
| A1 | 在 `/people` 点侧栏「新建对话」(桌面,和 iPhone 抽屉各一次) | 首页标题「新对话」,输入框占位「想聊什么都可以」且可输入,发送可用;不出现「正在打开生时校正…」;地址为 `/`;侧栏那条校正会话不显示「正在打开」 |
|
||||
| A2 | 在 `/people` 选中本人,点「和 TA 对话」 | 同 A1;等 5 秒,页面不从新对话翻回校正界面,地址里没有 `c=` |
|
||||
| A3 | 在 `/people` 选中一个他人,点「和 TA 对话」 | 同 A1;标题「新对话」旁边是这个人的名字 |
|
||||
| A4 | 在 `/people` 选中一个他人,点「看星盘」,到 `/chart` 后点左下角账户(回首页) | 落在这个人的新对话:标题「新对话」旁是这个人的名字,输入框可用;不出现旧校正标题和「正在打开生时校正…」(修复前就是这个画面) |
|
||||
| A5 | 从校正会话点侧栏「星盘」,直接点左下角账户回首页(当前人物仍是本人) | 按原规则回到那条校正会话并打开校正界面,地址带 `?c=`;不停在锁住的输入框 |
|
||||
| A6 | 首页点自己的「新建对话」,再进 `/chart`,点左下角账户回首页 | 不回到更早的校正会话 |
|
||||
|
||||
记录:每步通过 / 未通过、设备与浏览器、日期、脱敏现象;不记录账号、会话 ID、出生资料。
|
||||
|
||||
@@ -45,7 +45,7 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { bindCurrentSubjectAccount, readCurrentSubjectId, useCurrentSubjectProfile } from "@/lib/current-subject";
|
||||
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
|
||||
import { NewChatDeepLink, SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
@@ -1127,7 +1127,6 @@ export default function Home() {
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
setDailyStarlanguage={setDailyStarlanguage}
|
||||
/>
|
||||
<NewChatDeepLink ready={Boolean(account && modelCatalog)} startNewChat={startNewChat} />
|
||||
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
{pendingSessionDeletion ? (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useCurrentSubjectReady } from "@/lib/current-subject";
|
||||
import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types";
|
||||
|
||||
function stripQuery(key: string) {
|
||||
@@ -13,22 +12,6 @@ function stripQuery(key: string) {
|
||||
window.history.replaceState(null, "", `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`);
|
||||
}
|
||||
|
||||
export function NewChatDeepLink(props: {
|
||||
ready: boolean;
|
||||
startNewChat: () => Promise<unknown>;
|
||||
}) {
|
||||
const started = useRef(false);
|
||||
const subjectReady = useCurrentSubjectReady();
|
||||
useEffect(() => {
|
||||
if (!props.ready || !subjectReady || started.current) return;
|
||||
if (!new URLSearchParams(window.location.search).has("newChat")) return;
|
||||
started.current = true;
|
||||
stripQuery("newChat");
|
||||
void props.startNewChat();
|
||||
}, [props.ready, props.startNewChat, subjectReady]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SynastryDeepLink(props: {
|
||||
profile: Profile;
|
||||
draft: (record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) => Promise<void>;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type SetStateAction } from "react";
|
||||
import { FileText, MessageSquareText, Orbit } from "lucide-react";
|
||||
|
||||
import { newChatHref } from "@/lib/chat-session-url";
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
@@ -434,7 +435,7 @@ export function PeopleArchiveView({
|
||||
<dd>{peopleAyanamsaLabel(selected.profile)}</dd>
|
||||
</dl>
|
||||
<div className="people-archive-actions">
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/?newChat=1")}>
|
||||
<button type="button" onClick={() => openPerson(selected.id, newChatHref())}>
|
||||
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />和 TA 对话
|
||||
</button>
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/chart")}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
SESSION_LOOKUP_FAILED_NOTICE,
|
||||
SESSION_MISSING_NOTICE,
|
||||
clearLoginSessionReturn,
|
||||
parseSessionUrlQuery,
|
||||
writeSessionUrl,
|
||||
} from "@/lib/chat-session-url";
|
||||
@@ -348,6 +349,9 @@ 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("");
|
||||
@@ -617,6 +621,7 @@ 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("");
|
||||
|
||||
@@ -17,6 +17,12 @@ export type BootstrapSessionSelection = {
|
||||
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).
|
||||
*/
|
||||
export function newChatHref(): string {
|
||||
return "/?new=1";
|
||||
}
|
||||
@@ -147,15 +153,32 @@ export function resolveBootstrapSessionSelection(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
export function bootstrapSelectionFromLookup(
|
||||
status: "found" | "missing" | "unavailable",
|
||||
status: "found" | "missing" | "unavailable" | "other-subject",
|
||||
requestedId: string,
|
||||
defaultSessionId: string,
|
||||
origin: "url" | "stored-return" = "url",
|
||||
): BootstrapSessionSelection {
|
||||
if (status === "found") {
|
||||
return {
|
||||
sessionId: requestedId,
|
||||
urlAction: "keep",
|
||||
urlAction: origin === "stored-return" ? "replace-selected" : "keep",
|
||||
missing: false,
|
||||
clearStoredReturn: true,
|
||||
};
|
||||
}
|
||||
if (status === "other-subject") {
|
||||
return {
|
||||
sessionId: defaultSessionId,
|
||||
urlAction: "none",
|
||||
missing: false,
|
||||
clearStoredReturn: true,
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "@/lib/home-cloud-sync";
|
||||
import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile";
|
||||
import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import {
|
||||
emptyProfile,
|
||||
pendingConsultationStorageKey,
|
||||
@@ -351,6 +352,7 @@ 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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
bootstrapSelectionFromLookup,
|
||||
parseSessionUrlQuery,
|
||||
persistLoginSessionReturn,
|
||||
readLoginSessionReturn,
|
||||
resolveBootstrapSessionSelection,
|
||||
@@ -514,6 +515,8 @@ 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[];
|
||||
@@ -529,11 +532,19 @@ export async function resolveLookupBootstrap(input: {
|
||||
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),
|
||||
selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId, origin),
|
||||
sessions: mergeHydratedSession(input.sessions, looked.session),
|
||||
notice: null,
|
||||
};
|
||||
|
||||
@@ -87,6 +87,18 @@ test("new chat bootstrap consumes the URL before activation and reuses local-onl
|
||||
assert.doesNotMatch(landing, /persistSession|writeChatSession|fetch\(|writeSessionUrl/);
|
||||
});
|
||||
|
||||
test("there is one new-chat intent: /people「和 TA 对话」 links through newChatHref, no ?newChat parser remains", () => {
|
||||
// BUG-1038: a second intent (`?newChat=1`, parsed by a component mounted in
|
||||
// Home) never reached bootstrap's new-chat branch, so the stashed `?c=` won.
|
||||
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
||||
const links = readFileSync(new URL("../src/components/people-home-links.tsx", import.meta.url), "utf8");
|
||||
const home = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(people, /openPerson\(selected\.id, newChatHref\(\)\)\}>[\s\S]{0,160}?和 TA 对话/);
|
||||
for (const source of [people, links, home]) {
|
||||
assert.doesNotMatch(source, /newChat=|["']newChat["']|NewChatDeepLink/);
|
||||
}
|
||||
});
|
||||
|
||||
test("bootstrap reads a listed ?c= session and ignores login storage", () => {
|
||||
assert.deepEqual(
|
||||
resolveBootstrapSessionSelection({
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
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`).
|
||||
|
||||
// Synthetic ids and profiles only (no real person data).
|
||||
const USER = "11111111-1111-4111-8111-111111111111";
|
||||
const RECT = "33333333-3333-4333-8333-333333333333";
|
||||
const CASE = "44444444-4444-4444-8444-444444444444";
|
||||
const CONS = "55555555-5555-4555-8555-555555555555";
|
||||
const OTHER = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
const selfProfile = {
|
||||
name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import",
|
||||
birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "",
|
||||
uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "",
|
||||
countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri",
|
||||
};
|
||||
const otherProfile = { ...selfProfile, name: "阿澈", date: "1992-02-02" };
|
||||
const rectRow = {
|
||||
id: RECT, title: "生时校正 · 04:45–05:15", theme: "general", model_id: "deepseek-pro", messages: [],
|
||||
session_type: "birth_time_rectification", rectification_case_id: CASE, chart_profile_id: "self",
|
||||
chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
|
||||
created_at: "2026-09-20T00:00:00Z", updated_at: "2026-09-25T00:00:00Z",
|
||||
};
|
||||
const consultationRow = {
|
||||
id: CONS, title: "事业方向", theme: "career", model_id: "deepseek-pro",
|
||||
messages: [{ role: "user", text: "虚构问题" }, { role: "assistant", text: "虚构回答" }],
|
||||
session_type: "consultation", rectification_case_id: null, chart_profile_id: "self",
|
||||
chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
|
||||
created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z",
|
||||
};
|
||||
|
||||
type Api = { requests: string[]; opens: () => number };
|
||||
|
||||
function installApi(options: { listRectification?: boolean } = {}): { api: Api; restore: () => void } {
|
||||
const listed = options.listRectification === false ? [consultationRow] : [rectRow, consultationRow];
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: string[] = [];
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
const url = new URL(String(input), "http://localhost");
|
||||
const method = init?.method ?? "GET";
|
||||
const path = url.pathname;
|
||||
requests.push(`${method} ${path}${url.search}`);
|
||||
if (path === "/api/account") {
|
||||
return json({
|
||||
user: { id: USER, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9,
|
||||
isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null,
|
||||
hasConfirmedBirthTime: true, hasUsableBirthTime: true,
|
||||
});
|
||||
}
|
||||
if (path === "/api/chart-profiles") return json({ profiles: [{ id: OTHER, role: "other", profile: otherProfile }] });
|
||||
if (path === "/api/models") {
|
||||
return json({ defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] });
|
||||
}
|
||||
if (path === "/api/sessions" && method === "GET") {
|
||||
const subject = url.searchParams.get("subject");
|
||||
return json({ sessions: subject && subject !== "self" ? [] : listed, nextCursor: null });
|
||||
}
|
||||
if (path === `/api/sessions/${RECT}`) return json(method === "GET" ? { session: rectRow } : { ok: true });
|
||||
if (path === `/api/sessions/${CONS}`) return json(method === "GET" ? { session: consultationRow } : { ok: true });
|
||||
if (path.startsWith("/api/sessions")) return json({ ok: true });
|
||||
if (path === "/api/consult/status") return new Response(null, { status: 204 });
|
||||
if (path === "/api/rectification/cases/entry-summary") {
|
||||
return json({ has_resumable_case: false, has_terminal_case_with_time: true, latest_resumable: null, latest_terminal: { case_id: CASE, status: "completed" } });
|
||||
}
|
||||
if (path === "/api/rectification/cases/open") {
|
||||
return json({ disposition: "readonly", caseId: CASE, sessionId: RECT, status: "completed", shouldStartOpening: false, skillVersion: "test" });
|
||||
}
|
||||
if (path === `/api/rectification/cases/${CASE}`) return json({ turns: [], snapshot: null });
|
||||
if (path === "/api/daily-starlanguage") return new Response(null, { status: 204 });
|
||||
return json({ error: "not_mocked" }, 404);
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
api: { requests, opens: () => requests.filter((line) => line === "POST /api/rectification/cases/open").length },
|
||||
restore: () => { globalThis.fetch = originalFetch; },
|
||||
};
|
||||
}
|
||||
|
||||
function memoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => { values.set(key, String(value)); },
|
||||
removeItem: (key) => { values.delete(key); },
|
||||
clear: () => values.clear(),
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
get length() { return values.size; },
|
||||
};
|
||||
}
|
||||
|
||||
/** One browser tab: storages survive document loads, module state does not. */
|
||||
function createTab() {
|
||||
const session = memoryStorage();
|
||||
const local = memoryStorage();
|
||||
const location = {
|
||||
origin: "http://localhost", protocol: "http:", host: "localhost", hostname: "localhost",
|
||||
href: "http://localhost/", pathname: "/", search: "", hash: "",
|
||||
assigned: [] as string[],
|
||||
assign(href: string) { this.assigned.push(href); },
|
||||
replace(href: string) { this.assigned.push(href); },
|
||||
};
|
||||
const setUrl = (href: string) => {
|
||||
const next = new URL(href, "http://localhost");
|
||||
location.pathname = next.pathname;
|
||||
location.search = next.search;
|
||||
location.href = next.href;
|
||||
};
|
||||
const history = {
|
||||
pushState(_state: unknown, _title: string, url: string) { setUrl(url); },
|
||||
replaceState(_state: unknown, _title: string, url: string) { setUrl(url); },
|
||||
};
|
||||
return { session, local, location, history, setUrl };
|
||||
}
|
||||
|
||||
type Tab = ReturnType<typeof createTab>;
|
||||
|
||||
async function openDocument(tab: Tab, href: string, options: { mobile?: boolean } = {}) {
|
||||
const h = createClientLifecycleHarness();
|
||||
const win = globalThis.window as unknown as Record<string, unknown>;
|
||||
tab.setUrl(href);
|
||||
Object.assign(win, {
|
||||
location: tab.location,
|
||||
history: tab.history,
|
||||
navigator: { onLine: true, userAgent: "node" },
|
||||
sessionStorage: tab.session,
|
||||
localStorage: tab.local,
|
||||
innerWidth: options.mobile ? 390 : 1440,
|
||||
// floating-ui (tooltips / menus) checks `instanceof win.Element`.
|
||||
Element: (globalThis as unknown as { Element: unknown }).Element,
|
||||
Node: (globalThis as unknown as { Element: unknown }).Element,
|
||||
ShadowRoot: class {},
|
||||
matchMedia: (query: string) => ({
|
||||
matches: Boolean(options.mobile) && query.includes("max-width"),
|
||||
media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
|
||||
}),
|
||||
});
|
||||
// The shared host DOM has no layout. Home's scroll anchor and menus measure
|
||||
// and query; give them inert answers (no geometry is asserted here).
|
||||
const hostProto = (globalThis as unknown as { HTMLElement: { prototype: Record<string, unknown> } }).HTMLElement.prototype;
|
||||
Object.assign(hostProto, {
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
closest: () => null,
|
||||
matches: () => false,
|
||||
getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }),
|
||||
getClientRects: () => [],
|
||||
scrollTo() {},
|
||||
scrollIntoView() {},
|
||||
scrollHeight: 0, clientHeight: 0, scrollTop: 0, offsetHeight: 0, offsetTop: 0,
|
||||
});
|
||||
if (!Object.getOwnPropertyDescriptor(hostProto, "children")) {
|
||||
Object.defineProperty(hostProto, "children", { configurable: true, get(this: { childNodes: unknown[] }) { return this.childNodes; } });
|
||||
}
|
||||
const doc = globalThis.document as unknown as Record<string, unknown>;
|
||||
const createElement = (doc.createElement as (tag: string) => Record<string, unknown>).bind(doc);
|
||||
doc.createElement = (tag: string) => {
|
||||
const node = createElement(tag);
|
||||
node.style = { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" };
|
||||
return node;
|
||||
};
|
||||
doc.getElementsByTagName = () => [doc.head];
|
||||
doc.head = createElement("head");
|
||||
doc.documentElement = Object.assign(createElement("html"), { dataset: {} });
|
||||
// Floating portals (tooltips, popovers) mount into document.body.
|
||||
doc.body = createElement("body");
|
||||
const previous = {
|
||||
sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
|
||||
localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
|
||||
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||
matchMedia: Object.getOwnPropertyDescriptor(globalThis, "matchMedia"),
|
||||
Node: Object.getOwnPropertyDescriptor(globalThis, "Node"),
|
||||
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
|
||||
cancelAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame"),
|
||||
};
|
||||
Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "localStorage", { value: tab.local, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "navigator", { value: win.navigator, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "matchMedia", { value: win.matchMedia, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "Node", { value: win.Node, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "requestAnimationFrame", { value: win.requestAnimationFrame, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "cancelAnimationFrame", { value: win.cancelAnimationFrame, configurable: true, writable: true });
|
||||
// A document load re-evaluates every module. Reset the current-person store
|
||||
// (it decides which list the provider reads); it re-reads localStorage.
|
||||
const subject = await import("../src/lib/current-subject.ts");
|
||||
subject.resetSubjectCatalogForTests();
|
||||
|
||||
const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx");
|
||||
const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
|
||||
const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
|
||||
|
||||
// Same composition as app/(app)/layout.tsx (which cannot be imported here
|
||||
// because it pulls global CSS). The route child is swapped like a client
|
||||
// navigation: the provider and sidebar stay mounted, the page remounts.
|
||||
function AppShell({ children }: { children: ReactNode }) {
|
||||
const list = useSessionList();
|
||||
const registration = useShellRegistration();
|
||||
const { sessions, account } = sessionListSidebarModel(list, registration);
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
<main className="chat-app">
|
||||
<AppSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={registration?.activeSessionId ?? null}
|
||||
openingSessionId={registration?.openingSessionId ?? null}
|
||||
openErrorSessionId={registration?.openErrorSessionId ?? null}
|
||||
openErrorMessage={registration?.openErrorMessage ?? ""}
|
||||
account={account}
|
||||
settled={list.settled}
|
||||
signedOut={list.signedOut}
|
||||
controls={registration?.controls}
|
||||
/>
|
||||
<SidebarInset className={registration?.insetClassName ?? "chat-panel secondary-panel"} inert={registration?.insetInert}>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const Home = (await import("../src/app/(app)/page.tsx")).default;
|
||||
const { PeoplePage } = await import("../src/components/people/people-page.tsx");
|
||||
const route = (pathname: string): ReactNode => {
|
||||
if (pathname === "/") return <Home />;
|
||||
if (pathname === "/people") return <PeoplePage />;
|
||||
// /chart, /ephemeris, /reports: their bodies do not take part in the
|
||||
// new-chat path; the read-only sidebar above them is the shared one.
|
||||
return <section data-secondary-route={pathname} />;
|
||||
};
|
||||
const renderRoute = () => h.render(
|
||||
<SessionListProvider><AppShell>{route(tab.location.pathname)}</AppShell></SessionListProvider>,
|
||||
);
|
||||
await renderRoute();
|
||||
|
||||
const byClass = (name: string) => h.elements().filter((node) => String(node.props.className ?? "").split(/\s+/).includes(name));
|
||||
const view = () => {
|
||||
const title = byClass("chat-header-title")[0]?.childNodes.find((node) => node.tagName === "STRONG")?.text ?? null;
|
||||
const textarea = h.elements().find((node) => node.tagName === "TEXTAREA");
|
||||
return {
|
||||
url: `${tab.location.pathname}${tab.location.search}`,
|
||||
title,
|
||||
placeholder: (textarea?.props.placeholder as string | undefined) ?? null,
|
||||
inputDisabled: Boolean(textarea?.props.disabled),
|
||||
rectificationSurface: byClass("is-rectification").length > 0,
|
||||
chartName: byClass("chat-header-title")[0]?.text ?? "",
|
||||
};
|
||||
};
|
||||
const link = (label: string) => {
|
||||
const found = h.elements().find((node) => node.tagName === "A"
|
||||
&& (node.text.trim() === label || node.props["aria-label"] === label));
|
||||
assert.ok(found, `sidebar link ${label} is rendered`);
|
||||
return found;
|
||||
};
|
||||
const button = (label: string) => {
|
||||
const found = h.elements().find((node) => node.tagName === "BUTTON" && node.text.includes(label));
|
||||
assert.ok(found, `button ${label} is rendered`);
|
||||
return found;
|
||||
};
|
||||
/** Follow an in-app link the way the client router does. */
|
||||
async function followLink(label: string) {
|
||||
const anchor = link(label);
|
||||
const href = String(anchor.props.href);
|
||||
// The link's own click handler (leaveChat / closeDrawer) runs first.
|
||||
const own = anchor.props.onClick as ((event: unknown) => void) | undefined;
|
||||
const click = {
|
||||
button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false,
|
||||
preventDefault() { click.defaultPrevented = true; }, stopPropagation() {},
|
||||
currentTarget: anchor, target: anchor, nativeEvent: {},
|
||||
};
|
||||
await h.update(() => { own?.(click); });
|
||||
tab.history.pushState(null, "", href);
|
||||
await renderRoute();
|
||||
return href;
|
||||
}
|
||||
async function settle(until: () => boolean, label: string, ms = 12000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < ms) {
|
||||
await h.idle();
|
||||
if (until()) return;
|
||||
}
|
||||
assert.fail(`timed out waiting for ${label}: ${JSON.stringify(view())}`);
|
||||
}
|
||||
async function close() {
|
||||
try { await h.close(); } finally {
|
||||
for (const [key, descriptor] of Object.entries(previous)) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const drawerOpen = () => h.elements().some((node) => node.tagName === "ASIDE" && node.props["data-mobile-open"] === true);
|
||||
async function openDrawer() {
|
||||
const trigger = h.elements().find((node) => node.tagName === "BUTTON" && node.props["data-sidebar"] === "trigger");
|
||||
assert.ok(trigger, "the inset sidebar trigger is rendered");
|
||||
await h.event(trigger);
|
||||
assert.equal(drawerOpen(), true, "the mobile drawer opens");
|
||||
}
|
||||
return { h, view, link, button, followLink, settle, close, drawerOpen, openDrawer, errors: h.errors };
|
||||
}
|
||||
|
||||
async function openRectificationThenGoTo(tab: Tab, api: Api, secondaryLabel: string, options: { mobile?: boolean } = {}) {
|
||||
const doc = await openDocument(tab, `/?c=${RECT}`, options);
|
||||
await doc.settle(() => doc.view().rectificationSurface, "the rectification session to open");
|
||||
assert.equal(doc.view().title, "生时校正 · 04:45–05:15");
|
||||
assert.equal(api.opens(), 1);
|
||||
await doc.followLink(secondaryLabel);
|
||||
return doc;
|
||||
}
|
||||
|
||||
const secondaryPages = [
|
||||
{ label: "星盘", path: "/chart" },
|
||||
{ label: "星历", path: "/ephemeris" },
|
||||
{ label: "我的报告", path: "/reports" },
|
||||
{ label: "星盘档案", path: "/people" },
|
||||
] as const;
|
||||
|
||||
for (const page of [
|
||||
...secondaryPages.map((entry) => ({ ...entry, mobile: false })),
|
||||
{ ...secondaryPages[3], mobile: true },
|
||||
]) {
|
||||
test(`from ${page.path}${page.mobile ? " (mobile drawer)" : ""}, the sidebar 新建对话 after a rectification session lands on a clean new chat`, async () => {
|
||||
const tab = createTab();
|
||||
const { api, restore } = installApi();
|
||||
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
|
||||
try {
|
||||
doc = await openDocument(tab, `/?c=${RECT}`, { mobile: page.mobile });
|
||||
await doc.settle(() => doc!.view().rectificationSurface, "the rectification session to open");
|
||||
assert.equal(doc.view().title, "生时校正 · 04:45–05:15");
|
||||
assert.equal(api.opens(), 1);
|
||||
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);
|
||||
if (page.path === "/people") {
|
||||
await doc.settle(() => doc!.h.elements().some((node) => node.text.includes("和 TA 对话")), "the people page");
|
||||
}
|
||||
if (page.mobile) await doc.openDrawer();
|
||||
const href = await doc.followLink("新建对话");
|
||||
assert.equal(href, "/?new=1");
|
||||
if (page.mobile) assert.equal(doc.drawerOpen(), false, "the drawer closes on 新建对话");
|
||||
const opensBefore = api.opens();
|
||||
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, "/");
|
||||
assert.equal(view.rectificationSurface, false);
|
||||
assert.equal(view.inputDisabled, false);
|
||||
assert.notEqual(view.placeholder, "正在打开生时校正…");
|
||||
assert.equal(api.opens(), opensBefore, "no rectification session is re-opened");
|
||||
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "the stashed return is consumed");
|
||||
assert.deepEqual(doc.errors, []);
|
||||
} finally {
|
||||
await doc?.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const person of [
|
||||
{ row: "林遥", label: "本人" },
|
||||
{ row: "阿澈", label: "另一位档案人物" },
|
||||
]) {
|
||||
test(`from /people, 「和 TA 对话」 for ${person.label} after a rectification session opens a new chat bound to that person`, async () => {
|
||||
const tab = createTab();
|
||||
const { api, restore } = installApi();
|
||||
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
|
||||
try {
|
||||
doc = await openRectificationThenGoTo(tab, api, "星盘档案");
|
||||
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "BUTTON" && node.text.includes("阿澈")), "the people list");
|
||||
await doc.h.event(doc.button(person.row));
|
||||
await doc.h.event(doc.button("和 TA 对话"));
|
||||
const assigned = tab.location.assigned.at(-1);
|
||||
assert.ok(assigned, "和 TA 对话 navigates");
|
||||
await doc.close();
|
||||
doc = undefined;
|
||||
const opensBefore = api.opens();
|
||||
// 和 TA 对话 is a document load: module state resets, storages survive.
|
||||
doc = await openDocument(tab, assigned);
|
||||
await doc.settle(() => doc!.view().title !== null, "home to reveal");
|
||||
// Past the reveal: a background re-open of the old case would land here.
|
||||
for (let i = 0; i < 20; i += 1) await doc.h.idle();
|
||||
const view = doc.view();
|
||||
assert.equal(view.title, "新对话");
|
||||
assert.equal(view.rectificationSurface, false);
|
||||
assert.equal(view.inputDisabled, false);
|
||||
assert.notEqual(view.placeholder, "正在打开生时校正…");
|
||||
assert.doesNotMatch(view.url, /[?&]c=/, "the old session id is not written back into the URL");
|
||||
assert.ok(view.chartName.includes(person.row), `the new chat is bound to ${person.row}: ${view.chartName}`);
|
||||
assert.equal(api.opens(), opensBefore, "no rectification session is re-opened");
|
||||
assert.deepEqual(doc.errors, []);
|
||||
} finally {
|
||||
await doc?.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a stashed rectification session of the person you left is not reopened as a locked page", async () => {
|
||||
// Product screenshot state (BUG-1038): title = old rectification session,
|
||||
// composer「正在打开生时校正…」and disabled, starter greeting in the middle.
|
||||
const tab = createTab();
|
||||
const { api, restore } = installApi();
|
||||
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
|
||||
try {
|
||||
doc = await openRectificationThenGoTo(tab, api, "星盘档案");
|
||||
assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT);
|
||||
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("看星盘"));
|
||||
const assigned = tab.location.assigned.at(-1);
|
||||
assert.equal(assigned, "/chart");
|
||||
await doc.close();
|
||||
doc = undefined;
|
||||
doc = await openDocument(tab, "/chart");
|
||||
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), "the read-only account link");
|
||||
const opensBefore = api.opens();
|
||||
// Read-only footer: the account block links to bare `/`.
|
||||
const href = await doc.followLink("林遥,打开账户");
|
||||
assert.equal(href, "/");
|
||||
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.notEqual(view.placeholder, "正在打开生时校正…");
|
||||
assert.equal(view.inputDisabled, false);
|
||||
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.deepEqual(doc.errors, []);
|
||||
} finally {
|
||||
await doc?.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
const tab = createTab();
|
||||
tab.session.setItem("jyotisha.session-url-return", RECT);
|
||||
const { api, restore } = installApi({ listRectification: false });
|
||||
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
|
||||
try {
|
||||
doc = await openDocument(tab, "/");
|
||||
await doc.settle(() => doc!.view().rectificationSurface, "the stashed rectification session to open");
|
||||
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(tab.session.getItem("jyotisha.session-url-return"), null);
|
||||
assert.deepEqual(doc.errors, []);
|
||||
} finally {
|
||||
await doc?.close();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("an in-page new chat drops the stashed return, so a later bare / does not reopen the old session", async () => {
|
||||
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<typeof useSessionManagement>[0];
|
||||
const tab = createTab();
|
||||
tab.session.setItem("jyotisha.session-url-return", RECT);
|
||||
const h = createClientLifecycleHarness();
|
||||
const previous = {
|
||||
sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
|
||||
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
|
||||
};
|
||||
Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true });
|
||||
Object.defineProperty(globalThis, "requestAnimationFrame", { value: (callback: () => void) => setTimeout(callback, 0), configurable: true, writable: true });
|
||||
let api!: ReturnType<typeof useSessionManagement>;
|
||||
const noop = () => {};
|
||||
function Probe() {
|
||||
const [sessions, setSessions] = useState<Params["sessions"]>([]);
|
||||
const [active, setActive] = useState("");
|
||||
const popState = useRef<(search: string) => void>(noop);
|
||||
const known: Partial<Params> = {
|
||||
account: { user: { id: USER, email: null }, profile: emptyProfile, avatar: null, credits: 1, isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: false, hasUsableBirthTime: false },
|
||||
accountId: USER, activeSession: undefined, activeSessionId: active, activeSessionIdRef: { current: active },
|
||||
applySessionPopStateRef: popState, cancellationPending: false, chartLibrary: [], creatingSession: false,
|
||||
modelCatalog: previewModelCatalog, pendingSessionId: null, profile: emptyProfile, rectificationSessionId: null,
|
||||
sessionDetailInFlight: { current: new Set() }, sessionSelectionSource: { current: "user" },
|
||||
sessions, sessionsRef: { current: sessions }, setActiveSessionId: setActive, setSessions, uiPreview: { current: false },
|
||||
};
|
||||
const params = new Proxy(known, { get: (target, key) => Reflect.get(target, key) ?? noop }) as Params;
|
||||
api = useSessionManagement(params);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await h.render(<Probe />);
|
||||
let created: Awaited<ReturnType<typeof api.startNewChat>> = null;
|
||||
await h.update(() => { void api.startNewChat().then((session) => { created = session; }); });
|
||||
await h.idle();
|
||||
assert.ok(created, "the new chat is created");
|
||||
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
await h.close();
|
||||
for (const [key, descriptor] of Object.entries(previous)) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -64,6 +64,23 @@ test("lookup results: found keeps URL, 404 deletes, 5xx keeps URL", () => {
|
||||
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("cloud lookup and session management call the server before clearing ?c=", () => {
|
||||
const cloud = readFileSync(new URL("../src/lib/home-cloud-sync.ts", import.meta.url), "utf8");
|
||||
assert.match(cloud, /export async function lookupSessionById/);
|
||||
|
||||
Reference in New Issue
Block a user