Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5b0a214b | ||
|
|
38cb81ff75 | ||
|
|
1c1d6817f5 | ||
|
|
cc1a89802e | ||
|
|
b5e16b96f4 | ||
|
|
11c0028d59 |
@@ -1,5 +1,11 @@
|
||||
# BLOCKED
|
||||
|
||||
## 会话列表:元数据操作未上移到 provider(2026-09-17,BUG-927 让步)
|
||||
|
||||
- **让步:** 任务书 T2 允许第一步只做「路由组 + layout 常驻 + provider 拥有列表 + 首页注册全部控制」。改名 / 置顶 / 归档 / 删除 / 翻页仍由首页注册,未挂载首页时侧栏只读。
|
||||
- **现状:** 四个页面共用一份列表、切页不重拉已落地。星盘 / 星历 / 报告页不能改名或删除。
|
||||
- **解除需要:** 把只依赖 `sessions` + fetch 的元数据操作搬进 `SessionListProvider`,四页都能用。另立单。
|
||||
|
||||
## 会话列表:空咨询延迟落库未做(2026-09-17,BUG-928)
|
||||
|
||||
- **让步:** 任务书 T3 要把 `startNewChat` / 启动落点改成本地创建、第一问前才 `POST /api/sessions`。牵动 `?c=` 深链和刷新恢复,本轮只做服务端过滤 + 复用已有空咨询。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-17 — 回答从开头开始读:发送后问题钉在顶部,回答向下生长,长出视口时显示跳到最新
|
||||
|
||||
主会话和生时校正发出新一轮后,视口停在这一轮的开头(你刚问的那一行,或校正里点选项后的新助手行),回答在下面往下长,不再跟着最后一个字往下跑。超过一屏时出现「跳到最新」,按下才跟到结尾。短回答下面会留一段空白,好让问题留在顶部。换到另一段对话仍先看到最新内容。Skill 版本不变。
|
||||
|
||||
## 2026-09-17 — 四个页面共用一份会话列表,切页不再重拉
|
||||
|
||||
「星盘」「星历」「我的报告」和对话页共用同一条侧栏。打开一次之后,在这四个页面之间切换不会再重新去拉会话列表,改名也会马上在各页看到。没说过话的「新对话」不再进列表。新开的生时校正和今日节奏标题改成「生时校正 · 9月14日」这种类别在前的写法,侧栏多一行创建时间。Skill 版本不变。
|
||||
|
||||
+100
-1
@@ -774,6 +774,7 @@
|
||||
- 相关记录:BUG-039、BUG-040
|
||||
- 复发自:无
|
||||
- 修复版本:本次自动滚动修复提交
|
||||
- 2026-09-17 语义被 BUG-930 取代:生成中改为定位到本轮开头,不再贴底跟随。
|
||||
|
||||
## BUG-240 | 非评分回答绕过 Agent 并显示固定澄清模板
|
||||
|
||||
@@ -975,6 +976,7 @@
|
||||
- 防复发:新增独立聊天滚动容器时必须同时提供末尾锚点,并覆盖乐观用户消息、生成状态和回答文本更新三类触发源。
|
||||
- 相关记录:BUG-046
|
||||
- 修复版本:待提交(本地可测)
|
||||
- 2026-09-17 语义被 BUG-930 取代:生成中改为定位到本轮开头,不再贴底跟随。
|
||||
|
||||
## BUG-049 | 生时校正 Agent 提问悬空截断且回答缺少消息操作
|
||||
|
||||
@@ -12094,7 +12096,7 @@
|
||||
- 根因:`/` 不在 `(secondary)` 路由组,离开即卸载 Home;次级页另用 `useSidebarData` 再拉一遍。
|
||||
- 修复:四个页面进 `(app)` 路由组。一份 `SessionListProvider` 拉 `/api/sessions` 与 `/api/account`。首页不再自己 `fetchSessions`/`fetchAccount`。侧栏不随切页卸载。按任务书让步,改名/置顶/删除仍由首页注册,未注册时侧栏只读。
|
||||
- 验证:`session-list-provider.test.ts`、`sidebar-contract.test.ts`、`chart-page-view.test.tsx`。
|
||||
- 防复发:`app/` 里 `SidebarProvider` 只许出现在 `(app)/layout.tsx`。
|
||||
- 防复发:`app/` 里 `SidebarProvider` 只许出现在 `(app)/layout.tsx`。外壳搬家必须同步搬走针对 `page.tsx` 的源码合同,且交付前必须跑全量 `npm test` 与 `tsc --noEmit`,不得只跑定向。
|
||||
- 相关记录:BUG-745、BUG-926
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
@@ -12130,3 +12132,100 @@
|
||||
- 相关记录:BUG-699、BUG-557
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-930 | 主会话和生时校正回答落在结尾,读不到开头
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`useConversationScrollAnchor`、主会话 `send()`、生时校正面提交 / 点选项 / 开场、`JumpToLatestButton`
|
||||
- 用户现象:问一个问题后视口钉在回答最后一个字上,要读回答得不断上滑找开头。生时校正长旁白同样把本轮开头推出视口。
|
||||
- 触发条件:主会话或生时校正发出一轮,模型输出超过一屏。
|
||||
- 根因:跟随语义是「贴底」。`anchored` 初值为真,`ResizeObserver` 在内容变高时把 `scrollTop` 设到 `scrollHeight`;`send()` 还会 `anchorToLatest()`。对短回答两者等价,对长回答则把开头推出视口。
|
||||
- 修复:同一 hook 改为新一轮把本轮开头(用户行,或没有用户行时的新助手行)钉在滚动容器顶部,流式期间不再跟随。回答长出视口时用已有的「跳到最新」贴底并恢复跟随。末尾动态留白让短回答也能把开头留在顶部。切换会话仍先落底。取代 BUG-041 / BUG-048 的贴底语义。
|
||||
- 验证:`chat-notice-and-scroll-contract.test.ts` 源码合同与行为测试;校正面 `pinLatestTurn` 三处源码断言。验收方用 Chrome 真实布局骨架页跑过 S1–S4(长历史发问钉顶、短历史留白、先上滑再发问、切会话落底)。S5/S6 见 BUG-931 / BUG-932。
|
||||
- 防复发:跟随只经 `useConversationScrollAnchor`,跳到最新只经 `JumpToLatestButton`;`send()` 必须调 `pinLatestTurn`;校正面新轮只在提交文字、点选项、开场/无用户行助手到达时各 pin 一次。
|
||||
- 相关记录:BUG-478、BUG-041、BUG-048、BUG-931、BUG-932
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-931 | 无用户行的一轮钉不住,随后退回贴底跟随
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`useConversationScrollAnchor` `applyTurnSpacer` / `pinLatestTurn`;生时校正点选项、开场、自动续轮
|
||||
- 用户现象:点选项或开场后新助手行不在顶部;流式一开始视口又钉到最后一个字。
|
||||
- 触发条件:本轮没有 `.message-user`,`pinLatestTurn()` 钉的是最后一条 `.message-assistant`。
|
||||
- 根因:最后一条助手行同时是 turn head 和留白承载。`applyTurnSpacer` 用 `head.offsetHeight` 写 `--latest-turn-head-height`,量到的是「内容 + 上一帧留白」,公式自指后 `min-height` 坍成 0,`scrollTo` 被夹住。随后 `distance ≤ 96` 解除 `holdUnpin`,`anchored` 翻回真。
|
||||
- 修复:head 与 `lastTurnTail` 是同一元素时 `--latest-turn-head-height` 写 `0px`,该行 `min-height` 等于整个视口。选这一路而不是先 `clearTurnSpacer` 再量,是因为清变量后仍要等一次布局,`offsetHeight` 在同一帧里不可靠。
|
||||
- 验证:`chat-notice-and-scroll-contract.test.ts`「a turn with no user row…」。
|
||||
- 防复发:无用户行时 spacer 头高必须是 0;pin 后增高不得把 `anchored` 翻回真。
|
||||
- 相关记录:BUG-930
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-932 | 闲置会话上滑后子元素尺寸变化被写留白
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`useConversationScrollAnchor` `follow()` 非贴底分支
|
||||
- 用户现象:在旧会话里上滑阅读时,窗口变宽、图片加载或思考块折叠会把最后一条助手行撑出一段空白,「跳到最新」也可能被顶出来。
|
||||
- 触发条件:`anchored = false` 且本轮没有 `pinLatestTurn`,滚动容器子元素高度变化。
|
||||
- 根因:`follow()` 在未贴底时无条件 `applyTurnSpacer`,把闲置上滑和「本轮已钉住」当成同一件事。
|
||||
- 修复:只在 `pinnedHeadRef.current` 非空时写留白。`anchorToLatest()` 与 `resetKey` 变化时清 ref 和 CSS 变量。
|
||||
- 验证:`chat-notice-and-scroll-contract.test.ts`「idle history does not write a spacer…」。
|
||||
- 防复发:未 pin 的上滑不得写 `--conversation-viewport`;`latestBelowFold` 仍按尾部超出 96px 判定。
|
||||
- 相关记录:BUG-930
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-933 | 四条源码合同没跟着外壳搬家,门禁卡住
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`chat-navigation-a11y-contract.test.ts`、`rectification-history-open-20260909.test.ts`、`rectification-surface-contract.test.ts`、`chat-session-url.test.ts`
|
||||
- 用户现象:会话列表单实现已落地,但 staging 门禁 `npm test` 必红,今天的代码提交上不了线。
|
||||
- 触发条件:`e4e73f56` 把 `<main className="chat-app">` 和侧栏 props 搬到 `(app)/layout.tsx`,启动改成 `sessionListReady`。
|
||||
- 根因:四条合同仍按搬家前的 `page.tsx` 形状断言。被保护的性质都还在,是合同陈旧。执行方只跑了定向测试。
|
||||
- 修复:四条按「原值 / 新值 / 原因」改写,分别断 layout 与 homepage 注册两端,不得删测试。
|
||||
- 验证:上述四文件;全量 `npm test` 相对 `cc1a8980` 少这 4 条红。
|
||||
- 防复发:见 BUG-927。
|
||||
- 相关记录:BUG-927、BUG-745
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-934 | 今日星语 / 生时校正入口合同仍找已删除的卡片类名
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`tests/test_daily_and_rectification_entrypoints.py`
|
||||
- 用户现象:Python 定向跑这两条必红。不在 `CORE_PYTEST_TARGETS`,不挡门禁。
|
||||
- 触发条件:断言 `daily-starlanguage-card` / `birth-rectification-card`。
|
||||
- 根因:`62903b7a` 把空状态改成问候 + 输入框 + 两枚 pill,类名已全仓删除。既有欠账,不是会话列表单引入。
|
||||
- 修复:改为断言 `starter-entry` pill 与 `startDailyStarlanguageConsultation` / `openRectificationFromHomepage`,并断言旧类名不存在。
|
||||
- 验证:`pytest tests/test_daily_and_rectification_entrypoints.py`。
|
||||
- 防复发:入口形态变了必须改这条合同,不得把已删除的卡片类名当存在。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-935 | 列表只剩校正会话且无活跃会话时,普通输入框静默吞发送
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`page.tsx` `activeSession` 回退、`send()`、普通 `ChatComposer`
|
||||
- 用户现象:账号里只有校正会话时,普通输入框能打字;发送没有任何提示,字消失。
|
||||
- 触发条件:`activeSession` 为 `undefined`(列表全是校正,找不到非校正回退),校正面正在打开或打开失败。
|
||||
- 根因:校正守卫把 `?? sessions[0]` 改成 `find(非校正)`。无活跃会话时 `activeRectificationSession` 为假,输入框可用;`send()` 因 `!currentSession` 直接 `return false`。
|
||||
- 修复:`composerLocksAsRectification`:无活跃会话但列表有校正会话时按校正占位锁定。`send()` 无活跃会话时打开校正或提示「请先选一条对话」。
|
||||
- 验证:`rectification-session-composer-guard.test.ts`。
|
||||
- 防复发:无活跃会话不得静默吞发送;列表全是校正时普通输入框不得可用。
|
||||
- 相关记录:BUG-924
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# PROGRESS · 回答定位到开头(2026-09-17)
|
||||
|
||||
工作树:`worktree-green-harbor-5be3`
|
||||
分支:`worktree/green-harbor-5be3`
|
||||
基线:`origin/staging` @ `5203f9f0`(任务书扩到校正面后)
|
||||
本机 Windows。
|
||||
|
||||
Skill **未 bump**。未改 Python、迁移、Skill。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T1 hook 改语义 `pinLatestTurn` BUG-930 | 完成 | 签名不变。新一轮钉本轮开头(用户行;无用户行则新助手行)。`send()` 改 `pinLatestTurn()`。校正面三处:文字提交、点选项、开场/采用新助手行。 |
|
||||
| T2 `latestBelowFold` | 完成 | 主会话 `jumpToLatestVisible` 与校正面按钮都改看 hook 的 `latestBelowFold`(末条底部超出视口 > 96px)。 |
|
||||
| T3 末尾动态留白 | 完成 | 最后一轮助手行 `min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height))`,变量由 hook 写到滚动容器 `style`。未改 `.conversation` 的 `padding-bottom`,无新增 sticky。 |
|
||||
| T4 思考块核实 | 完成 | 核实:`.consultation-thinking-report` 内 `ConsultationRunTimeline` 流式期间默认展开(`userOpen ?? live`),结算后折叠为一行;高度随步骤条数有界,不是逐 token 无界增长。未改。 |
|
||||
| T5 记录 | 完成 | BUG-930;BUG-041 / BUG-048 末尾各补一行;CHANGELOG;DESIGN Answer start anchor;真人清单。 |
|
||||
|
||||
## 让步 / 偏离
|
||||
|
||||
- `resetKey` 变化先落底;若当时就在底部 96px 内则保持跟随,直到 `pinLatestTurn` 或上滑。未在落底后强制置假,否则历史会话消息尚未 hydrate 时会停在顶部。与「打开历史落在底部」一致。
|
||||
- 本轮开头定位用 `getBoundingClientRect` 相对滚动容器,而不是字面 `row.offsetTop`(嵌套 `.message-entry` 时 `offsetTop` 不是容器坐标)。
|
||||
- 无 jsdom 依赖;行为测试用最小 fake scroller 行使导出函数。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error / 118 warning |
|
||||
| 定向 | chat-notice-and-scroll / a11y / rectification-agentic-entry / starter-questions / home-shell / chat-panel-scroll-guard / birth-time-mobile-scroll **92 passed / 0 failed**;rectification-answer-choice **34 passed / 0 failed** |
|
||||
| `page.tsx` 行数 | 开工 1830 → 现在 1830 |
|
||||
| `next build` | Compile + TypeScript 通过;collect page data 在本机 Windows 因 Skill 运行时 `symlink EPERM` 失败(与本轮无关的环境缺口),未核对 `/` Static 与 gzip |
|
||||
|
||||
无登录态、无 Chrome:走查见 `docs/testing/consultation-answer-start-anchor-20260917.md`。
|
||||
@@ -0,0 +1,26 @@
|
||||
# PROGRESS · 回答定位到开头修复单(2026-09-17)
|
||||
|
||||
工作树:`worktree-green-harbor-5be3`
|
||||
分支:`worktree/green-harbor-5be3`
|
||||
基线:`origin/staging` @ `b5e16b96`
|
||||
本机 Windows。
|
||||
|
||||
Skill **未 bump**。未改 Python、迁移、Skill、组件、CSS、`page.tsx`。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 前置 tsc 两行 | 完成 | `sessionSidebarTitle(session, library)` 与测试第二参去掉。标题函数本来只吃 session,这两处是 `e4e73f56` 挡住门禁的 TS 错。 |
|
||||
| F1 BUG-931 | 完成 | head 与 `lastTurnTail` 同一元素时 `--latest-turn-head-height` 写 `0px`。不采用先 clear 再量:同一帧 `offsetHeight` 仍含上一帧 min-height。 |
|
||||
| F2 BUG-932 | 完成 | `follow()` 只在 `pinnedHeadRef` 非空时写留白;`anchorToLatest` / `resetKey` 清 ref 与变量。 |
|
||||
| F3 记录 | 完成 | BUG-931/932;BUG-930 验证补 Chrome 骨架页;真机清单第 5 条拆成点选项 / 开场;状态板。 |
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错(清掉过期 `.next/types` 之后;原先两处 `Expected 1 arguments, but got 2` 已修) |
|
||||
| `npm run lint` | 0 error / 117 warning |
|
||||
| 定向 | chat-notice-and-scroll + chart-library-session **23 passed / 0 failed**(含 F1/F2 两条新测试) |
|
||||
| `next build --webpack` | Compile + TypeScript 通过。本机 Windows 在 collect page data 时 Skill `symlink EPERM`,未核 `/` Static 与 gzip。Linux 门禁应能过类型检查。 |
|
||||
@@ -0,0 +1,30 @@
|
||||
# PROGRESS · 会话列表验收修复单(2026-09-17)
|
||||
|
||||
工作树:`.worktrees/session-list-single-source-fix-20260917`
|
||||
分支:`codex/session-list-single-source-fix-20260917`
|
||||
基线:`origin/staging` @ `38cb81ff`
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| F1 四条合同搬家 BUG-933 | 完成 | 不断性质,改断言到 layout + 注册载荷 |
|
||||
| F2 Python 入口合同 BUG-934 | 完成 | 改为 `starter-entry` pill,断言旧卡片类名不存在 |
|
||||
| F3 无活跃会话吞发送 BUG-935 | 完成 | `composerLocksAsRectification` + `send()` 给反馈 |
|
||||
| F4 记录 | 完成 | BUG-927 防复发已补;元数据操作让步进 `BLOCKED.md` |
|
||||
|
||||
`page.tsx` 行数:开工 1824,结束 1824。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error(118 warning,未动) |
|
||||
| 四条目标合同 | `chat-navigation-a11y` / `rectification-history-open` / `rectification-surface-contract` / `chat-session-url`「home stays a client-read query」全绿 |
|
||||
| F3 | `rectification-session-composer-guard` 新断言绿 |
|
||||
| F2 | `pytest tests/test_daily_and_rectification_entrypoints.py` **4 passed** |
|
||||
| 全量 `npm test` | 本机 Windows worktree **3221 / 3115 pass / 92 fail / 14 skip**。任务点名的 4 条不在失败清单。多出来的失败主要是:skill runtime `EPERM` 建 symlink、esbuild `spawn UNKNOWN`(junction 指到别的 worktree 的 esbuild)、无 Docker 的 database/deploy 套件。与验收机 `cc1a8980` 的 3457 / 39 红不可直接逐条对比。`creating and leaving a session keep the address bar in sync` 仍红:`deleteSession` 已走 `activateFallbackSession`(composer-guard 起),不是本单引入 |
|
||||
| `next build --webpack` | webpack **Compiled successfully**;收集 `/api/birth-time-guide` 页数据时 Windows `EPERM` symlink 失败。`/` 是否 Static 未在本机核到。门禁 Linux 机应能过编译 |
|
||||
|
||||
未改排序、provider、路由组。`page.tsx` 1824 → 1824。
|
||||
@@ -106,7 +106,7 @@
|
||||
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` |
|
||||
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
|
||||
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` |
|
||||
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 待验收 | `codex/rectification-session-composer-guard-20260917` |
|
||||
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
@@ -131,8 +131,10 @@
|
||||
| `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` |
|
||||
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
|
||||
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
|
||||
| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 待验收(T1–T4;延迟落库让步) | `codex/session-list-single-source-20260917` |
|
||||
| `TASK-consultation-answer-start-anchor-20260917.md` | — | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 待领取 | — |
|
||||
| `TASK-session-list-single-source-20260917.md` | `PROGRESS-session-list-single-source-20260917.md` | 会话列表一处数据源:本地 PG 兼容层 `order()` 只保留最后一键,`/api/sessions` 实际按 `id` 排、与游标不一致;`/` 与次级页两份数据源、`/` 每次回来重启动(产品拍板:首页与三个次级页进同一路由组,侧栏外壳与列表 provider 常驻 layout,不做 store);空「新对话」落库堆积(首页 50 条里 28 条);标题类别在后、同名靠墙钟 HH:MM。串行在 composer-guard 单之后。BUG 段 926 起 | 验收未通过 | `e4e73f56`(实现按任务书落地:多键排序 / `(app)` 路由组常驻外壳 / provider / 空会话不入列 + draft 复用 / 标题类别在前;两条让步任务书允许。**P1:4 条针对 `page.tsx` 的源码合同没跟着外壳搬家,`npm test` 在门禁里 `set -e` 下必红,staging 至今停在 `dc2f2a16`**。另:进度记录报「tsc 0 错」实为 2 错,只跑了定向测试。修复单 `TASK-session-list-single-source-fix-20260917.md`) |
|
||||
| `TASK-session-list-single-source-fix-20260917.md` | `PROGRESS-session-list-single-source-fix-20260917.md` | 验收修复单:F1 四条源码合同搬到 `(app)/layout.tsx` 两端对断(BUG-933);F2 两条陈旧 Python 入口断言(BUG-934,既有欠账);F3 无活跃会话时输入框静默吞发送(BUG-935);交付前必须跑全量测试 | 待验收 | `codex/session-list-single-source-fix-20260917` |
|
||||
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
|
||||
| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 已验收 | `cc1a8980`(Claude 验收:tsc 0 / lint 0 error / npm test 3457 条 39 红与 11c0028d 逐条一致、新增 2 条绿 / `next build --webpack` 通过、`/` Static、首屏 gzip 591,242(较 09-16 基线 582,800 +1.45%,含会话列表单)/ Chrome 真实布局 S1–S6 全部通过,S6 新助手行距顶 16px 且增高不动,S5 不再写留白);真机六条欠 |
|
||||
|
||||
### 个人报告
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 验收修复单 · 回答定位到开头:无用户行的一轮钉不住、闲置会话被留白(2026-09-17)
|
||||
|
||||
## 0. 基线
|
||||
|
||||
- 验收对象:`11c0028d`(`origin/staging` head,执行方变基后快进推送)。任务书:`TASK-consultation-answer-start-anchor-20260917.md`。
|
||||
- 分支:`codex/consultation-answer-start-anchor-fix-20260917`,从 `origin/staging` 起。
|
||||
- 范围:只有 `frontend/src/hooks/use-conversation-scroll-anchor.ts` 与其测试、`docs/BUG_HISTORY.md`、`docs/tasks/`。不动 `page.tsx`、不动组件、不动 CSS。
|
||||
- BUG 段:**BUG-931 起**(基线最大号 BUG-930)。
|
||||
- **前置**:staging 自 `e4e73f56`(会话列表单)起 `tsc --noEmit` 与 `next build` 都红(`src/lib/session-sidebar-row.ts(30,41)`、`tests/chart-library-session.test.ts(89,42)`:`Expected 1 arguments, but got 2`),门禁过不了,`/api/health` 仍停在 `dc2f2a16`。那两处属于会话列表单的验收,另行处理;本单执行时若 staging 仍红,先在本分支顺手修这两行并在进度记录写明,否则本单也发不出去。
|
||||
|
||||
## 1. 验收实证
|
||||
|
||||
验收环境:Linux,Node 20.19,Google Chrome 151 无头(本机有 Chrome,真实布局;无登录态,所以用真实 hook + 与 `globals.css` 同参数的骨架页做场景,不是 staging 页面)。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 2 错,**均继承自 `e4e73f56`**,本提交 0 新增 |
|
||||
| `npm run lint` | 0 error / 117 warning |
|
||||
| `npm test` | 3455 条,39 红;与父提交 `e4e73f56`(3450 条,39 红)逐条一致,**0 新红、+5 新测试全绿**。`e4e73f56` 自身比 `5203f9f0` 多 4 条红(会话列表单的事,另记) |
|
||||
| `next build --webpack` | 编译通过,`Failed to type check`(同上两处继承错误);`/` Static 与 gzip 因此未核 |
|
||||
| `page.tsx` 行数 | 1830 → 1830 |
|
||||
| 文档 | BUG-930、BUG-041/048 各补指回行、CHANGELOG、DESIGN「Answer start anchor」、真机清单、PROGRESS 齐 |
|
||||
| T4 思考块 | 执行方核实「按步骤条数有界、结算后折一行、未改」,与代码一致,接受 |
|
||||
|
||||
真实布局场景(容器 600px 高,`.message-list` 与 `.message` 用 `globals.css` 同一组 padding,最后一轮的 `min-height` 规则原样):
|
||||
|
||||
| 场景 | 结果 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| S1 长历史、读者在底部、发问 | 钉顶后用户行距容器顶 16px;流式增高 6 步 `scrollTop` 纹丝不动;尾部超出 96px 后 `latestBelowFold` 为真;按「跳到最新」后贴底并跟随 | 通过 |
|
||||
| S2 短历史(不溢出)、发问 | 留白让用户行到顶(16px),增高不动 | 通过 |
|
||||
| S3 读者先滚到顶部再发问 | 钉顶 16px,增高不动 | 通过 |
|
||||
| S4 切换会话 | 落底,`anchored` 为真 | 通过 |
|
||||
| S5 闲置历史会话,读者上滑 400px,某条旧消息高度变化 30px | **最后一条助手行被写入留白**(底部从 936 变 1320,`--conversation-viewport` / `--latest-turn-head-height` 被写上),`scrollTop` 漂 30px | **P2,BUG-932** |
|
||||
| S6 本轮没有用户行(校正点选项 / 开场 / 自动续轮):追加新助手行后 `pinLatestTurn()` | **钉不住**:新助手行停在距顶 480px;`--latest-turn-head-height` 被写成 740px;随后第一步增高 `anchored` 翻成真,视口回到贴底跟随 | **P1,BUG-931** |
|
||||
|
||||
S6 的机理(按符号):`resolveTurnHead` 正确选到新助手行;但 `applyTurnSpacer(element, head)` 用 `head.offsetHeight` 写 `--latest-turn-head-height`,而这个 head **就是**承载 `min-height: calc(viewport − head)` 的最后一条助手行——前一帧 `follow()` 的非贴底分支已经给它写过一次留白,于是量到的高度是「内容 + 留白」,留白公式自指后坍缩为 0,`scrollTo` 目标被 `scrollHeight` 夹住,头钉不到顶。接着 `measure()` 见 `distance ≤ 96` 解除 `holdUnpin`,`nextAnchorState` 判回 `anchored = true`,后续增高就是 BUG-930 之前的贴底行为。校正面用 `ChatMessageRow`,行也是 `.message-assistant`,所以真实页面同样命中。
|
||||
|
||||
S5 的机理:`follow()` 的非贴底分支对**任何** `anchored = false` 的容器都 `applyTurnSpacer`,不管有没有正在进行的一轮。读者在旧会话里上滑后,任何子元素尺寸变化(窗口宽度、图片、思考块折叠)都会给最后一条助手行补一段留白,还会把 `latestBelowFold` 顶成真。
|
||||
|
||||
## 2. 任务分解
|
||||
|
||||
### F1 头就是留白行时,留白按整视口算(BUG-931)
|
||||
|
||||
- `applyTurnSpacer(container, head)`:若 `head` 与 `lastTurnTail(container)` 是同一个元素(本轮无用户行),`--latest-turn-head-height` 写 `0px`,让该行 `min-height` = 整个视口;否则照旧写 `head.offsetHeight`。或者在量高之前 `clearTurnSpacer` 再量,两者取其一,进度记录写明选哪种及原因。
|
||||
- `pinLatestTurn` 在 `applyTurnSpacer` 之后、`scrollTo` 之前必须能把 head 钉到 `offset − space4`;不得靠 `holdUnpin` 之外的新状态。
|
||||
- 验收:
|
||||
- 行为测试(fake scroller 或 jsdom)新增:容器内 `[u][a][u][a][a-new]`,`pinLatestTurn()` 后 `scrollTop === offset(a-new) − 16`,`--latest-turn-head-height === "0px"`;随后增高 3 步 `anchored` 仍为假、`scrollTop` 不变。
|
||||
- 现有主会话场景(S1–S3)断言不变。
|
||||
|
||||
### F2 留白只在本轮钉住期间存在(BUG-932)
|
||||
|
||||
- `follow()` 的非贴底分支只在 `pinnedHeadRef.current !== null` 时 `applyTurnSpacer`;读者单纯上滑(无 pin)不写留白。`anchorToLatest()` 与 `resetKey` 变化时清 `pinnedHeadRef` 与留白(现在已清)。
|
||||
- `latestBelowFold` 在无 pin、读者上滑时仍按尾部超出 96px 判定(保留「读者上滑后可跳到最新」)。
|
||||
- 验收:行为测试新增:长历史落底 → 上滑 400px → 改某条旧消息高度 → 容器 `style` 无 `--conversation-viewport`,`scrollTop` 不变(允许 ±1px)。
|
||||
|
||||
### F3 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 新增 BUG-931、BUG-932(关联 BUG-930);BUG-930 的「验证」补一句真实布局场景已由验收方跑过。
|
||||
- `docs/tasks/PROGRESS-consultation-answer-start-anchor-fix-20260917.md`;状态板行。
|
||||
- 真机清单第 5 条拆成两条:点选项后新助手行在顶部;开场轮在顶部。
|
||||
|
||||
## 3. 硬红线
|
||||
|
||||
沿用原单 §4:滚动逻辑只在 hook 内;单一 hook、单一按钮;不 sticky;改断言写三栏;`page.tsx` 不增行。
|
||||
|
||||
## 4. 验收口径
|
||||
|
||||
`tsc --noEmit` 0 错(含前置两行修复后);`npm run lint` 0 error;`npm test` 失败清单与 `11c0028d` 逐条一致、新增测试全绿;`next build` 通过、`/` Static、gzip ±2%(此项因门禁红一直没核,本单必须补上)。验收方会用同一套 Chrome 骨架页复跑 S1–S6。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 验收修复单 · 会话列表单一数据源:四条源码合同没跟着外壳搬家,门禁卡住(2026-09-17)
|
||||
|
||||
## 0. 基线
|
||||
|
||||
- 验收对象:`e4e73f56`(会话列表单)与 `6d81062b`(校正输入框守卫单),当前都在 `origin/staging`,head `cc1a8980`。
|
||||
- 分支:`codex/session-list-single-source-fix-20260917`,从 `origin/staging` 起。
|
||||
- 范围:`frontend/tests/` 四个合同测试;`tests/test_daily_and_rectification_entrypoints.py`;`frontend/src/app/(app)/page.tsx`(F3,只允许减行或持平);`docs/`。不动 provider、不动排序、不动路由组。
|
||||
- BUG 段:**BUG-933 起**(基线最大号 BUG-932)。
|
||||
- **本单是 staging 能否部署的唯一拦路石**:`/api/health` 仍是 `dc2f2a16`,今天四个代码提交一个都没上线。
|
||||
|
||||
## 1. 验收结论
|
||||
|
||||
### 校正输入框守卫单 `6d81062b`:**通过**
|
||||
|
||||
| 任务 | 结果 |
|
||||
| --- | --- |
|
||||
| T1 `send()` 会话类型守卫 | 通过。`consultSendBlockedByRectificationSession` 在扣点与撤回窗口之前拦下,保留草稿,转 `openRectificationSession`;刷新恢复重放同样受守卫,pending 存储里指向校正会话的记录直接丢弃 |
|
||||
| T2 普通输入框只有禁用态 | 通过。`inputDisabled` / `submitBlocked` 都加了 `activeRectificationSession`,占位文案「正在打开生时校正…」,打开失败出「重新打开生时校正」按钮并先清 `rectificationError` |
|
||||
| T3 三条回退路径 | 通过,且多修了归档一条。`fallbackSessionId` 优先非校正会话,落到校正会话时走 `selectSession` 的延迟切换 |
|
||||
| T4 服务端错误码 | 通过。类型不对回 `409 session_not_consultation`(在扣点预留之前,不扣点),真查不到仍 404;客户端 `payloadCode` 把 code 带到 `ConsultationResponseError`,命中后回滚乐观消息、不显示报错、直接开校正面 |
|
||||
| T5 记录 | 通过。BUG-924 / 925、CHANGELOG、DESIGN、VOICE、真机清单齐 |
|
||||
| `page.tsx` 行数 | 1830 → 1830 |
|
||||
|
||||
残留一条 P3,见 F3。
|
||||
|
||||
### 会话列表单 `e4e73f56`:**未通过(P1)**
|
||||
|
||||
实现本身按任务书落地:多键排序(`ordering` 改数组 + `formatOrderClause`)、`(app)` 路由组 + `(app)/layout.tsx` 常驻外壳 + `SessionListProvider`、`app/` 下 `SidebarProvider` 恰好一次、`use-sidebar-data.ts` 与 `sidebar-data-cache.ts` 已删、服务端排除 `messages = []` 并单独回一条 `draft` 供启动复用、标题改「类别 · M月D日」并删 `uniquify`、`toSidebarSessionRow` 两处侧栏共用。两条让步(元数据操作未上移、延迟落库未做)任务书都允许。
|
||||
|
||||
**但 `npm test` 多出 4 条红,且这 4 条会让 staging 门禁必红。**
|
||||
|
||||
`.gitea/workflows/backend-quality-gate.yml` 的 `Validate backend, package, frontend, and database contracts` 步骤在 `set -euo pipefail` 下依次跑 `npm test --prefix frontend`、`npm run lint --prefix frontend`。这 4 条是纯源码正则合同,不依赖 Docker、不依赖环境,在门禁机上照样红:
|
||||
|
||||
| 测试文件 | 断言找不到的东西 | 它现在在哪 |
|
||||
| --- | --- | --- |
|
||||
| `chat-navigation-a11y-contract.test.ts` | `<main className="chat-app">` 紧跟 `replyAnnouncement` 的 sr-only 播报 | `<main>` 在 `(app)/layout.tsx`,播报仍在 `page.tsx`,只是不再相邻 |
|
||||
| `rectification-history-open-20260909.test.ts` | `page.tsx` 里 `openErrorSessionId={rectificationErrorSessionId}` | `(app)/layout.tsx` 从 `registration.openErrorSessionId` 传给 `AppSidebar` |
|
||||
| `rectification-surface-contract.test.ts` | `page.tsx` 里 `openingSessionId={rectificationOpeningSessionId}` | 同上,经 `registration.openingSessionId` |
|
||||
| `chat-session-url.test.ts` | `page.tsx` 里 `const [nextAccount, modelCatalogResult, sessionsPayload]` | 启动改成 `await sessionListReady` + `sessionListBoot()`,这个解构没了 |
|
||||
|
||||
四条被保护的性质**都还成立**(`/` 仍 `○ Static`,侧栏行仍拿得到 opening / openError,播报仍在),所以是合同陈旧、不是行为回归。但它们被留在红里,等于把门禁堵死。
|
||||
|
||||
根因是流程:执行方只跑了定向测试(进度记录写「定向 26 + 先前 114/115」),没跑全量 `npm test`,也因此没发现同批次带进的两处 TS 错(`session-sidebar-row.ts(30,41)`、`chart-library-session.test.ts(89,42)`,进度记录写着「tsc 0 错」,实测 2 错,后来由滚动单的 `cc1a8980` 顺手修掉)。
|
||||
|
||||
## 2. 任务分解
|
||||
|
||||
### F1 四条源码合同跟着外壳搬家(BUG-933)
|
||||
|
||||
四条都按「原值 / 新值 / 原因」三栏改写,**不得删除测试、不得弱化成只判存在**:
|
||||
|
||||
- `chat-navigation-a11y-contract.test.ts`:改为分别断言 `(app)/layout.tsx` 里有且只有一处 `<main className="chat-app">`,且 `page.tsx` 里 `replyAnnouncement` 的 sr-only 播报仍带 `role="status" aria-live="polite" aria-atomic="true"`,并且它是 `SidebarInset` 内容的第一个子节点。
|
||||
- `rectification-history-open-20260909.test.ts`、`rectification-surface-contract.test.ts`:改为断言 `(app)/layout.tsx` 把 `openErrorSessionId` / `openingSessionId` / `openErrorMessage` 传给 `AppSidebar`,且 `page.tsx` 的 `registerShellControls` 载荷里带这三个字段(两端都断,缺一端就等于断链)。
|
||||
- `chat-session-url.test.ts`:把「首页自己并发拉账户/目录/列表」的断言改成「首页 `await sessionListReady` 后读 `sessionListBoot()`,并且 `page.tsx` 里不再出现 `fetchSessions(`」,保留它原本要守的「`/` 是静态路由上的客户端读」这一性质。
|
||||
- 验收:`npm test --prefix frontend` 的失败清单与 `cc1a8980` 比**少这 4 条、不多任何一条**;`npm run lint` 0 error。
|
||||
|
||||
### F2 两条陈旧的 Python 入口合同(BUG-934)
|
||||
|
||||
`tests/test_daily_and_rectification_entrypoints.py` 断言首页出现 `daily-starlanguage-card` / `birth-rectification-card`,这两个类名在 `62903b7a`(空状态改成「问候 + 输入框 + 两枚入口 pill」)就已删除,全仓已无。**这是既有欠账,不是本批引入**,也不在 `CORE_PYTEST_TARGETS` 里,所以不挡门禁,但任何一次 Python 定向跑都会红。
|
||||
|
||||
- 改为断言当前真实的入口形态(`StarterEntries` 的两枚 pill 与 `startDailyStarlanguageConsultation` / 校正入口),或在确认无对应实现后连同该断言一并删除并在测试里写明原因。选哪种由执行方判断后写进进度记录。
|
||||
- 验收:`.venv/bin/python -m pytest tests/test_daily_and_rectification_entrypoints.py` 全绿。
|
||||
|
||||
### F3 校正守卫的残留死角:一个会话都不活跃时输入框静默吞发送(BUG-935)
|
||||
|
||||
`page.tsx` 的 `activeSession` 现在是 `sessions.find(id === activeSessionId) ?? sessions.find(非校正)`。当**列表里只剩校正会话**时它是 `undefined`(旧代码 `?? sessions[0]` 不会)。此时:`activeRectificationSession` 为假 → 普通输入框**可用**;按发送 `send()` 因 `!currentSession` 直接 `return false`,没有任何提示;因为 `activeRectificationSession` 为假,「重新打开生时校正」按钮也不出现。
|
||||
|
||||
触发路径:账号里只有校正会话,或删除当前普通会话后剩下全是校正会话,且此时校正面正在打开或打开失败。窄,但正是本单要消灭的那类死角。
|
||||
|
||||
- 修法:`activeSession` 为空且列表里存在校正会话时,按「校正会话占位」处理——输入框禁用、占位文案「正在打开生时校正…」、打开失败出重开按钮;派生值放进现有 hook 或 `rectification-session-composer-guard.ts`,`page.tsx` 不增行。
|
||||
- 验收:`rectification-session-composer-guard.test.ts` 加一条纯函数断言(输入「列表全是校正会话、activeSession 为空」→ 锁定态为真);`send()` 在无活跃会话时不得静默返回而不给任何反馈。
|
||||
|
||||
### F4 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 新增 BUG-933 / 934 / 935(933 关联 BUG-927 与 BUG-745;935 关联 BUG-924)。
|
||||
- BUG-927 的「防复发」补一条:**外壳搬家必须同步搬走针对 `page.tsx` 的源码合同,且交付前必须跑全量 `npm test` 与 `tsc --noEmit`,不得只跑定向。**
|
||||
- `docs/tasks/PROGRESS-session-list-single-source-fix-20260917.md`;状态板三行改状态。
|
||||
- T2 的「元数据操作未上移到 provider」目前只写在进度记录里,补进 `BLOCKED.md`,与延迟落库那条并列。
|
||||
|
||||
## 3. 硬红线
|
||||
|
||||
1. 不得靠删测试或放宽断言过门;四条合同必须仍然守住原来的性质。
|
||||
2. 不动排序、provider、路由组的既有行为。
|
||||
3. `page.tsx` 不增行。
|
||||
4. 交付前必须跑**全量** `npm test --prefix frontend`、`tsc --noEmit`、`next build --webpack`,并把失败清单与 `cc1a8980` 逐条比对后写进进度记录。
|
||||
|
||||
## 4. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/session-list-single-source-fix-20260917 .worktrees/session-list-single-source-fix-20260917 origin/staging
|
||||
cd .worktrees/session-list-single-source-fix-20260917/frontend
|
||||
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20
|
||||
```
|
||||
|
||||
## 5. 验收口径
|
||||
|
||||
`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 相对 `cc1a8980` 少 4 条红、不新增;`next build --webpack` 通过且 `/` 仍 `○ Static`、首屏 gzip ±2%;`pytest tests/test_daily_and_rectification_entrypoints.py` 全绿。合入后必须核对 `/api/health` 的 `deployment.gitCommit` 等于最近一次含门禁路径的 staging 提交——今天四个代码提交至今一个都没上线。
|
||||
@@ -0,0 +1,12 @@
|
||||
# 真机清单 · 回答从开头开始读(2026-09-17)
|
||||
|
||||
在 staging 上走。本机无登录态、无 Chrome。
|
||||
|
||||
1. 主会话问一个会有长回答的问题(例如「详细分析我的事业格局」):发送后问题行在顶部,回答从它下面开始往下长,视口不跟着最后一个字跑。
|
||||
2. 回答超过一屏后出现「跳到最新」;按下贴底,并跟随到这一轮结束。
|
||||
3. 短回答(一两句)问题仍在顶部,下方有留白,不抖动。
|
||||
4. 切换到别的已有会话:落在底部(最新内容)。
|
||||
5. 生时校正点选项后:新助手行在顶部,回答从它下面往下长,不贴底。
|
||||
6. 生时校正开场轮:第一条助手行在顶部。
|
||||
7. 生时校正打字提交:用户行在顶部;长旁白时出现「跳到最新」,按下能看到选择卡。
|
||||
8. iPhone 键盘弹起再收起后位置不跳(配合 BUG-920 一起看)。
|
||||
+11
-1
@@ -223,11 +223,21 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
|
||||
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
|
||||
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
|
||||
|
||||
### Answer start anchor
|
||||
|
||||
Both chat surfaces pin a new turn at its head instead of following the last streamed token.
|
||||
|
||||
- **Head:** the user row of this turn. When this turn has no user row (rectification choice tap, opening, auto-continue) the head is the new assistant row.
|
||||
- **Pin:** on send (and on those rectification new-turn entries) the scroller places that row at the top with a `space-4` gap and then holds still while the reply grows downward.
|
||||
- **Spacer:** the last assistant row's `min-height` is `calc(var(--conversation-viewport) - var(--latest-turn-head-height))`, both variables written onto the scroller by `useConversationScrollAnchor`. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
|
||||
- **History:** opening another session still lands on the newest content once.
|
||||
- **Jump to latest:** if the growing turn (or the reader scrolling) leaves more than 96px below the fold, the shared chip appears; pressing it sticks to the bottom for the rest of the turn.
|
||||
|
||||
### Jump to latest
|
||||
|
||||
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
|
||||
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
|
||||
- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
|
||||
- **Visibility:** shown when the last turn extends more than 96px below the fold — either because the reader scrolled up, or because this turn grew past the viewport while pinned at its head — and hidden again once they return to the bottom or press it. Sending a question pins the turn head; pressing the control re-anchors to the bottom.
|
||||
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
|
||||
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
resolveRectificationEntryAction,
|
||||
} from "@/lib/rectification-entry";
|
||||
import {
|
||||
dropRectificationStoredPending,
|
||||
composerLocksAsRectification, dropRectificationStoredPending,
|
||||
ordinaryComposerPlaceholder,
|
||||
rectificationOrdinaryComposerReopen,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
@@ -391,7 +391,7 @@ export default function Home() {
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
?? sessions.find((session) => session.sessionType !== "birth_time_rectification");
|
||||
const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification";
|
||||
const activeRectificationSession = composerLocksAsRectification(activeSession, sessions);
|
||||
const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId);
|
||||
const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen);
|
||||
const rectificationComposerReopen = rectificationOrdinaryComposerReopen({ activeRectificationSession, error: rectificationError, errorSessionId: rectificationErrorSessionId, sessionId: activeSession?.id });
|
||||
@@ -783,7 +783,7 @@ export default function Home() {
|
||||
});
|
||||
const jumpToLatestVisible = !rectificationSurfaceOpen
|
||||
&& !starterHomeVisible
|
||||
&& !conversationAnchor.anchored
|
||||
&& conversationAnchor.latestBelowFold
|
||||
&& Boolean(activeSession?.messages.length);
|
||||
|
||||
function setDraft(value: string) {
|
||||
@@ -1745,7 +1745,7 @@ export default function Home() {
|
||||
{jumpToLatestVisible && (
|
||||
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
|
||||
)}
|
||||
{rectificationComposerReopen && activeSession ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession.id)} /> : null}
|
||||
{rectificationComposerReopen ? <RectificationComposerReopen message={rectificationErrorMessage} onReopen={() => void reopenRectificationSession(activeSession?.id || rectificationErrorSessionId || "")} /> : null}
|
||||
<ChatComposer
|
||||
inputRef={composerInput}
|
||||
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
|
||||
|
||||
@@ -1127,6 +1127,11 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-content { min-width: 0; max-width: min(80%, 680px); }
|
||||
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
|
||||
/* Last turn fills the remaining viewport so a short reply can still pin its
|
||||
head at the top. Variables are written by useConversationScrollAnchor. */
|
||||
.conversation .message-list > :last-child .message-assistant {
|
||||
min-height: calc(var(--conversation-viewport, 0px) - var(--latest-turn-head-height, 0px));
|
||||
}
|
||||
.message-thinking {
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
@@ -464,8 +464,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const boardId = useId();
|
||||
const boardTitleId = useId();
|
||||
const composerRemainingId = useId();
|
||||
// The same anchor-and-follow the consultation surface uses: streamed tokens and
|
||||
// new cards land the viewport on the bottom only while the reader is there.
|
||||
// Same pin-the-turn-head hook as the consultation surface (BUG-930).
|
||||
const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -717,6 +716,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: []),
|
||||
liveRow,
|
||||
]));
|
||||
if (!continuation) conversationAnchor.pinLatestTurn();
|
||||
let raw = "";
|
||||
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
|
||||
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
|
||||
@@ -1079,6 +1079,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
},
|
||||
},
|
||||
]);
|
||||
conversationAnchor.pinLatestTurn();
|
||||
setPending(true);
|
||||
const abortController = new AbortController();
|
||||
runAbort.current = abortController;
|
||||
@@ -1231,6 +1232,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
|
||||
},
|
||||
]);
|
||||
conversationAnchor.pinLatestTurn();
|
||||
const abortController = new AbortController();
|
||||
runAbort.current = abortController;
|
||||
try {
|
||||
@@ -1812,7 +1814,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
</section>
|
||||
|
||||
<div className="composer-wrap">
|
||||
{!conversationAnchor.anchored && (
|
||||
{conversationAnchor.latestBelowFold && (
|
||||
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
|
||||
)}
|
||||
<ChatComposer
|
||||
|
||||
@@ -64,6 +64,7 @@ import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace }
|
||||
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
|
||||
import {
|
||||
consultSendBlockedByRectificationSession,
|
||||
missingActiveSessionSendAction,
|
||||
SESSION_NOT_CONSULTATION_CODE,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
import type {
|
||||
@@ -86,7 +87,9 @@ import type { PublicLanguageModelCatalog } from "@/lib/public-models";
|
||||
|
||||
type ConversationAnchor = {
|
||||
readonly anchored: boolean;
|
||||
readonly latestBelowFold: boolean;
|
||||
readonly anchorToLatest: () => void;
|
||||
readonly pinLatestTurn: (target?: HTMLElement) => void;
|
||||
};
|
||||
|
||||
export type ConsultationRunParams = {
|
||||
@@ -512,7 +515,19 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
? sessions.find((session) => session.id === targetSessionId)
|
||||
: activeSession;
|
||||
const currentSession = options.sessionOverride ?? liveSession;
|
||||
if (!question || !currentSession || !modelCatalog || !account) return false;
|
||||
if (!question) return false;
|
||||
if (!currentSession) {
|
||||
const action = missingActiveSessionSendAction(sessions);
|
||||
if ("openSessionId" in action) {
|
||||
setDraft(originalQuestion);
|
||||
await openRectificationSession(action.openSessionId);
|
||||
setDraft(originalQuestion);
|
||||
return false;
|
||||
}
|
||||
setComposerNotice(action.notice);
|
||||
return false;
|
||||
}
|
||||
if (!modelCatalog || !account) return false;
|
||||
if (consultSendBlockedByRectificationSession(currentSession)) {
|
||||
if (resuming) {
|
||||
pendingConsultation.current = null;
|
||||
@@ -657,7 +672,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
}
|
||||
setOnboardingJustCompleted(false);
|
||||
updateSession(sessionId, () => userSession);
|
||||
conversationAnchor.anchorToLatest();
|
||||
conversationAnchor.pinLatestTurn();
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
export const conversationAnchorThreshold = 96;
|
||||
export const conversationPinSpaceToken = "--space-4";
|
||||
const pinSpaceFallbackPx = 16;
|
||||
|
||||
type AnchorState = {
|
||||
readonly key: string;
|
||||
@@ -12,7 +14,9 @@ type AnchorState = {
|
||||
|
||||
type ConversationScrollAnchor = {
|
||||
readonly anchored: boolean;
|
||||
readonly latestBelowFold: boolean;
|
||||
readonly anchorToLatest: () => void;
|
||||
readonly pinLatestTurn: (target?: HTMLElement) => void;
|
||||
};
|
||||
|
||||
export function conversationDistanceFromBottom(container: HTMLElement) {
|
||||
@@ -24,28 +28,125 @@ export function nextAnchorState(anchored: boolean, distanceFromBottom: number, s
|
||||
return scrolledUp ? false : anchored;
|
||||
}
|
||||
|
||||
export function readPinSpacePx(element: HTMLElement): number {
|
||||
if (typeof getComputedStyle !== "function") return pinSpaceFallbackPx;
|
||||
const raw = getComputedStyle(element).getPropertyValue(conversationPinSpaceToken).trim();
|
||||
const parsed = Number.parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : pinSpaceFallbackPx;
|
||||
}
|
||||
|
||||
const DOCUMENT_POSITION_FOLLOWING = 4;
|
||||
|
||||
export function isNodeAfter(earlier: Node, later: Node): boolean {
|
||||
if (typeof earlier.compareDocumentPosition !== "function") return false;
|
||||
return (earlier.compareDocumentPosition(later) & DOCUMENT_POSITION_FOLLOWING) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn head is the latest user row, unless an assistant sits after it with
|
||||
* another assistant in between (choice / opening / auto-continue: this turn
|
||||
* has no user row) or there is no user row at all.
|
||||
*/
|
||||
export function resolveTurnHead(container: ParentNode, target?: HTMLElement | null): HTMLElement | null {
|
||||
if (target) return target;
|
||||
const users = Array.from(container.querySelectorAll(".message-user")) as HTMLElement[];
|
||||
const assistants = Array.from(container.querySelectorAll(".message-assistant")) as HTMLElement[];
|
||||
const lastUser = users.at(-1) ?? null;
|
||||
const lastAssistant = assistants.at(-1) ?? null;
|
||||
if (!lastUser) return lastAssistant;
|
||||
if (!lastAssistant) return lastUser;
|
||||
if (!isNodeAfter(lastUser, lastAssistant)) return lastUser;
|
||||
const between = assistants.some((row) => isNodeAfter(lastUser, row) && isNodeAfter(row, lastAssistant));
|
||||
return between ? lastAssistant : lastUser;
|
||||
}
|
||||
|
||||
export function offsetWithinScroller(scroller: HTMLElement, node: HTMLElement): number {
|
||||
return node.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
|
||||
}
|
||||
|
||||
export function pinTurnScrollTop(scroller: HTMLElement, row: HTMLElement, spacePx: number): number {
|
||||
return Math.max(0, offsetWithinScroller(scroller, row) - spacePx);
|
||||
}
|
||||
|
||||
export function applyFollowBottom(
|
||||
element: { scrollTop: number; scrollHeight: number },
|
||||
anchored: boolean,
|
||||
): void {
|
||||
if (!anchored) return;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
export function lastTurnTail(container: HTMLElement): HTMLElement | null {
|
||||
return (container.querySelector(".message-list > :last-child .message-assistant")
|
||||
?? container.querySelector(".message-assistant:last-of-type")
|
||||
?? container.querySelector(".message-user:last-of-type")) as HTMLElement | null;
|
||||
}
|
||||
|
||||
export function turnHeadHeightForSpacer(head: HTMLElement | null, tail: HTMLElement | null): number {
|
||||
if (!head) return 0;
|
||||
// The last assistant carries the min-height spacer. Measuring it as the turn
|
||||
// head feeds the spacer back into itself (BUG-931).
|
||||
if (tail && head === tail) return 0;
|
||||
return head.offsetHeight;
|
||||
}
|
||||
|
||||
export function applyTurnSpacer(container: HTMLElement, head: HTMLElement | null): void {
|
||||
container.style.setProperty("--conversation-viewport", `${container.clientHeight}px`);
|
||||
container.style.setProperty(
|
||||
"--latest-turn-head-height",
|
||||
`${turnHeadHeightForSpacer(head, lastTurnTail(container))}px`,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearTurnSpacer(container: HTMLElement): void {
|
||||
container.style.removeProperty("--conversation-viewport");
|
||||
container.style.removeProperty("--latest-turn-head-height");
|
||||
}
|
||||
|
||||
export function turnTailOverflow(container: HTMLElement): number {
|
||||
const last = lastTurnTail(container);
|
||||
if (!last) return conversationDistanceFromBottom(container);
|
||||
return last.offsetTop + last.offsetHeight - (container.scrollTop + container.clientHeight);
|
||||
}
|
||||
|
||||
export function latestContentBelowFold(overflow: number): boolean {
|
||||
return overflow > conversationAnchorThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns both halves of "follow the conversation": whether the reader is
|
||||
* anchored to the bottom (a scroll listener), and landing the viewport on the
|
||||
* newest content while they are (a resize observer over the scroller's
|
||||
* children, one frame per change). Both chat surfaces use this one hook, so
|
||||
* streamed tokens never call scrollTo directly and settlement never adds a
|
||||
* second, smooth scroll on top of the follow.
|
||||
* children, one frame per change). Both chat surfaces use this one hook.
|
||||
*
|
||||
* A new turn pins its head (the user row, or the new assistant when this turn
|
||||
* has no user row) at the top and does not follow streamed growth. Switching
|
||||
* conversations still lands on the newest content once.
|
||||
*/
|
||||
export function useConversationScrollAnchor(
|
||||
container: RefObject<HTMLElement | null>,
|
||||
active: boolean,
|
||||
resetKey: string,
|
||||
): ConversationScrollAnchor {
|
||||
const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: true });
|
||||
const [anchor, setAnchor] = useState<AnchorState>({ key: resetKey, anchored: false });
|
||||
const [latestBelowFold, setLatestBelowFold] = useState(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const anchoredRef = useRef(true);
|
||||
const anchoredRef = useRef(false);
|
||||
const shouldLand = useRef(true);
|
||||
const holdUnpin = useRef(false);
|
||||
const pinnedHeadRef = useRef<HTMLElement | null>(null);
|
||||
const anchored = anchor.key === resetKey ? anchor.anchored : true;
|
||||
|
||||
useEffect(() => {
|
||||
anchoredRef.current = anchored;
|
||||
}, [anchored]);
|
||||
|
||||
useEffect(() => {
|
||||
shouldLand.current = true;
|
||||
holdUnpin.current = false;
|
||||
pinnedHeadRef.current = null;
|
||||
}, [resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = container.current;
|
||||
if (!active || !element) return;
|
||||
@@ -53,9 +154,20 @@ export function useConversationScrollAnchor(
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
const overflow = turnTailOverflow(element);
|
||||
const distance = conversationDistanceFromBottom(element);
|
||||
const scrolledUp = element.scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = element.scrollTop;
|
||||
setLatestBelowFold(latestContentBelowFold(overflow));
|
||||
if (holdUnpin.current) {
|
||||
if (scrolledUp) {
|
||||
holdUnpin.current = false;
|
||||
} else if (distance <= conversationAnchorThreshold) {
|
||||
holdUnpin.current = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setAnchor((current) => {
|
||||
const currentAnchored = current.key === resetKey ? current.anchored : true;
|
||||
const next = nextAnchorState(currentAnchored, distance, scrolledUp);
|
||||
@@ -76,15 +188,31 @@ export function useConversationScrollAnchor(
|
||||
|
||||
// Follow: while anchored, any change in the scroller's content height lands the
|
||||
// viewport on the bottom, at most once per frame. Switching conversations
|
||||
// (resetKey) lands there immediately.
|
||||
// (resetKey) lands there immediately. Unanchored turns only refresh the spacer.
|
||||
useEffect(() => {
|
||||
const element = container.current;
|
||||
if (!active || !element) return;
|
||||
let frame = 0;
|
||||
const follow = () => {
|
||||
frame = 0;
|
||||
if (!anchoredRef.current) return;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
if (shouldLand.current) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
shouldLand.current = false;
|
||||
holdUnpin.current = false;
|
||||
pinnedHeadRef.current = null;
|
||||
clearTurnSpacer(element);
|
||||
const atBottom = conversationDistanceFromBottom(element) <= conversationAnchorThreshold;
|
||||
anchoredRef.current = atBottom;
|
||||
setAnchor({ key: resetKey, anchored: atBottom });
|
||||
setLatestBelowFold(false);
|
||||
return;
|
||||
}
|
||||
if (anchoredRef.current) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
} else if (pinnedHeadRef.current) {
|
||||
applyTurnSpacer(element, pinnedHeadRef.current);
|
||||
}
|
||||
setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
|
||||
};
|
||||
const requestFollow = () => {
|
||||
if (frame) return;
|
||||
@@ -118,13 +246,47 @@ export function useConversationScrollAnchor(
|
||||
|
||||
return {
|
||||
anchored,
|
||||
latestBelowFold,
|
||||
anchorToLatest: () => {
|
||||
const element = container.current;
|
||||
holdUnpin.current = false;
|
||||
shouldLand.current = false;
|
||||
pinnedHeadRef.current = null;
|
||||
if (element) {
|
||||
clearTurnSpacer(element);
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" });
|
||||
setLatestBelowFold(false);
|
||||
}
|
||||
setAnchor({ key: resetKey, anchored: true });
|
||||
},
|
||||
pinLatestTurn: (target?: HTMLElement) => {
|
||||
const element = container.current;
|
||||
const run = () => {
|
||||
shouldLand.current = false;
|
||||
holdUnpin.current = true;
|
||||
anchoredRef.current = false;
|
||||
if (!element) {
|
||||
setAnchor({ key: resetKey, anchored: false });
|
||||
setLatestBelowFold(false);
|
||||
return;
|
||||
}
|
||||
const head = resolveTurnHead(element, target);
|
||||
pinnedHeadRef.current = head;
|
||||
applyTurnSpacer(element, head);
|
||||
const space = readPinSpacePx(element);
|
||||
const top = head ? pinTurnScrollTop(element, head, space) : 0;
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
element.scrollTo({ top, behavior: reduceMotion ? "auto" : "smooth" });
|
||||
lastScrollTop.current = element.scrollTop;
|
||||
setAnchor({ key: resetKey, anchored: false });
|
||||
setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
|
||||
};
|
||||
if (typeof window === "undefined") {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(run);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,24 @@ export function consultSendBlockedByRectificationSession(
|
||||
return isRectificationChatSession(session);
|
||||
}
|
||||
|
||||
/** Empty active session with only rectification rows still occupies the ordinary composer. */
|
||||
export function composerLocksAsRectification(
|
||||
activeSession: { sessionType?: string } | null | undefined,
|
||||
sessions: readonly { sessionType: string }[],
|
||||
): boolean {
|
||||
if (isRectificationChatSession(activeSession)) return true;
|
||||
if (activeSession) return false;
|
||||
return sessions.some((session) => session.sessionType === "birth_time_rectification");
|
||||
}
|
||||
|
||||
export function missingActiveSessionSendAction(
|
||||
sessions: readonly { id: string; sessionType: string }[],
|
||||
): { openSessionId: string } | { notice: string } {
|
||||
const rectification = sessions.find((session) => session.sessionType === "birth_time_rectification");
|
||||
if (rectification) return { openSessionId: rectification.id };
|
||||
return { notice: "请先选一条对话,或点上方「新建对话」。" };
|
||||
}
|
||||
|
||||
export function dropRectificationStoredPending<
|
||||
S extends { sessionId: string },
|
||||
T extends { id: string; sessionType: string },
|
||||
@@ -63,5 +81,6 @@ export function rectificationOrdinaryComposerReopen(input: {
|
||||
if (!input.activeRectificationSession || !input.error || input.error === "profile_incomplete") {
|
||||
return false;
|
||||
}
|
||||
if (!input.sessionId) return true;
|
||||
return !input.errorSessionId || input.errorSessionId === input.sessionId;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function toSidebarSessionRow(
|
||||
): SidebarSession {
|
||||
return {
|
||||
id: session.id,
|
||||
title: sessionSidebarTitle(session, library),
|
||||
title: sessionSidebarTitle(session),
|
||||
subtitle: sessionSidebarSubtitle(session, library) || null,
|
||||
pinned: session.pinned,
|
||||
archived: Boolean(session.archivedAt),
|
||||
|
||||
@@ -86,7 +86,10 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
|
||||
archivedAt: null,
|
||||
messagesHydrated: true,
|
||||
};
|
||||
assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机");
|
||||
// 原值: sessionSidebarTitle(base, library)
|
||||
// 新值: sessionSidebarTitle(base)
|
||||
// 原因: sessionSidebarTitle 只吃 session;第二参是 e4e73f56 的 TS 错,挡住 tsc / next build
|
||||
assert.equal(sessionSidebarTitle(base), "半年内换工作时机");
|
||||
assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14");
|
||||
const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const };
|
||||
assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方");
|
||||
|
||||
@@ -61,7 +61,7 @@ test("both insufficient-credit paths are soft so the typed question is not throw
|
||||
assert.match(creditGuard, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||||
assert.doesNotMatch(creditGuard, /window\.location/);
|
||||
const creditPush = sendSource.indexOf('openAccountDialog("billing", { source: "insufficient-credits" })');
|
||||
const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");");
|
||||
const sendClearsComposer = sendSource.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");");
|
||||
assert.ok(creditPush >= 0 && sendClearsComposer > creditPush);
|
||||
assert.equal(pageSource.match(/openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 2);
|
||||
});
|
||||
@@ -108,10 +108,16 @@ test("bootstrap failure retries with a hard reload because refresh cannot rerun
|
||||
});
|
||||
|
||||
test("the streaming reply announces state transitions, never streamed deltas", () => {
|
||||
// Given: one page-level status region outside every aria-busy subtree.
|
||||
// 原值:page.tsx 里 `<main className="chat-app">` 紧跟 replyAnnouncement 的 sr-only 播报
|
||||
// 新值:`<main className="chat-app">` 只在 (app)/layout.tsx 出现一次;播报仍在 page.tsx,是 SidebarInset 内容的第一个子节点
|
||||
// 原因:T2 外壳搬家,播报仍在对话页、仍在 aria-busy 列表之外
|
||||
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
||||
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.equal(layout.match(/<main className="chat-app">/g)?.length, 1);
|
||||
assert.doesNotMatch(pageFile, /<main className="chat-app">/);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
|
||||
pageFile,
|
||||
/return \(\s*<>\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
|
||||
);
|
||||
|
||||
// Then: the region is fed a phase-derived string, not the streaming text.
|
||||
|
||||
@@ -3,7 +3,17 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { noticeTone } from "../src/lib/chat-notice.ts";
|
||||
import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
|
||||
import {
|
||||
applyFollowBottom,
|
||||
applyTurnSpacer,
|
||||
clearTurnSpacer,
|
||||
conversationAnchorThreshold,
|
||||
latestContentBelowFold,
|
||||
nextAnchorState,
|
||||
pinTurnScrollTop,
|
||||
resolveTurnHead,
|
||||
turnTailOverflow,
|
||||
} from "../src/hooks/use-conversation-scroll-anchor.ts";
|
||||
import { homeSurface } from "./home-surface.ts";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
@@ -72,12 +82,12 @@ test("anchors the streaming scroll instead of following every token", () => {
|
||||
// `container.scrollTo` after an `anchored` guard. That effect ran once per token and added
|
||||
// a second smooth scroll on settle; the follow now lives in the hook, driven by a
|
||||
// ResizeObserver over the scroller's children, one frame per change (BUG-478).
|
||||
// 原值: follow() 在 !anchored 时直接 return
|
||||
// 新值: follow() 仅在 anchored 时写 scrollTop = scrollHeight,未钉住时只刷新 spacer
|
||||
// 原因: BUG-930 流式期间视口钉在本轮开头,留白变量仍要随高度更新
|
||||
assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
|
||||
const follow = sourceBetween(anchorSource, "const follow = () => {", "};");
|
||||
assert.match(follow, /if \(!anchoredRef\.current\) return/);
|
||||
const scrollGuard = follow.indexOf("if (!anchoredRef.current) return");
|
||||
const scrollCall = follow.indexOf("element.scrollTop = element.scrollHeight");
|
||||
assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
|
||||
assert.match(anchorSource, /if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;/);
|
||||
assert.match(anchorSource, /applyTurnSpacer\(element,/);
|
||||
assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/);
|
||||
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
|
||||
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
|
||||
@@ -88,9 +98,12 @@ test("scrolls to the newest turn on intentional jumps", () => {
|
||||
assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/);
|
||||
assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/);
|
||||
|
||||
// And: sending a question re-anchors before the optimistic turn renders.
|
||||
// 原值: send() 调 anchorToLatest(),乐观入列后贴底
|
||||
// 新值: send() 调 pinLatestTurn(),下一帧把本轮开头钉到顶部
|
||||
// 原因: BUG-930
|
||||
const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
|
||||
assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
|
||||
assert.match(sendBlock, /conversationAnchor\.pinLatestTurn\(\)/);
|
||||
assert.doesNotMatch(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
|
||||
});
|
||||
|
||||
test("offers an accessible jump-to-latest control while reading history", () => {
|
||||
@@ -98,11 +111,14 @@ test("offers an accessible jump-to-latest control while reading history", () =>
|
||||
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
|
||||
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
|
||||
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
|
||||
// 原值: jumpToLatestVisible 看 !conversationAnchor.anchored
|
||||
// 新值: 看 conversationAnchor.latestBelowFold(底部距离 > 96px)
|
||||
// 原因: BUG-930 回答长出视口时即使读者没主动上滑也要出现「跳到最新」
|
||||
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
|
||||
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
||||
const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
||||
|
||||
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
|
||||
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& conversationAnchor\.latestBelowFold/);
|
||||
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||||
assert.match(jumpSource, /type="button"/);
|
||||
assert.match(jumpSource, /aria-label="跳到最新"/);
|
||||
@@ -143,3 +159,221 @@ test("re-anchors once the reader returns to the newest turn", () => {
|
||||
assert.equal(nextAnchorState(false, 600, false), false);
|
||||
assert.equal(nextAnchorState(false, 40, false), true);
|
||||
});
|
||||
|
||||
test("pins a new turn at the head instead of following streamed growth", () => {
|
||||
const user = fakeRow("message-user", 16, 48);
|
||||
const assistant = fakeRow("message-assistant", 80, 200);
|
||||
const scroller = fakeScroller([user, assistant], 400);
|
||||
const pinned = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
|
||||
scroller.scrollTop = pinned;
|
||||
const start = scroller.scrollTop;
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
assistant.offsetHeight += 80;
|
||||
scroller.scrollHeight += 80;
|
||||
applyFollowBottom(scroller, false);
|
||||
}
|
||||
assert.equal(scroller.scrollTop, start);
|
||||
assert.equal(start, user.offsetTop - 16);
|
||||
|
||||
applyFollowBottom(scroller, true);
|
||||
assert.equal(scroller.scrollTop, scroller.scrollHeight);
|
||||
});
|
||||
|
||||
test("latestBelowFold tracks overflow past the fold and clears at the bottom", () => {
|
||||
const user = fakeRow("message-user", 16, 48);
|
||||
const assistant = fakeRow("message-assistant", 80, 200);
|
||||
const scroller = fakeScroller([user, assistant], 400);
|
||||
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(user), 16);
|
||||
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
|
||||
|
||||
assistant.offsetHeight = 1200;
|
||||
scroller.scrollHeight = assistant.offsetTop + assistant.offsetHeight + 24;
|
||||
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), true);
|
||||
assert.ok(turnTailOverflow(asElement(scroller)) > conversationAnchorThreshold);
|
||||
|
||||
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
|
||||
assert.equal(latestContentBelowFold(turnTailOverflow(asElement(scroller))), false);
|
||||
});
|
||||
|
||||
test("resolveTurnHead prefers the user row unless this turn has no user", () => {
|
||||
const typedUser = fakeRow("message-user", 8, 40);
|
||||
const typedAssistant = fakeRow("message-assistant", 64, 80);
|
||||
const typed = fakeScroller([typedUser, typedAssistant], 400);
|
||||
assert.equal(resolveTurnHead(asElement(typed)), asElement(typedUser));
|
||||
|
||||
const priorUser = fakeRow("message-user", 8, 40);
|
||||
const priorAssistant = fakeRow("message-assistant", 64, 80);
|
||||
const choiceAssistant = fakeRow("message-assistant", 160, 80);
|
||||
const choice = fakeScroller([priorUser, priorAssistant, choiceAssistant], 400);
|
||||
assert.equal(resolveTurnHead(asElement(choice)), asElement(choiceAssistant));
|
||||
|
||||
const opening = fakeRow("message-assistant", 0, 80);
|
||||
const openingScroller = fakeScroller([opening], 400);
|
||||
assert.equal(resolveTurnHead(asElement(openingScroller)), asElement(opening));
|
||||
});
|
||||
|
||||
test("turn spacer uses conversation viewport variables and is not sticky", () => {
|
||||
assert.match(
|
||||
globalsSource,
|
||||
/\.conversation \.message-list > :last-child \.message-assistant \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
|
||||
);
|
||||
const spacerRule = sourceBetween(
|
||||
globalsSource,
|
||||
".conversation .message-list > :last-child .message-assistant {",
|
||||
"}",
|
||||
);
|
||||
assert.doesNotMatch(spacerRule, /sticky/);
|
||||
assert.doesNotMatch(anchorSource, /position:\s*sticky/);
|
||||
|
||||
const scroller = fakeScroller([], 400);
|
||||
const head = fakeRow("message-user", 16, 48);
|
||||
applyTurnSpacer(asElement(scroller), asElement(head));
|
||||
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "400px");
|
||||
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "48px");
|
||||
clearTurnSpacer(asElement(scroller));
|
||||
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
|
||||
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "");
|
||||
});
|
||||
|
||||
test("a turn with no user row pins the new assistant and uses a full-viewport spacer", () => {
|
||||
const priorUser = fakeRow("message-user", 8, 40);
|
||||
const priorAssistant = fakeRow("message-assistant", 56, 80);
|
||||
const laterUser = fakeRow("message-user", 152, 40);
|
||||
const laterAssistant = fakeRow("message-assistant", 200, 80);
|
||||
const incoming = fakeRow("message-assistant", 480, 80);
|
||||
const scroller = fakeScroller([priorUser, priorAssistant, laterUser, laterAssistant, incoming], 600);
|
||||
const head = resolveTurnHead(asElement(scroller));
|
||||
assert.equal(head, asElement(incoming));
|
||||
applyTurnSpacer(asElement(scroller), head);
|
||||
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "0px");
|
||||
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(incoming), 16);
|
||||
assert.equal(scroller.scrollTop, incoming.offsetTop - 16);
|
||||
const start = scroller.scrollTop;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
incoming.offsetHeight += 80;
|
||||
scroller.scrollHeight += 80;
|
||||
applyFollowBottom(scroller, false);
|
||||
}
|
||||
assert.equal(scroller.scrollTop, start);
|
||||
});
|
||||
|
||||
test("idle history does not write a spacer when the reader has only scrolled up", () => {
|
||||
// 原值: follow() 在 !anchored 时无条件 applyTurnSpacer
|
||||
// 新值: 只在 pinnedHeadRef 非空时写留白
|
||||
// 原因: BUG-932 闲置会话上滑后子元素尺寸变化不应把最后一条助手行撑开
|
||||
assert.match(
|
||||
anchorSource,
|
||||
/if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;\s*\} else if \(pinnedHeadRef\.current\) \{\s*applyTurnSpacer\(element, pinnedHeadRef\.current\);/,
|
||||
);
|
||||
assert.match(anchorSource, /pinnedHeadRef\.current = null;\s*if \(element\) \{\s*clearTurnSpacer\(element\);/);
|
||||
const user = fakeRow("message-user", 8, 40);
|
||||
const assistant = fakeRow("message-assistant", 56, 900);
|
||||
const scroller = fakeScroller([user, assistant], 600);
|
||||
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
|
||||
scroller.scrollTop = Math.max(0, scroller.scrollTop - 400);
|
||||
const start = scroller.scrollTop;
|
||||
assistant.offsetHeight += 30;
|
||||
scroller.scrollHeight += 30;
|
||||
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
|
||||
assert.equal(Math.abs(scroller.scrollTop - start) <= 1, true);
|
||||
});
|
||||
|
||||
test("consultation send and rectification new turns call pinLatestTurn", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const pins = chat.match(/conversationAnchor\.pinLatestTurn\(\)/g) ?? [];
|
||||
assert.ok(pins.length >= 3, `expected three pinLatestTurn calls, got ${pins.length}`);
|
||||
assert.doesNotMatch(chat, /conversation\.current\?\.scrollTo|element\.scrollTo/);
|
||||
assert.match(chat, /conversationAnchor\.latestBelowFold/);
|
||||
assert.match(anchorSource, /window\.requestAnimationFrame\(run\)/);
|
||||
});
|
||||
|
||||
type FakeRow = {
|
||||
className: string;
|
||||
offsetTop: number;
|
||||
offsetHeight: number;
|
||||
order: number;
|
||||
compareDocumentPosition: (other: Node) => number;
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
};
|
||||
|
||||
type FakeScroller = {
|
||||
rows: FakeRow[];
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
children: unknown[];
|
||||
style: {
|
||||
setProperty: (name: string, value: string) => void;
|
||||
getPropertyValue: (name: string) => string;
|
||||
removeProperty: (name: string) => void;
|
||||
};
|
||||
querySelectorAll: (selector: string) => FakeRow[];
|
||||
querySelector: (selector: string) => FakeRow | null;
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
};
|
||||
|
||||
function asElement(value: FakeRow | FakeScroller): HTMLElement {
|
||||
return value as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
function fakeRow(className: string, offsetTop: number, offsetHeight: number): FakeRow {
|
||||
const row = {
|
||||
className,
|
||||
offsetTop,
|
||||
offsetHeight,
|
||||
order: 0,
|
||||
compareDocumentPosition(other: Node) {
|
||||
const theirOrder = (other as unknown as FakeRow).order ?? 0;
|
||||
if (this.order < theirOrder) return 4;
|
||||
if (this.order > theirOrder) return 2;
|
||||
return 0;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { top: this.offsetTop, left: 0, right: 0, bottom: this.offsetTop + this.offsetHeight, width: 0, height: this.offsetHeight, x: 0, y: this.offsetTop, toJSON() { return this; } };
|
||||
},
|
||||
};
|
||||
return row;
|
||||
}
|
||||
|
||||
function fakeScroller(rows: FakeRow[], clientHeight: number): FakeScroller {
|
||||
rows.forEach((row, index) => {
|
||||
row.order = index;
|
||||
});
|
||||
const last = rows.at(-1);
|
||||
const styleValues = new Map<string, string>();
|
||||
const scroller = {
|
||||
rows,
|
||||
scrollTop: 0,
|
||||
clientHeight,
|
||||
scrollHeight: last ? last.offsetTop + last.offsetHeight + 24 : clientHeight,
|
||||
children: [],
|
||||
style: {
|
||||
setProperty(name: string, value: string) {
|
||||
styleValues.set(name, value);
|
||||
},
|
||||
getPropertyValue(name: string) {
|
||||
return styleValues.get(name) ?? "";
|
||||
},
|
||||
removeProperty(name: string) {
|
||||
styleValues.delete(name);
|
||||
},
|
||||
},
|
||||
querySelectorAll(selector: string) {
|
||||
const token = selector.replace(".", "");
|
||||
return rows.filter((row) => row.className === token);
|
||||
},
|
||||
querySelector(selector: string) {
|
||||
if (selector.includes(":last-child .message-assistant") || selector.includes(".message-assistant:last-of-type")) {
|
||||
return rows.filter((row) => row.className === "message-assistant").at(-1) ?? null;
|
||||
}
|
||||
if (selector.includes(".message-user:last-of-type")) {
|
||||
return rows.filter((row) => row.className === "message-user").at(-1) ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { top: 0, left: 0, right: 0, bottom: clientHeight, width: 0, height: clientHeight, x: 0, y: 0, toJSON() { return this; } };
|
||||
},
|
||||
};
|
||||
return scroller;
|
||||
}
|
||||
|
||||
@@ -213,12 +213,19 @@ test("home stays a client-read query on a static route", () => {
|
||||
assert.match(page, /window\.history\.replaceState/);
|
||||
assert.match(lib, /history\.pushState\(null, "", next\)/);
|
||||
assert.match(lib, /history\.replaceState\(null, "", next\)/);
|
||||
// 原值:首页自己并发拉账户/目录/列表,`const [nextAccount, modelCatalogResult, sessionsPayload]`
|
||||
// 新值:`await sessionListReady` 后读 `sessionListBoot()`,page.tsx 不再 `fetchSessions(`
|
||||
// 原因:T2 列表由 layout provider 拉一次;本条仍守「`/` 是静态路由上的客户端读」
|
||||
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
const preview = sourceBetween(
|
||||
page,
|
||||
pageFile,
|
||||
"if (previewMode) {",
|
||||
"const [nextAccount, modelCatalogResult, sessionsPayload]",
|
||||
"await sessionListReady;",
|
||||
);
|
||||
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
|
||||
assert.match(pageFile, /await sessionListReady;/);
|
||||
assert.match(pageFile, /sessionListBoot\(\)/);
|
||||
assert.doesNotMatch(pageFile, /fetchSessions\(/);
|
||||
assert.match(page, /SESSION_MISSING_NOTICE/);
|
||||
assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除");
|
||||
});
|
||||
|
||||
@@ -341,7 +341,10 @@ test("Agentic rectification follows the conversation tail only while the reader
|
||||
assert.match(chat, /const conversation = useRef<HTMLElement>\(null\)/);
|
||||
assert.match(chat, /<section[\s\S]*ref=\{conversation\}[\s\S]*className="conversation is-rectification"/);
|
||||
assert.match(chat, /const conversationAnchor = useConversationScrollAnchor\(conversation, true, caseId\)/);
|
||||
assert.match(chat, /\{!conversationAnchor\.anchored && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||||
// 原值: !conversationAnchor.anchored
|
||||
// 新值: conversationAnchor.latestBelowFold
|
||||
// 原因: BUG-930 两个面同一套「本轮开头钉顶」,跳到最新看内容是否长出视口
|
||||
assert.match(chat, /\{conversationAnchor\.latestBelowFold && \(\s*<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||||
assert.doesNotMatch(chat, /followTailRef|isNearBottom|shouldShowJumpToLatest|choiceCardsOpen|followLatestContent|updateFollowState|回到最新|onScroll=/);
|
||||
assert.doesNotMatch(styles, /\.rectification-jump-latest/);
|
||||
assert.match(styles, /\.jump-to-latest \{[^}]*justify-content: center/);
|
||||
|
||||
@@ -1457,7 +1457,10 @@ test("the public agent route treats structured choice as a non-model command", (
|
||||
// Former locks: `回到最新` and `followTailRef.current` — the rectification-only chip and follow
|
||||
// flag (BUG-478). The shared control and anchor are locked instead.
|
||||
assert.match(chat, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||||
assert.match(chat, /conversationAnchor\.anchored/);
|
||||
// 原值: conversationAnchor.anchored
|
||||
// 新值: conversationAnchor.latestBelowFold
|
||||
// 原因: BUG-930
|
||||
assert.match(chat, /conversationAnchor\.latestBelowFold/);
|
||||
});
|
||||
|
||||
test("rectification attempt timeout stays under the agent route budget", () => {
|
||||
|
||||
@@ -55,11 +55,15 @@ test("a history-list open failure shows under the clicked row, not on the starte
|
||||
pageSource,
|
||||
/rectificationError=\{rectificationErrorSessionId \? "" : rectificationError\}/,
|
||||
);
|
||||
assert.match(pageSource, /openErrorSessionId=\{rectificationErrorSessionId\}/);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/openErrorMessage=\{rectificationErrorSessionId \? rectificationErrorMessage : ""\}/,
|
||||
);
|
||||
// 原值:page.tsx 把 openErrorSessionId / openErrorMessage 直接传给 AppSidebar
|
||||
// 新值:layout 从 registration 传给 AppSidebar,首页 registerShellControls 载荷带这两个字段
|
||||
// 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到打开失败
|
||||
const layout = read("../src/app/(app)/layout.tsx");
|
||||
const shell = read("../src/hooks/use-home-shell-registration.ts");
|
||||
assert.match(layout, /openErrorSessionId=\{registration\?\.openErrorSessionId \?\? null\}/);
|
||||
assert.match(layout, /openErrorMessage=\{registration\?\.openErrorMessage \?\? ""\}/);
|
||||
assert.match(shell, /openErrorSessionId: rectificationErrorSessionId,/);
|
||||
assert.match(shell, /openErrorMessage: rectificationErrorSessionId \? rectificationErrorMessage : "",/);
|
||||
assert.match(starter, /rectificationError && !rectificationSurfaceOpen && \(/);
|
||||
assert.match(sidebar, /openErrorSessionId/);
|
||||
assert.match(sidebarRow, /error \? \(/);
|
||||
@@ -70,5 +74,5 @@ test("a history-list open failure shows under the clicked row, not on the starte
|
||||
const notice = cssDeclarations(".session-open-error", styles);
|
||||
assert.match(notice, /color:\s*var\(--color-danger\)/);
|
||||
assert.match(notice, /font-size:\s*var\(--type-overline\)/);
|
||||
assert.match(page, /openErrorSessionId=\{rectificationErrorSessionId\}/);
|
||||
assert.match(layout, /openErrorSessionId=\{registration\?\.openErrorSessionId \?\? null\}/);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@ import test from "node:test";
|
||||
|
||||
import { homeSurface as page } from "./home-surface.ts";
|
||||
import {
|
||||
composerLocksAsRectification,
|
||||
consultSendBlockedByRectificationSession,
|
||||
dropRectificationStoredPending,
|
||||
fallbackSessionId,
|
||||
missingActiveSessionSendAction,
|
||||
RECTIFICATION_OPENING_COMPOSER_PLACEHOLDER,
|
||||
RECTIFICATION_REOPEN_LABEL,
|
||||
rectificationOrdinaryComposerReopen,
|
||||
@@ -104,3 +106,34 @@ test("delete and popstate fallbacks do not setActiveSessionId onto a rectificati
|
||||
assert.match(sessions, /const fallbackId = fallbackSessionId\(listed\);/);
|
||||
assert.match(page, /sessions.find\(\(session\) => session.sessionType !== "birth_time_rectification"\)/);
|
||||
});
|
||||
|
||||
test("an empty active session still locks the composer when the list is only rectification", () => {
|
||||
assert.equal(
|
||||
composerLocksAsRectification(undefined, [{ sessionType: "birth_time_rectification" }]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
composerLocksAsRectification(undefined, [{ sessionType: "consultation" }]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
composerLocksAsRectification({ sessionType: "consultation" }, [{ sessionType: "birth_time_rectification" }]),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
missingActiveSessionSendAction([{ id: "r1", sessionType: "birth_time_rectification" }]),
|
||||
{ openSessionId: "r1" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
missingActiveSessionSendAction([]),
|
||||
{ notice: "请先选一条对话,或点上方「新建对话」。" },
|
||||
);
|
||||
assert.match(pageSource, /composerLocksAsRectification\(activeSession, sessions\)/);
|
||||
assert.match(sendFn, /missingActiveSessionSendAction\(sessions\)/);
|
||||
assert.match(sendFn, /openRectificationSession\(action\.openSessionId\)/);
|
||||
assert.match(sendFn, /setComposerNotice\(action\.notice\)/);
|
||||
assert.doesNotMatch(
|
||||
sendFn,
|
||||
/if \(!question \|\| !currentSession \|\| !modelCatalog \|\| !account\) return false;/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,13 @@ test("entry feedback is static: card and sidebar row say 正在打开, and nothi
|
||||
assert.doesNotMatch(starter, /InlineSpinner/);
|
||||
assert.match(sidebarRow, /aria-busy=\{opening \? true : undefined\}/);
|
||||
assert.match(sidebarRow, /RECTIFICATION_SIDEBAR_OPENING_NOTE/);
|
||||
assert.match(page, /openingSessionId=\{rectificationOpeningSessionId\}/);
|
||||
// 原值:page.tsx 把 openingSessionId 直接传给 AppSidebar
|
||||
// 新值:layout 从 registration 传入,首页 registerShellControls 载荷带 openingSessionId
|
||||
// 原因:T2 外壳搬家,两端都断才等于侧栏行仍拿得到正在打开
|
||||
const layout = read("../src/app/(app)/layout.tsx");
|
||||
const shell = read("../src/hooks/use-home-shell-registration.ts");
|
||||
assert.match(layout, /openingSessionId=\{registration\?\.openingSessionId \?\? null\}/);
|
||||
assert.match(shell, /openingSessionId: rectificationOpeningSessionId,/);
|
||||
// 原值 `.product-entrypoint-card[data-opening="true"]` / 新值 `.starter-entry[data-opening="true"]`
|
||||
// / 原因:入口从卡片降级成 pill。「打开 Case 时只换指针、不出 spinner」这条不变。
|
||||
assert.match(styles, /\.starter-entry\[data-opening="true"\][^{]*\{ cursor: progress; \}/);
|
||||
|
||||
@@ -17,13 +17,17 @@ def _home_entrypoint_surface() -> str:
|
||||
|
||||
|
||||
def test_daily_starlanguage_entrypoint_is_productized() -> None:
|
||||
# 原值:断言 daily-starlanguage-card(空状态大卡)
|
||||
# 新值:StarterEntries 的 starter-entry pill + startDailyStarlanguageConsultation
|
||||
# 原因:62903b7a 已把卡片改成问候 + 输入框 + 两枚 pill,全仓已无该类名
|
||||
source = _home_entrypoint_surface()
|
||||
assert "今日星语" in source
|
||||
assert "fetchDailyStarlanguage" in source
|
||||
assert "daily-starlanguage-card" in source
|
||||
assert "starter-entry" in source
|
||||
assert "startDailyStarlanguageConsultation" in source
|
||||
assert "从今日问起" in source
|
||||
assert "今天的星语还没写出来。" in source
|
||||
assert "daily-starlanguage-card" not in source
|
||||
assert "buildDailyStarlanguageCard" not in source
|
||||
assert "点这里" not in source
|
||||
assert "不会用通用文案顶替" not in source
|
||||
@@ -40,10 +44,14 @@ def test_daily_starlanguage_api_declares_honest_source_boundary() -> None:
|
||||
|
||||
|
||||
def test_birth_time_rectification_entrypoint_is_productized() -> None:
|
||||
# 原值:断言 birth-rectification-card
|
||||
# 新值:同一套 starter-entry pill + openRectificationFromHomepage
|
||||
# 原因:同上,卡片入口已删除
|
||||
source = _home_entrypoint_surface()
|
||||
assert "生时校正" in source
|
||||
assert "birth-rectification-card" in source
|
||||
assert "starter-entry" in source
|
||||
assert "openRectificationFromHomepage" in source
|
||||
assert "birth-rectification-card" not in source
|
||||
|
||||
|
||||
def test_birth_rectification_api_proxies_active_questionnaire_with_boundary() -> None:
|
||||
|
||||
Reference in New Issue
Block a user