From 48174a3cc55013fa2525ffc31aaa717606709394 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 12:03:26 +0000 Subject: [PATCH] docs(chat): add session URL routing task brief Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8 --- TASK-session-url-20260901.md | 94 ++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 TASK-session-url-20260901.md diff --git a/TASK-session-url-20260901.md b/TASK-session-url-20260901.md new file mode 100644 index 00000000..40204a0c --- /dev/null +++ b/TASK-session-url-20260901.md @@ -0,0 +1,94 @@ +# 任务书 · 会话 URL 化(2026-09-01) + +基线:`origin/staging` @ `b6989c3e`(开工时以 `origin/staging` 最新为准)。本轮是 BUG-464(消息服务端权威化)的直接后续,依赖该轮交付的 `GET /api/sessions/[id]` 详情接口与 `ensureSessionMessages` 按需加载。与任何同期改 `frontend/src/app/page.tsx` 的轮次**不得并行**。 + +--- + +## 为什么要做(事故实证) + +下面所有行号只是线索,按符号定位,`origin/staging` 上可能有偏移。 + +1. **会话选择是纯内存状态,URL 里没有任何痕迹。** `activeSessionId` 是 `useState("")`(`page.tsx:1285`),全仓 `frontend/src` 没有一处 `pushState` / `replaceState` / `useSearchParams`(grep 可证)。后果: + - **刷新丢位置**:无论用户在哪个会话,刷新后回到默认态(启动逻辑 `page.tsx:1904` 固定选 `nextSessions[0]`,即最近更新的会话)。 + - **浏览器返回键无意义**:在会话间切换不产生历史条目,back 直接离开站点。 + - **无深链**:用户无法把某个会话的地址发给自己另一台设备或收藏。 +2. **401 跳登录后回不到原会话。** `redirectToLogin`(`page.tsx:1120` 附近)`window.location.replace("/login")`,登录成功后 `email-otp-login.tsx` 用 `successPath` 落回 `/`(`email-otp-login.tsx:41`,类型是 `"/" | "/admin"` 字面量联合)——中途丢掉一切位置信息。 +3. 上一轮已具备的能力(本轮直接复用,不要重做):`GET /api/sessions/[id]` 详情接口、`ensureSessionMessages` 的按需加载与 `messagesHydrated` 内存缓存、`selectSession`(`page.tsx:2442`)这个唯一的用户切换入口。 + +## 决策记录(产品授权,2026-09-01) + +1. **采用 query 参数 `?c=`,不采用路径段 `/c/[sessionId]`。** 理由:路径段方案要求把 4557 行的 `Home` 组件抽成两个路由共享的模块,或改造 app 目录结构——重构风险远超本轮收益;query 方案让 `/` 保持现有路由形态与 `○ Static` 构建模式(上一轮优化成果,有合同测试依赖)。将来要好看的路径,可以加一个薄的 `/c/[id]` → `/?c=` redirect,不在本轮。 +2. **用原生 `window.history.pushState` / `replaceState` + `popstate`,不用 `router.push`。** Next 16 官方文档明确支持原生 History API 并与 Router 集成(见 `node_modules/next/dist/docs/01-app/01-getting-started/04-linking-and-navigating.md` 的 "Native History API" 一节,写代码前先读它)。`router.push` 到同路径不同 query 会走一次 Next 导航流程,本页所有状态本来就在客户端,纯 History API 足够且不惊动路由。 +3. **401 回跳用 sessionStorage 暂存,不给登录页加 `next` 参数。** `successPath` 的字面量联合类型是有意的防开放重定向设计,不拓宽它。`redirectToLogin` 前把当前 `?c=` 值存 sessionStorage,登录落回 `/` 后启动逻辑读取并恢复。登录页零改动。 +4. **默认选中不写 URL。** 用户没点过任何会话时(启动默认选中最近会话、或 starter home),地址栏保持干净的 `/`;只有用户主动切换/新建/打开校正时才 `pushState`。这样 back 键的语义是"回到上一个我主动去过的地方"。 + +## 硬红线 + +1. **`/` 的构建模式不得回归。** `next build` 后 `/` 必须仍是 `○ Static`;不得为读取 query 引入让页面转 `ƒ` 的用法(读参数用客户端 `location.search` / popstate,不要在服务端组件层碰 `searchParams`)。现有锁路由/样式隔离的合同测试必须保持绿灯且不被修改。 +2. **不得改 `email-otp-login.tsx` 的 `successPath` 类型或登录页逻辑**(决策记录 3 的方案不需要)。 +3. 不得手写 `useCallback` / `useMemo`。 +4. **不得修改既有测试断言** —— 例外仅限锁住"会话选择无 URL"这一缺陷本身的断言;须在断言上方注释原值与原因,并在 PROGRESS 单列。 +5. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。 +6. 测试总数不得低于基线 **2413**,且 fail=0、skipped=0(有 Docker 的环境)。无 Docker 时既有缺口为 24 条数据库/部署类失败 + 10 skipped(清单与 `b6989c3e` 一致),**必须逐条比对确认没有新增**。本轮不动数据库,`test:db` 不新增要求。 +7. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。 +8. 本轮无视觉改动;若新增提示文案(如"该对话不存在"),沿用现有 composer notice / toast 通道,浅色深色两套下检查。 + +让步顺序:功能与测试不回归 > 可验证的修复 > 代码整洁。 + +## 开工前置 + +```bash +git fetch origin --prune +git worktree add -b codex/session-url-20260901 \ + ../.worktrees/session-url-20260901 origin/staging +``` + +基线必须是 `origin/staging`。读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。**先读 `node_modules/next/dist/docs/01-app/01-getting-started/04-linking-and-navigating.md` 的 Native History API 一节再写代码。**改前在 `docs/BUG_HISTORY.md` 检索同类记录(BUG-464 是直接上游)。 + +**先读这几处再动手**: + +- `page.tsx` 的 `activeSessionId` 全部触点:1285(state)、1377(`?? sessions[0]` 兜底)、1904(启动默认选中)、1989/3271(恢复流程)、2351/2370(删除)、2413/2424(新建与失败回滚)、2442(`selectSession`,用户切换唯一入口)、3036(校正打开)、1795(uiPreview,**预览态不碰 URL**)。 +- `ensureSessionMessages`(2298 附近):详情加载已有 in-flight 去重与 hydrated 缓存,URL 恢复直接复用,不要另写加载逻辑。 +- `redirectToLogin`(1120 附近)。 + +## 任务分解 + +### 任务 1(P0)· URL 读写与历史导航 + +- 启动:解析 `location.search` 的 `c`。合法 UUID 且存在于列表 → 选中它(URL 保持不动);不存在/非法 → `replaceState` 清掉参数、走现有默认选中、composer notice 提示"该对话不存在或已被删除"。 +- 用户主动动作后写 URL:`selectSession` → `pushState('?c=')`;`startNewChat` 成功后 → `pushState`(create 失败回滚时 `replaceState` 回上一个状态);校正打开(3036)→ `pushState`。默认选中与恢复流程(1904/1989/3271)**不写 URL**(决策记录 4)。 +- 删除当前会话(2351/2370):切到兜底会话并 `replaceState`(有兜底会话写它的 id,没有则清参数),URL 不得留死链。 +- `popstate`:按事件里的 `?c=`(无参数 = 默认会话)执行与 `selectSession` 相同的切换,但**不得再 pushState**(用来源标志位区分,防历史条目翻倍);id 已不在列表时按"不存在"处理。 +- 流式进行中切换会话/back-forward:保持现有行为(生成继续、回来能看到),不得因 URL 逻辑中断 streaming。 +- `uiPreview` 模式(1795)不读不写 URL。 + +### 任务 2(P1)· 401 回跳恢复 + +- `redirectToLogin` 跳转前:当前 `?c=` 值(仅校验为 UUID 后)存 `sessionStorage`(读写包 try/catch)。 +- 登录成功落回 `/` 后的启动逻辑:URL 无 `c` 且 sessionStorage 有暂存 → 按暂存 id 走任务 1 的启动选中并 `replaceState` 写回 URL,随后清暂存;URL 已有 `c` 则忽略暂存。 +- 暂存的 id 已失效(会话被删)→ 走"不存在"分支,不报错。 + +### 任务 3(P1)· 合同测试 + +- 新增合同测试锁住:启动读参、popstate 不二次 push、删除后 URL 清理、默认选中不写 URL、`redirectToLogin` 暂存。样式参照本仓库既有的源码正则合同测试。 +- `next build` 路由模式断言若已有测试锁 `/` 为 Static,确认它仍绿;没有的话本轮补一条。 + +## 总验收 + +1. `./node_modules/.bin/tsc --noEmit` 通过;测试满足红线 6。 +2. `next build`:`/` 仍为 `○ Static`,写进 PROGRESS。 +3. 行为实测(有登录态环境逐条做,无登录态则用合同测试覆盖并在 PROGRESS 如实标注哪些没实测): + - 打开会话 A → 刷新 → 仍在 A,消息经详情接口恢复; + - 粘贴 `/?c=` 直达指定会话; + - A→B→C 后 back 回 B、再 back 回 A、forward 回 B,全程无整页刷新,已 hydrated 的会话不重复请求详情; + - 删除当前会话后地址栏无死链;伪造/他人会话 id 得到提示并回默认态; + - 流式生成中切走再 back 回来,生成未中断; + - 401 跳登录 → 登录成功 → 回到跳转前的会话。 + +## 明确不做(不要顺手做) + +- 不做 `/c/[sessionId]` 路径段或任何 app 目录结构调整。 +- 不拆 `page.tsx`(P2 另立项)。 +- 不动登录页、不加 `next` 回跳参数。 +- 不给报告页/会员页等其它 surface 加 URL 状态。 +- 不删 BUG-464 的 PATCH 兼容层(等观测确认无量后另一轮)。