From 8902e48468d19e59fbebb805917008f660310e78 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 24 Sep 2026 00:25:28 +0800 Subject: [PATCH] fix(chat): honor new-chat intent from secondary pages Create a fresh local consultation for explicit new-chat navigation and keep reserved recovery from taking over its landing. Add regression tests and record validation gaps for remote review. Co-Authored-By: Claude Code --- BLOCKED.md | 8 ++ CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 16 +++ docs/research/pre_work_error_ledger.md | 6 + ...RESS-secondary-new-chat-intent-20260923.md | 103 ++++++++++++++++++ docs/tasks/README.md | 2 +- ...TASK-secondary-new-chat-intent-20260923.md | 2 + docs/testing/secondary-new-chat-20260923.md | 30 +++++ frontend/DESIGN.md | 11 ++ frontend/src/app/(app)/page.tsx | 5 + frontend/src/components/app-sidebar.tsx | 4 +- frontend/src/lib/chat-session-url.ts | 21 +++- frontend/src/lib/home-bootstrap.ts | 5 +- frontend/tests/chat-session-url.test.ts | 53 +++++++++ frontend/tests/home-bootstrap-reveal.test.ts | 19 ++++ frontend/tests/new-chat-recovery.test.ts | 79 ++++++++++++++ frontend/tests/sidebar-contract.test.ts | 4 + 17 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md create mode 100644 docs/testing/secondary-new-chat-20260923.md create mode 100644 frontend/tests/new-chat-recovery.test.ts diff --git a/BLOCKED.md b/BLOCKED.md index 68bc1510..9de1742a 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,13 @@ # BLOCKED +## BUG-1015:次级页新建已本地修复,完整构建与真人验收待补(2026-09-23) + +- 本轮 `tsc --noEmit` 0 错、lint 0 error / 119 既有 warning、定向 95/95;全量基线 3708/3619pass/89fail → 最终 3717/3628pass/89fail,名称多重集合无删除、新增9全通过,失败名单不变。失败逐条诊断核对见 PROGRESS。 +- Docker 存在且 daemon 有响应;41 项数据库 fixture 在默认网络地址池耗尽(`all predefined address pools have been fully subnetted`)处失败,不是“无 Docker”。未清理其他会话网络或卷。 +- 默认 `npm run build` 受工作树 node_modules junction 超出 Turbopack root 限制;固定提交隔离基线与最终版均另跑 `npm run build -- --webpack`,编译及类型检查成功,但页面数据收集创建 Skill `SKILL.md` 符号链接报 `EPERM`。未改业务/配置/权限迎合构建,`/` Static 与首屏 gzip ±2% **未完成验证**。 +- 本轮没有受控登录浏览器验收证据;真机清单已写 `docs/testing/secondary-new-chat-20260923.md`,覆盖桌面/移动、新建→首问保存、刷新、c/存根优先级及旧 reserved 咨询恢复。没有借用账号、读取凭据或宣称部署通过。 +- 执行分支 `codex/secondary-new-chat-intent-20260923`;2026-09-24 产品授权先推 staging 供远程 review,交付以实际推送及远端 SHA 核对为准,部署尚待核验。BUG-1015 暂记 blocked,不遵照任务书预先写 resolved;待完整构建和受控 staging 验收后解除。详见 `docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md`。 + ## staging 门禁三项修复的验证边界(2026-09-23) - H1~H3 定向与真实组件 Chrome 验证通过;最终全量比基线原三项转绿、无新增失败,88项既有失败诊断不变。代码已推 staging `36a73761`;Gitea run 2857 已完成但总体失败,validate 唯一失败是 BUG-1011 的 Linux SQL argv `E2BIG`,publish skipped,因此尚未部署。BUG-1011 属于并行修复单,本单不改 postgres-fixture。 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1f740e..d820fcdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-09-23 — 次级页「新建对话」真正打开空会话(本地修复,待部署) + +- 星盘、星历、我的报告及报告详情页的「新建对话」现在进入新的空咨询,不再打开原来的会话。 +- 第一问之前不保存、不新增历史列表项;发送第一问后才保存并更新会话地址。普通回首页与刷新规则不变。 +- Skill 版本不 bump;真人走查见 `docs/testing/secondary-new-chat-20260923.md`。 + ## 2026-09-23 — 聊天选择器焦点与窄屏入口修复(staging,待 BUG-1011 门禁修复后部署) - 人物选择器关闭后焦点仍回到入口,不再因焦点恢复滚动聊天面板。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 350c45ef..aa4e9aaf 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13474,6 +13474,22 @@ - 复发自:聊天面板既有 focus 防滚动合同的新增组件遗漏。 - 修复版本:`36a737616c67a251610662ccab76845b193993fb`(staging;未部署)。 +## BUG-1015 | 次级页「新建对话」没有新建意图,回首页打开旧对话 + +- 状态:blocked(本地修复及针对性回归通过;完整构建、部署与受控真人走查未完成,不提前标 resolved) +- 首次发现:2026-09-23 +- 最近更新:2026-09-23 +- 影响面:星盘、星历、报告列表及详情页的只读侧栏新建入口与首页启动落点。 +- 用户现象:在次级页点击「新建对话」后进入旧会话,而不是空咨询。 +- 触发条件:只读侧栏缺少首页 controls,其新建入口原为无意图的 `/`;若还有旧 reserved 咨询,启动恢复还会主动激活旧会话。 +- 根因:回首页与新建共用无参数地址,首页按既有 URL/登录存根/默认选择规则落点;原任务书只补 URL 意图,独立验收又确认后台恢复在新建激活后覆盖活动会话。前者不是缓存或导航性能复发,后者是同一入口的组合路径遗漏。 +- 修复:唯一 URL helper 生成 `/?new=1`、按参数存在性识别,优先于 c 与登录存根;sessionHref 一律消费 new。首页复用已有本地 createSession 分支,激活前 replace 清除 new/c 并清存根;产品追加授权后,在旧咨询恢复完成的同步区块内恢复新建落点并清本次恢复提示,保留 pending/recovering,不取消后台任务。首问保存与列表过滤沿用 BUG-989。 +- 验证:URL/落点/侧栏及既有深链、列表、增长合同通过;新增动态执行真实恢复函数及首页装配区块的测试,new-chat 保持新落点、无 new 保持旧恢复行为。最终全量数字、独立验收及环境证据见 `docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md`。未以源码测试冒充登录浏览器或部署验收。 +- 防复发:保持 BUG-744 单侧栏、BUG-745/927 共享外壳、BUG-989 首问前不落库/不写 c/列表无 draft;新增优先级、参数消费、激活顺序、本地创建与 reserved 恢复冲突测试。原合同只锁导航方式及无参数默认落点,没有连接“新建”意图,故未拦住本次问题;既有断言未删除或弱化。 +- 相关记录:BUG-744、BUG-745、BUG-927、BUG-989、BUG-705、BUG-995;`TASK-secondary-new-chat-intent-20260923.md`(D7 为本轮追加授权)。 +- 复发自:无;与 BUG-745 同入口但不同根因。 +- 修复版本:本次提交(执行分支 `codex/secondary-new-chat-intent-20260923`,基线 `1c95eb397`);2026-09-24 产品授权推 staging 供远程 review,精确 SHA 以实际推送及远端核对为准,部署尚待核验。 + ## BUG-1014 | 输入框源码合同未跟上已删除人物的发送限制 - 状态:resolved(run 2857 的 H3 门禁通过;staging 总门禁仍因 BUG-1011 失败,尚未部署) diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 6a8a3e78..89706e01 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,12 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-09-24 · 次级页新建意图交付预检 + +- 产品授权推送供远程 review;fetch 与 pre_work_check 远端可见性均成功,推送仍须另核 SHA,不将远端可读等同写入成功。 +- Windows python3 launcher 退出49;已有 Python 3.11.7 可运行预检。适配器与碎片扫描步骤成功,focused 22通过/2失败:`.workbuddy` 镜像路径缺失断言复现;fragment audit candidate_count=4 与 workspace_residue_count=29 的数量约束不成立。未伪造镜像、未清理他人目录、未改测试,整体预检仍为 fail。 +- 本单前端最终定向95/95、全量新增9项通过且89项既有失败原因不变;Windows Skill symlink EPERM 导致完整构建/Static/gzip未闭环。详见 `docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md`,不以允许远程 review 冒充验收或部署通过。 + ## 2026-09-23 · staging 门禁修复交付的写认证复发 - 默认 Git 认证推送 `36a73761` 返回 `Failed to authenticate user`;本地提交成功不等于远端交付,首次失败后明确保留未推送状态。 diff --git a/docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md b/docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md new file mode 100644 index 00000000..32399cbd --- /dev/null +++ b/docs/tasks/PROGRESS-secondary-new-chat-intent-20260923.md @@ -0,0 +1,103 @@ +# PROGRESS · 次级页「新建对话」意图(2026-09-23) + +## 结论与交付边界 + +实现与定向独立复核完成;**完整验收尚未完成**。2026-09-24 产品明确要求先 push 供远程 review,本次提交交付 staging;推送成功与远端 SHA 以实际核对回报为准,不以提交本身冒充部署成功。不提升 main、不改 workflow/DNS。BUG-1015 暂记 blocked(已本地修复,构建/受控真人证据未闭环),不按任务书预先指定的 resolved 提前结案。 + +| 项目 | 值 | +| --- | --- | +| 任务书 | `docs/tasks/TASK-secondary-new-chat-intent-20260923.md` | +| 实测基线 | `origin/staging = 1c95eb397`(已 fetch;任务书原基线之后仅任务文档) | +| 执行分支 | `codex/secondary-new-chat-intent-20260923` | +| 工作树 | `.worktrees/secondary-new-chat-intent-20260923` | +| BUG 编号 | 开工最大 BUG-1014,本单 BUG-1015 | +| 固定构建基线 | 本工作树下 `.worktrees/secondary-new-chat-baseline-20260923`,detached `1c95eb397` | +| 原始日志 | 本地 `artifacts/secondary-new-chat/`,不纳入提交 | +| 真人清单 | `docs/testing/secondary-new-chat-20260923.md` | + +## 已实现 + +- T1:`chat-session-url.ts` 唯一生成/解析新建意图;参数按存在性识别(`?new=1`、`?new=`、`?new` 均有效),优先于合法/非法/未加载的 c 及登录存根,`missing=false`。所有 sessionHref 都消费 new,保留无关参数。 +- T2:首页复用原 starter 创建分支,不重复拼装会话,不新增状态/ref;激活前清 URL,按既有 clearStoredReturn 清存根。 +- T3:只改只读侧栏既有新建链接,仍经 SidebarMenuLink → AppLink,仍关闭抽屉;首页按钮、账户页脚、品牌标记不变。 +- T4:更新 BUG_HISTORY、CHANGELOG、DESIGN、任务状态板、BLOCKED、本进度与真机清单。没有新增产品文案,VOICE 无需修改;Skill 不 bump。 +- 补修 D7:旧 reserved 咨询仍执行真实恢复逻辑并保留 pending/recovering,之后新建意图重新取得活动落点并清启动恢复提示。无 new 时不改变恢复行为。 + +## 决策、勘误与偏离 + +1. **追加授权而非擅自破红线**:独立验收首次发现 `restoreConsultationRecovery()` 无条件激活旧会话,导致新建路径被覆盖。主会话向产品说明超出“一行”范围,产品明确选择“一并修复”。已在 TASK D7 记录:仅首页额外四行保护,首页总计 +5 行,无新增 state/ref,不改恢复 hook 或后台任务,不取消请求。 +2. T2“激活前清理”与“放在 replace-clear 邻行”原描述冲突;以明确时序为准,清理放在 setActiveSessionId 前。 +3. 当前品牌行无任务书所说的 AppLink,只是 div/span/strong,不补造入口。默认会话也可能受收藏及返回存根影响,不扩大修改无参数首页规则。 +4. 空历史启动已有兜底会创建一个本地空咨询,再执行新建分支会产生另一个本地空对象;两者均不落库、不显示于历史。本单按“new 恒新建”要求保留,不另改会话状态流。 +5. 任务书预写 resolved 与事实证据冲突:完整构建/真人/部署未完成,按 AGENTS 诚实边界暂记 blocked。 +6. 测试数量按 BUG-995 / frontend AGENTS 比较**名称多重集合**,不仅看汇总尾行。没有把既有失败当通过。 + +## 历史检索与保护 + +开工只读代理按现象、入口、模块及 `/api/sessions` 检索并完整读取相关记录:BUG-744、745、927、928、987、989、705、967、995 等。 + +- BUG-744 单一侧栏与只读同构仍由 sidebar-contract 守护。 +- BUG-745 的旧 sidebar-data-cache 已被 BUG-927 共享 `(app)` / SessionListProvider 替代,不恢复旧缓存;客户端导航、provider 生命周期合同仍在。 +- BUG-989 本地新建、首问 persistSession(create)、创建成功才写 c、列表无 draft 与咨询专用过滤均仍在。 +- BUG-705 未列出合法深链仍 lookup,不受 new 分支之外的行为影响。 +- 本次原测试没拦住:只锁住了“导航方式”和“无参数默认落点”,没有验证跨页新建意图;补上组合与真实恢复冲突验证。 + +## 验证数字 + +| 检查 | 改动前基线 | 最终补修后 | 结论 | +| --- | ---: | ---: | --- | +| tsc --noEmit | 0 错 | 0 错 | 通过 | +| npm run lint | 0 error / 119 warning | 0 error / 119 warning | 通过;不顺手修 warning | +| 全量测试总数 | 3708 | 3717 | +9 | +| 全量 pass | 3619 | 3628 | +9 | +| 全量 fail | 89 | 89 | 失败名单无增减,不是全绿 | +| cancelled / skipped | 0 / 0 | 0 / 0 | 不变 | +| 顶层 TAP 名称 | 3690 | 3699 | 删除0、新增9 | +| 全部含子测试名称 | 3708 | 3717 | 多重集合删除0、新增9,新增全通过 | +| 本轮定向(8文件) | — | 95 pass / 0 fail | 通过 | +| 独立新增恢复测试 | — | 2 pass / 0 fail | 通过 | +| 独立内存变异(3种) | — | 每种1 pass / 1预期fail | 删除保护/只删落点/只删提示清理均被捕获 | +| 首页增长 | 基线 | +5行 / state+0 / ref+0 | D7已授权,增长合同通过 | +| next build Static | 未完成 | 未完成 | 环境缺口 | +| 首屏 gzip ±2% | 未测得 | 未测得 | 环境缺口,不报推测数字 | + +命令及日志: + +- `./node_modules/.bin/tsc --noEmit` → `baseline-tsc.log` / `final2-tsc.log`。 +- `npm run lint` → `baseline-lint.log` / `final2-lint.log`。 +- `npm test` → `baseline-test.log` / `final2-test.log`;`compare-tests.cjs final2` → `test-comparison.json`(名称、失败名、基础诊断字段)。 +- 定向:chat-session-url、home-bootstrap-reveal、sidebar-contract、new-chat-recovery、session-lookup-unlisted、session-list-filter、chat-session-authority、home-shell-growth-contract → `final2-focused.log`。 +- 最初先加测试验证红灯:未实现新导出导致URL测试无法载入,落点/只读链接断言失败;随后实现转绿。未将该次红灯当作“全部9条都单独复现”。 +- 第一次备用基线构建与新增测试编辑重叠,在类型阶段读到尚未实现的新导出,**该次日志无效,不充当基线**;已另建固定提交隔离工作树重新构建作为有效对照。 + +最终 `baseline-test.log` 与 `final2-test.log` 已独立逐项复核:89/89 完整 TAP 失败诊断(多行 error、expected/actual/operator、code、failureType、完整栈)在只归一化耗时、PID/时间戳、随机临时目录及经 SHA-256 验证的路径派生哈希后相同,未抹栈或行号。既有测试状态变化0。code分布两侧均 ERR_TEST_FAILURE49 / ERR_ASSERTION32 / ENOENT4 / EPERM4。41/41 Docker 项目均对应各自 daemon 地址池耗尽输出;4个文件加载前置stderr归一化后也一致。原始完整诊断有51项随机值变化;Compose附带进度/包装有6/41项差异,但失败原因不变,不宣称原文逐字一致。 + +| 最终既有失败类别 | 数量 | +| --- | ---: | +| Docker默认网络地址池耗尽 | 41 | +| symlink EPERM(含子进程/文件加载) | 13 | +| Windows重复盘符路径ENOENT | 4 | +| methodology解析断言 | 9 | +| 迁移读取到链接目标字符串而非SQL | 5 | +| 其他既有脚本/环境断言 | 17 | +| 合计 | 89 | + +其余17项涉及备份边界6、数据库环境校验3、部署环境校验3、缺rsync2、install权限1、YAML子进程9009共1、docs-only-range子进程49共1;日志不足以进一步确定所有退出码底因,不擅自修复或笼统归因。 + +## 既有断言变更表 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 只读侧栏测试仅守同构、无菜单、页脚 `/`、无写请求 | 保留全部原断言,追加 `.new-chat` 必须调用 newChatHref 且关闭抽屉、不得字面量 `/` | 锁定新建语义,不弱化旧合同 | +| 其余既有断言 | 不变 | 新增9个测试,无删改或重命名既有用例;sibling 参数原断言也未改 | + +新增测试:URL 5、落点2、reserved恢复2;恢复测试执行真实函数与首页区块,不仅做正则存在性断言。 + +## 环境缺口与后续验收 + +1. 复用本机 node_modules junction;根工作树 lock 与基线 lock 文件不同,但逐项核对顶层已安装非可选包版本与本单 lock 相符,没有安装/升级依赖。默认 Turbopack 报依赖链接超出 filesystem root;没有为此改 next.config。 +2. 固定基线与最终均补跑官方 `npm run build -- --webpack`。编译及构建内类型检查成功,页面数据收集创建 Skill SKILL.md 符号链接报 EPERM。有效基线日志 `baseline-isolated-build.log`、最终 `final2-build.log`。不修改 Skill 权限或禁用检查,Static/gzip 缺口保留。 +3. Docker daemon 有响应但默认网络地址池耗尽;数据库 fixture 无法创建网络,未真正运行SQL。本单无数据库变更,不运行单独 test:db、不清理共享网络。 +4. 没有本轮受控登录浏览器证据,移动抽屉/首问网络/真实恢复按真人清单补验。存在本机浏览器与否不等于已有受控登录态,不冒称“无Chrome”或“浏览器通过”。 +5. 2026-09-23 实现完成时未 commit/push;2026-09-24 产品明确授权推送供远程 review,已重新 fetch,远端仍为 `1c95eb397`,BUG-1015 未被其他单占用,符合快进交付前置条件。此次只提交本单代码/测试/记录,不包含 artifacts 或依赖链接;Gitea 门禁及部署仍须独立核验。 +6. 推送前按 §9 运行 pre_work_check:本机 python3 launcher 退出49;改用已有 Python 3.11.7 执行,远端 verified、适配器与碎片扫描步骤成功,但 focused 22通过/2失败:既有 `.workbuddy` 镜像路径断言,以及 candidate_count=4 不满足 workspace_residue_count=29 的数量断言。未伪造镜像或改治理测试以消红;不把整体预检写成通过。日志在本地 `push-pre-work-python.log`,同步追加错误台账。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 2d3c110b..d57be9f3 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -117,7 +117,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-secondary-new-chat-intent-20260923.md` | — | **次级页「新建对话」落到最近一次对话**:`/chart` `/ephemeris` `/reports` 的侧栏没有 `controls`,「新建对话」只是 `href="/"` 的回首页链接;首页无参启动的落点是 `nextSessions[0]`(最近更新那条),只有最近一条是校正会话才改落空咨询。BUG-745 修过同一入口的「慢」,没修「回到哪」。产品 09-23 拍板:链接改 `/?new=1`,首页看到 `new` 就本地建空咨询并 `replaceState` 抹掉参数;`new` 胜过 `c` 与登录返回存根;沿用 BUG-989 不落库不写 `?c=`;页脚与品牌行的 `/` 不改;刷新无参 `/` 仍落最近对话属既有设计。红线:`/` 保持 Static、不得用 `useSearchParams`;解析与生成只在 `chat-session-url.ts` 一处。BUG-1015 | 待领取 | — | +| `TASK-secondary-new-chat-intent-20260923.md` | `PROGRESS-secondary-new-chat-intent-20260923.md` | **次级页「新建对话」落到最近一次对话**:`/chart` `/ephemeris` `/reports` 的侧栏没有 `controls`,「新建对话」只是 `href="/"` 的回首页链接;首页无参启动的落点是 `nextSessions[0]`(最近更新那条),只有最近一条是校正会话才改落空咨询。BUG-745 修过同一入口的「慢」,没修「回到哪」。产品 09-23 拍板:链接改 `/?new=1`,首页看到 `new` 就本地建空咨询并 `replaceState` 抹掉参数;`new` 胜过 `c` 与登录返回存根;沿用 BUG-989 不落库不写 `?c=`;页脚与品牌行的 `/` 不改;刷新无参 `/` 仍落最近对话属既有设计。红线:`/` 保持 Static、不得用 `useSearchParams`;解析与生成只在 `chat-session-url.ts` 一处。BUG-1015;执行中追加 D7:保留旧任务恢复但不抢新建落点。 | 待验收(本地定向通过;构建/真人缺口) | 本次提交(`codex/secondary-new-chat-intent-20260923`);09-24 授权推 staging 供 review,远端 SHA 以交付回报为准,部署待核验 | | `TASK-chat-message-authority-20260901.md` | — | 消息服务端权威化 | 已验收 | `b6989c3e`(BUG-464) | | `TASK-session-url-20260901.md` | — | 会话 URL 化 | 已验收 | `924f4202` | | `TASK-cloud-truth-convergence-20260901.md` | — | 本地/云端双份真相收敛 | 已验收 | `ce6a8a7e`(BUG-466) | diff --git a/docs/tasks/TASK-secondary-new-chat-intent-20260923.md b/docs/tasks/TASK-secondary-new-chat-intent-20260923.md index 71937e67..29905fb2 100644 --- a/docs/tasks/TASK-secondary-new-chat-intent-20260923.md +++ b/docs/tasks/TASK-secondary-new-chat-intent-20260923.md @@ -33,6 +33,8 @@ - **D4** 优先级:`new` 胜过 `c` 与登录返回存根(`storedReturnId`)。带 `new` 时忽略两者,并清掉存根(它只服务于登录后回到原会话,用户既然点了新建,存根已过时)。 - **D5** 不改的:侧栏页脚 `.profile-trigger` 与品牌行两个 `href="/"` 仍是"回首页",落点规则不变;首页自身按钮不变;`BUG-989` 的三条防复发不变。 - **D6** 不在本单:刷新 `/`(无参数)仍落最近对话——这是既有设计,不是本单事故。 +- **D7(执行中追加授权,2026-09-23)** 独立验收发现:存在旧 `reserved` 咨询时,`restoreConsultationRecovery()` 会把新建落点改回旧咨询。产品明确选择「一并修复」,授权首页在恢复后增加 `new-chat` 落点保护并清除此次恢复提示,保留旧任务的 pending/recovering 能力,不取消任务、不改恢复 hook。本授权有限扩展红线 5 / T2「首页只加一行」:允许额外一个四行保护块(首页合计 +5 行、state/ref 不增),必须补动态执行真实恢复函数与首页装配段的回归测试;无 `new` 的恢复行为不变。 +- **执行勘误**:当前品牌行是 `div` + `span/strong`,没有 D5 所述链接;保持现状,不补造入口。首页无参数还受收藏顺序及登录存根影响,本单不更改这些规则。URL 清理优先满足 T2「激活之前」时序,不拘泥于位于激活之后的 `replace-clear` 邻行。 ## 硬红线 diff --git a/docs/testing/secondary-new-chat-20260923.md b/docs/testing/secondary-new-chat-20260923.md new file mode 100644 index 00000000..e43f77d7 --- /dev/null +++ b/docs/testing/secondary-new-chat-20260923.md @@ -0,0 +1,30 @@ +# 次级页「新建对话」真人验收(2026-09-23) + +关联:`TASK-secondary-new-chat-intent-20260923.md`、BUG-1015。 + +## 前置与记录边界 + +- 本清单尚未执行,不以源码合同或纯函数测试代替真实登录走查。 +- 在本轮修复已部署的 staging 使用受控测试账号;先核对 `/api/health` 的部署提交。不借用任何他人的登录态。 +- 使用明确虚构的资料与对话。测试账号至少有一条已保存的普通咨询;记录列表项数量,不记录账号、会话 ID、出生信息或完整请求体。 +- 用浏览器网络面板核对请求方法和路径即可,不保存 Cookie、JWT 或请求正文。每个场景记录通过/未通过、浏览器、日期和脱敏现象。 + +## 必验 + +| 步骤 | 操作 | 预期 | +| --- | --- | --- | +| 1 | 从已有普通咨询进入 `/chart`,点侧栏「新建对话」 | 首页为空会话,不显示旧消息;地址为 `/`,无 `new`、无 `c`;历史列表不增加;没有创建会话的 `POST /api/sessions`,无“该对话不存在或已被删除”提示 | +| 2 | 在步骤 1 的空会话发送第一问 | 走既有创建流程;成功后地址为 `/?c=<新会话>`,历史顶部出现新会话;输入与回复正常 | +| 3 | 重做步骤 1,不发消息,直接刷新 | 按无参数首页的既有规则恢复会话,不再次执行新建意图;不是继续保留上一步未保存空会话。无收藏/存根干扰时回最近普通咨询(D6) | +| 4 | 在 `/ephemeris`、`/reports`、`/reports/<受控测试报告>` 分别重复步骤 1 | 与步骤 1 一致 | +| 5 | 移动端打开次级页抽屉,点「新建对话」 | 与步骤 1 一致,抽屉关闭;没有新入口、额外等待动画 | +| 6 | 已有登录返回存根时,访问 `/?new=1&c=<已有会话>`;另测 c 为格式错误值 | 新建意图胜出,打开空咨询;清除两种参数及存根;不恢复旧会话,不提示会话缺失 | +| 7 | 次级页点击账户页脚返回 `/`;首页点击自己的「新建对话」 | 页脚仍走原回首页规则;首页按钮仍走原本地新建流程 | +| 8 | 单独访问有效 `/?c=<已有会话>`(不带 new),并浏览器后退/前进 | 深链仍打开指定会话;没有被本次新建规则抢走;URL 历史仍按原设计工作 | +| 9 | 在旧咨询仍后台生成时进入次级页,再点「新建对话」 | 仍停留新空咨询;旧任务继续恢复但不抢回活动会话,启动恢复提示不留在空会话上;无 `new` 正常回首页时仍恢复旧咨询 | + +## 边界复核 + +- 刚注册、没有历史的受控账号进入新建入口仍只有一个可见空会话,首问前不写数据库、不显示空列表行。启动内部已有的空列表兜底与本单新建分支可能分别产生本地空对象;两者都必须隐藏且不持久化。 +- 普通刷新 `/` 不承诺“始终空白”,本单不更改默认落点规则。 +- 需要模型凭据才能完成步骤 2 的真实回复;若不可用,只记录创建链的实际证据,将回复部分标环境缺口,不伪写通过。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f7d882d1..5d222eec 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -456,6 +456,17 @@ one skeleton, none of them carrying the sidebar. `/?c=`; the footer is a link to `/`. Renaming, pinning, archiving and deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback layer, which is far more than these four routes need. +- **New chat is not a home link.** The read-only 新建对话 action links to + `/?new=1`; Home consumes that intent before activation and opens a fresh local + consultation. It clears `new`, any `c`, and the login-return stub. Before the + first question there is no saved session, no `?c=`, and no new history row. + The first question uses the existing persistence flow. A reserved older + consultation keeps recovering in the background without taking over this + explicit new-chat landing or leaving its startup recovery notice there. + The account footer + still links to `/`, and a bare-home refresh keeps its existing landing rule. + The same action and mobile drawer-close behavior are retained; no extra + button, copy, loading state, or visual treatment is introduced. - **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are `AppLink` on every page, including `/`, where they used to be a full document load that threw away the React tree, the session list and the account. Only diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index f091d37d..bb75fe1f 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -1062,6 +1062,7 @@ export default function Home() { setStartGreeting(nextProfile.name.trim() ? createStartGreeting(nextProfile.name) : ""); setOnboardingStep(missingProfileStep(nextProfile) ?? "name"); setSessions(nextSessions); + if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace"); setActiveSessionId(landingSessionId); if (bootstrapSelection.clearStoredReturn) clearLoginSessionReturn(); if (bootstrapSelection.urlAction === "replace-clear") writeSessionUrl(null, "replace"); @@ -1074,6 +1075,10 @@ export default function Home() { 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") { + setActiveSessionId(landingSessionId); + setComposerNotice(""); + } } if (modelCatalogResult.unavailable) { setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 9adafaa7..0fcfe6b2 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 { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; +import { newChatHref, persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; import { prefetchSecondaryPage } from "@/lib/secondary-page-data"; import { Sidebar, @@ -248,7 +248,7 @@ export function AppSidebar({ {showExpandedContent ? {creatingSession ? "正在创建" : "新建对话"} : null} ) : ( - + diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts index fdc70ccb..a8006c63 100644 --- a/frontend/src/lib/chat-session-url.ts +++ b/frontend/src/lib/chat-session-url.ts @@ -1,4 +1,5 @@ export const SESSION_URL_QUERY_KEY = "c"; +export const NEW_CHAT_QUERY_KEY = "new"; export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return"; export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除"; export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。"; @@ -11,11 +12,20 @@ export type SessionUrlQuery = { export type BootstrapSessionSelection = { readonly sessionId: string; - readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup"; + readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat"; readonly missing: boolean; readonly clearStoredReturn: boolean; }; +export function newChatHref(): string { + return "/?new=1"; +} + +export function parseNewChatIntent(search: string): boolean { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + return params.has(NEW_CHAT_QUERY_KEY); +} + export function parseSessionUrlQuery(search: string): SessionUrlQuery { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); if (!params.has(SESSION_URL_QUERY_KEY)) { @@ -30,6 +40,7 @@ export function parseSessionUrlQuery(search: string): SessionUrlQuery { export function sessionHref(search: string, sessionId: string | null, pathname = "/"): string { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + params.delete(NEW_CHAT_QUERY_KEY); if (sessionId) params.set(SESSION_URL_QUERY_KEY, sessionId); else params.delete(SESSION_URL_QUERY_KEY); const query = params.toString(); @@ -79,6 +90,14 @@ export function resolveBootstrapSessionSelection(input: { readonly search: string; readonly storedReturnId: string | null; }): BootstrapSessionSelection { + if (parseNewChatIntent(input.search)) { + return { + sessionId: input.defaultSessionId, + urlAction: "new-chat", + missing: false, + clearStoredReturn: true, + }; + } const query = parseSessionUrlQuery(input.search); if (query.present) { if (query.sessionId && input.listedIds.includes(query.sessionId)) { diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index fe6fdf03..b3cb3656 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -18,7 +18,7 @@ export type LandingSession = Readonly<{ messages: readonly unknown[]; }>; -export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup"; +export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat"; /** * A rectification Case is opened only when the address bar names that session @@ -43,7 +43,7 @@ export function resolveStarterHomeLandingSessionId( selectedId: string, urlAction: LandingUrlAction, ): string { - if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") { + if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup" || urlAction === "new-chat") { return selectedId; } const selected = sessions.find((session) => session.id === selectedId); @@ -61,6 +61,7 @@ export function starterHomeLandingNeedsConsultation( landingId: string, urlAction: LandingUrlAction, ): boolean { + if (urlAction === "new-chat") return true; if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") return false; const landing = sessions.find((session) => session.id === landingId); return !landing || landing.sessionType === "birth_time_rectification"; diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index 1c2e97a1..a952e365 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -3,6 +3,9 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { + NEW_CHAT_QUERY_KEY, + newChatHref, + parseNewChatIntent, SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE, SESSION_URL_QUERY_KEY, @@ -30,6 +33,56 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) { return source.slice(start, end); } +test("new chat intent is presence-based and has one canonical href", () => { + assert.equal(NEW_CHAT_QUERY_KEY, "new"); + assert.equal(newChatHref(), "/?new=1"); + for (const search of ["?new=1", "?new=", "?new", "new=0"]) { + assert.equal(parseNewChatIntent(search), true); + } + assert.equal(parseNewChatIntent(""), false); + assert.equal(parseNewChatIntent("?renew=1"), false); +}); + +test("new chat intent overrides listed, unlisted and invalid c plus login return", () => { + for (const requestedId of [sessionB, sessionC, "not-a-uuid"]) { + assert.deepEqual(resolveBootstrapSessionSelection({ + listedIds: [sessionA, sessionB], + defaultSessionId: sessionA, + search: `?new=1&c=${requestedId}`, + storedReturnId: sessionB, + }), { + sessionId: sessionA, + urlAction: "new-chat", + missing: false, + clearStoredReturn: true, + }); + } +}); + +test("clearing a new chat intent removes c but preserves unrelated query keys", () => { + assert.equal(sessionHref("?new=1&x=1", null), "/?x=1"); + assert.equal(sessionHref(`?new=&c=${sessionA}&x=1`, null), "/?x=1"); + assert.equal(sessionHref("?new=1", null), "/"); +}); + +test("persisting a new chat replaces the intent with the saved session id", () => { + assert.equal(sessionHref("?new=1", sessionA), `/?c=${sessionA}`); + assert.equal(sessionHref(`?new=1&c=${sessionB}`, sessionA), `/?c=${sessionA}`); +}); + +test("new chat bootstrap consumes the URL before activation and reuses local-only creation", () => { + const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();"); + 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\(\)/); + const landing = sourceBetween(bootstrap, "if (starterHomeLandingNeedsConsultation(", "const activeListed"); + assert.equal((landing.match(/createSession\(/g) ?? []).length, 1); + assert.match(landing, /nextSessions = \[homeSession, \.\.\.nextSessions\]/); + assert.match(landing, /landingSessionId = homeSession\.id/); + assert.doesNotMatch(landing, /persistSession|writeChatSession|fetch\(|writeSessionUrl/); +}); + test("bootstrap reads a listed ?c= session and ignores login storage", () => { assert.deepEqual( resolveBootstrapSessionSelection({ diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index 387f8497..e72d940c 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -84,6 +84,25 @@ test("default landing replaces a latest rectification session with an empty cons assert.match(page, /starterHomeLandingNeedsConsultation\(/); }); +test("new chat landing requests a fresh consultation even with an existing ordinary chat", () => { + const sessions = [ + { id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }, + { id: emptyConsultationId, sessionType: "consultation", messages: [] }, + { id: rectificationId, sessionType: "birth_time_rectification", messages: [] }, + ]; + for (const session of sessions) { + assert.equal(resolveStarterHomeLandingSessionId(sessions, session.id, "new-chat"), session.id); + assert.equal(starterHomeLandingNeedsConsultation(sessions, session.id, "new-chat"), true); + } + assert.equal(starterHomeLandingNeedsConsultation([], consultationId, "new-chat"), true); +}); + +test("bare home still opens the selected ordinary chat without creating another", () => { + const sessions = [{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }]; + assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId); + assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), false); +}); + test("prepare phase settles only when every applicable item has an answer", () => { // 原值: BootstrapPrepareState 含 profileComplete + onboardingSettled,未完成建议问题不得揭幕。 // 新值: 揭幕只等入口摘要、今日星语(若适用)与校正会话(若适用)。 diff --git a/frontend/tests/new-chat-recovery.test.ts b/frontend/tests/new-chat-recovery.test.ts new file mode 100644 index 00000000..7e05a9bb --- /dev/null +++ b/frontend/tests/new-chat-recovery.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import ts from "typescript"; + +import { createSession } from "../src/lib/home-cloud-sync.ts"; +import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts"; + +const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); +const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8"); + +function sourceBetween(source: string, start: string, end: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + assert.ok(from >= 0 && to > from, `missing source boundary: ${start}`); + return source.slice(from, to); +} + +function bootstrapWithRecovery(urlAction: "new-chat" | "none") { + // Exercise the real recovery function and Home's activation block together. + // The synthetic ordinary chat has no engine payload or private user data. + const oldSession = createSession("test-model"); + oldSession.messages = [{ role: "user", text: "测试问题" }]; + const localSession = createSession("test-model"); + const pending: { current: { sessionId: string; phase: string } | null } = { current: null }; + const state = { active: "", pendingSession: "", pendingRequest: "", phase: "", notice: "", storedCleared: false }; + const writes: unknown[][] = []; + const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current ="); + const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (modelCatalogResult.unavailable)"); + const compiled = ts.transpileModule(`${recovery}\n${activation}`, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }).outputText; + runInNewContext(compiled, { + AbortController, + navigator: { onLine: true }, + pendingConsultation: pending, + bootstrapSelection: { urlAction, clearStoredReturn: urlAction === "new-chat" }, + landingSessionId: localSession.id, + nextSessions: [localSession, oldSession], + reservedConsultation: { status: "reserved", sessionId: oldSession.id, requestId: "test-request" }, + storedPending: null, + consultSendBlockedByRectificationSession: () => false, + isUnsavedEmptyConsultation, + setActiveSessionId: (id: string) => { state.active = id; }, + setPendingSessionId: (id: string) => { state.pendingSession = id; }, + setPendingRequestId: (id: string) => { state.pendingRequest = id; }, + setConsultationPhase: (phase: string) => { state.phase = phase; }, + setComposerNotice: (notice: string) => { state.notice = notice; }, + setStreamingReply: () => {}, + clearLoginSessionReturn: () => { state.storedCleared = true; }, + writeSessionUrl: (...args: unknown[]) => { writes.push(args); }, + updateSession: () => { assert.fail("the existing user question must not be duplicated"); }, + }); + return { state, pending, writes, localSession, oldSession }; +} + +test("new chat keeps its local landing while a reserved older consultation recovers", () => { + const { state, pending, writes, localSession, oldSession } = bootstrapWithRecovery("new-chat"); + assert.equal(state.active, localSession.id); + assert.equal(state.notice, ""); + assert.equal(state.storedCleared, true); + assert.deepEqual(writes, [[null, "replace"]]); + assert.equal(isUnsavedEmptyConsultation(localSession), true); + assert.equal(state.pendingSession, oldSession.id); + assert.equal(state.pendingRequest, "test-request"); + assert.equal(state.phase, "recovering"); + assert.equal(pending.current?.sessionId, oldSession.id); + assert.equal(pending.current?.phase, "recovering"); +}); + +test("ordinary home landing still activates and announces the reserved consultation", () => { + const { state, pending, writes, oldSession } = bootstrapWithRecovery("none"); + 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/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 525ca324..cc597aa5 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -238,6 +238,10 @@ test("the same component renders read-only when `/` is not the one mounting it", const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,")); assert.doesNotMatch(readonlyRow, /session-menu-trigger/); + // Only the new-chat action carries intent; the footer remains a home link. + assert.match(appSidebar, /]*href=\{newChatHref\(\)\}[^>]*onClick=\{closeDrawer\}/); + assert.doesNotMatch(appSidebar, /]*href="\/"/); + // The footer is the same 56px `.profile-trigger`, as a link with no chevron. assert.match(appSidebar, /