From cc1a89802ea2965e3453b5b832998bdf006df503 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 17 Sep 2026 22:25:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=97=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=A1=8C=E9=92=89=E9=A1=B6=E3=80=81=E9=97=B2=E7=BD=AE=E4=B8=8D?= =?UTF-8?q?=E4=B8=8A=E7=95=99=E7=99=BD=EF=BC=8C=E5=B9=B6=E4=BF=AE=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=97=E8=A1=A8=20tsc=20=E4=B8=A4=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BUG_HISTORY.md | 36 +++++++++++++++- ...tation-answer-start-anchor-fix-20260917.md | 26 +++++++++++ docs/tasks/README.md | 2 +- ...nsultation-answer-start-anchor-20260917.md | 6 ++- .../hooks/use-conversation-scroll-anchor.ts | 31 +++++++++---- frontend/src/lib/session-sidebar-row.ts | 2 +- frontend/tests/chart-library-session.test.ts | 5 ++- .../chat-notice-and-scroll-contract.test.ts | 43 +++++++++++++++++++ 8 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 docs/tasks/PROGRESS-consultation-answer-start-anchor-fix-20260917.md diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 22715f99..db9ac96e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12143,8 +12143,40 @@ - 触发条件:主会话或生时校正发出一轮,模型输出超过一屏。 - 根因:跟随语义是「贴底」。`anchored` 初值为真,`ResizeObserver` 在内容变高时把 `scrollTop` 设到 `scrollHeight`;`send()` 还会 `anchorToLatest()`。对短回答两者等价,对长回答则把开头推出视口。 - 修复:同一 hook 改为新一轮把本轮开头(用户行,或没有用户行时的新助手行)钉在滚动容器顶部,流式期间不再跟随。回答长出视口时用已有的「跳到最新」贴底并恢复跟随。末尾动态留白让短回答也能把开头留在顶部。切换会话仍先落底。取代 BUG-041 / BUG-048 的贴底语义。 -- 验证:`chat-notice-and-scroll-contract.test.ts` 源码合同与行为测试;校正面 `pinLatestTurn` 三处源码断言。 +- 验证:`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-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 - 复发自:无 - 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-consultation-answer-start-anchor-fix-20260917.md b/docs/tasks/PROGRESS-consultation-answer-start-anchor-fix-20260917.md new file mode 100644 index 00000000..0779ef73 --- /dev/null +++ b/docs/tasks/PROGRESS-consultation-answer-start-anchor-fix-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 门禁应能过类型检查。 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 86f6a770..4cf75f69 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -133,7 +133,7 @@ | `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` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 验收未通过:主会话钉顶/跳到最新/切会话(Chrome 真实布局 S1–S4)通过;P1 无用户行的一轮(校正点选项/开场)留白自指坍缩、钉不住并回退贴底(BUG-931);P2 闲置会话上滑后子元素尺寸变化被写留白(BUG-932)。tsc/build 红继承自 e4e73f56。修复单 `TASK-consultation-answer-start-anchor-fix-20260917.md` | `worktree/green-harbor-5be3` | -| `TASK-consultation-answer-start-anchor-fix-20260917.md` | — | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 待领取 | — | +| `TASK-consultation-answer-start-anchor-fix-20260917.md` | `PROGRESS-consultation-answer-start-anchor-fix-20260917.md` | 验收修复单:F1 头就是留白行时留白按整视口算(BUG-931);F2 留白只在钉住期间存在(BUG-932);前置:先修 e4e73f56 的两处 TS 错否则门禁不过 | 待验收 | `worktree/green-harbor-5be3` | ### 个人报告 diff --git a/docs/testing/consultation-answer-start-anchor-20260917.md b/docs/testing/consultation-answer-start-anchor-20260917.md index 31cedeb8..e52e6f10 100644 --- a/docs/testing/consultation-answer-start-anchor-20260917.md +++ b/docs/testing/consultation-answer-start-anchor-20260917.md @@ -6,5 +6,7 @@ 2. 回答超过一屏后出现「跳到最新」;按下贴底,并跟随到这一轮结束。 3. 短回答(一两句)问题仍在顶部,下方有留白,不抖动。 4. 切换到别的已有会话:落在底部(最新内容)。 -5. 生时校正:打字提交、点选项、开场三种情况下本轮开头都在顶部;长旁白时出现「跳到最新」,按下能看到选择卡。 -6. iPhone 键盘弹起再收起后位置不跳(配合 BUG-920 一起看)。 +5. 生时校正点选项后:新助手行在顶部,回答从它下面往下长,不贴底。 +6. 生时校正开场轮:第一条助手行在顶部。 +7. 生时校正打字提交:用户行在顶部;长旁白时出现「跳到最新」,按下能看到选择卡。 +8. iPhone 键盘弹起再收起后位置不跳(配合 BUG-920 一起看)。 diff --git a/frontend/src/hooks/use-conversation-scroll-anchor.ts b/frontend/src/hooks/use-conversation-scroll-anchor.ts index 4c8a44c7..bc9b3091 100644 --- a/frontend/src/hooks/use-conversation-scroll-anchor.ts +++ b/frontend/src/hooks/use-conversation-scroll-anchor.ts @@ -76,9 +76,26 @@ export function applyFollowBottom( 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", `${head?.offsetHeight ?? 0}px`); + container.style.setProperty( + "--latest-turn-head-height", + `${turnHeadHeightForSpacer(head, lastTurnTail(container))}px`, + ); } export function clearTurnSpacer(container: HTMLElement): void { @@ -86,12 +103,6 @@ export function clearTurnSpacer(container: HTMLElement): void { container.style.removeProperty("--latest-turn-head-height"); } -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 turnTailOverflow(container: HTMLElement): number { const last = lastTurnTail(container); if (!last) return conversationDistanceFromBottom(container); @@ -198,8 +209,8 @@ export function useConversationScrollAnchor( } if (anchoredRef.current) { element.scrollTop = element.scrollHeight; - } else { - applyTurnSpacer(element, pinnedHeadRef.current ?? resolveTurnHead(element)); + } else if (pinnedHeadRef.current) { + applyTurnSpacer(element, pinnedHeadRef.current); } setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element))); }; @@ -240,7 +251,9 @@ export function useConversationScrollAnchor( 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); diff --git a/frontend/src/lib/session-sidebar-row.ts b/frontend/src/lib/session-sidebar-row.ts index 843dcf5f..8489870a 100644 --- a/frontend/src/lib/session-sidebar-row.ts +++ b/frontend/src/lib/session-sidebar-row.ts @@ -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), diff --git a/frontend/tests/chart-library-session.test.ts b/frontend/tests/chart-library-session.test.ts index 87b8223a..ed3af84d 100644 --- a/frontend/tests/chart-library-session.test.ts +++ b/frontend/tests/chart-library-session.test.ts @@ -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 · 资料已删除 · 对方"); diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index c7649fd1..56823237 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -235,6 +235,49 @@ test("turn spacer uses conversation viewport variables and is not sticky", () => 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) ?? [];