From 6ce4e671869bf08099a8c8e0828c08d0f4be5bfa Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 17 Aug 2026 21:38:17 +0800 Subject: [PATCH] fix(chat): rest the jump-to-latest button on the composer, not the padded scroller The button was a sticky child of the scroll container, which reserves composer-sized bottom padding. Sticky boxes are clamped by their containing block's content box, so the 12px offset never applied and the button parked 160px above the composer, on top of the transcript. Anchor it to the composer wrapper's own top edge instead, so the offset no longer depends on the --composer-reserve constant. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 15 +++++++++++ frontend/src/app/page.tsx | 26 +++++++++---------- .../chat-navigation-a11y-contract.test.ts | 2 +- .../chat-notice-and-scroll-contract.test.ts | 16 +++++++++++- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2007b7e4..058bfb68 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3878,3 +3878,18 @@ - 防复发:镜像构建日志里的 `EBADENGINE` 不得当作噪声跳过——它意味着依赖声明的运行环境与镜像实际提供的不一致,而 npm 不会阻止这种安装,因此它是少数「构建期唯一一次提示、之后只会在运行期爆发」的信号。基础镜像用浮动 tag(`node:22-alpine`)时,实际 Node 版本会随上游重建漂移,依赖的引擎下限也会随升级上移,两者相向移动,这类不匹配会反复出现。 - 相关记录:BUG-262 无前序同类记录。与 BUG-261 同为首次本地构建 staging 镜像时暴露的构建期问题。 - 修复版本:未修复 + +## BUG-263 | “跳到最新”按钮悬在输入框上方约 160px,压住正文而不是贴着输入框 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-17 +- 最近更新:2026-08-17 +- 影响面:`/` 主对话页在用户向上翻阅历史时出现的“跳到最新”控件。 +- 用户现象:按钮既没有贴着下方输入框,也没有落在空白处,而是悬在正文中间挡住一整行回答文字;视觉上不像输入框的附属控件,更像漂在内容上的异物。 +- 触发条件:会话已有消息,用户向上滚动超过 96px 锚定阈值。桌面端偏移约 160px,移动端约 128px。 +- 根因:按钮此前是 `.conversation`(滚动容器)的 `position: sticky; bottom: 12px` 子元素,而 `.conversation` 带 `padding-bottom: var(--composer-reserve)`(桌面 148px / 移动 116px)。sticky 元素被钉在视口底部之前,会先被自己的包含块——也就是滚动容器的 **内容盒**——夹住,而内容盒底边正好比容器可视底边高出这一段 padding。于是 `bottom: 12px` 从未生效,按钮实际停在 padding + 12px 处。这段 padding 是早期输入框覆盖式布局的遗留:现在 `.chat-panel` 用 `grid-template-rows: 68px minmax(0,1fr) auto`,`.composer-wrap` 已是独立行、不再覆盖对话区。 +- 修复:把按钮从滚动容器移到 `.composer-wrap` 内部,改用 `absolute inset-x-0 bottom-full` + `pb-3`,让它挂在输入框区域自己的上边缘。`.composer-wrap` 本就是 `position: sticky`(定位元素)且 `z-index: 2`,可直接作为包含块,无需新增定位上下文。这样偏移不再依赖 `--composer-reserve`,输入框长高、推荐问题行出现或消失时按钮都跟着走。按钮自身的可访问性属性(真实 ` - - )} )} @@ -3504,6 +3491,19 @@ export default function Home() { )} {!rectificationSurfaceOpen && !onboardingFormActive &&
+ {jumpToLatestVisible && ( +
+ +
+ )} {activeSuggestions.length > 0 && (
{activeSuggestions.map((question) => ( diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index 88aea49b..51a5227c 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -199,7 +199,7 @@ test("aria-busy marks the updating message list without swallowing the status re }); test("the jump-to-latest control stays keyboard reachable and labelled", () => { - const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "
\n )}"); + const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ""); const buttonTag = sourceBetween(jumpControl, ""); // Given: a real button in document order, never removed from the tab sequence. diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index f0c2aae0..6130b3f9 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -8,6 +8,7 @@ import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts" const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8"); const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8"); +const globalsSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); function sourceBetween(source: string, startMarker: string, endMarker: string) { const start = source.indexOf(startMarker); @@ -86,7 +87,7 @@ test("scrolls to the newest turn on intentional jumps", () => { }); test("offers an accessible jump-to-latest control while reading history", () => { - const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "
\n )}"); + const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ""); assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/); assert.match(jumpControl, /type="button"/); @@ -97,6 +98,19 @@ test("offers an accessible jump-to-latest control while reading history", () => assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/); }); +test("rests the jump-to-latest control on the composer instead of the padded scroller", () => { + // Given: the scroll container reserves composer-sized padding, which clamps any sticky child + // of it that far above the composer, on top of the transcript the reader is still reading. + assert.match(globalsSource, /\.conversation \{[^}]*padding-bottom: var\(--composer-reserve\)/); + assert.match(globalsSource, /\.composer-wrap \{[^}]*position: sticky/); + + // Then: the control hangs off the composer's own top edge, whatever that reserve is. + const composerWrap = sourceBetween(pageSource, "className={`composer-wrap ", ""); + assert.match(jumpControl, /absolute inset-x-0 bottom-full/); + assert.doesNotMatch(jumpControl, /sticky/); +}); + test("keeps the scroll listener passive and reduced-motion aware", () => { assert.match(anchorSource, /addEventListener\("scroll", onScroll, \{ passive: true \}\)/); assert.match(anchorSource, /frame = window\.requestAnimationFrame\(measure\)/);