fix(chat): rest the jump-to-latest button on the composer, not the padded scroller
Independent Staging Quality Gate / validate (push) Failing after 11m39s
Independent Staging Quality Gate / publish (push) Has been skipped

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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 21:38:17 +08:00
parent 561010f2e7
commit 6ce4e67186
4 changed files with 44 additions and 15 deletions
+15
View File
@@ -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`,输入框长高、推荐问题行出现或消失时按钮都跟着走。按钮自身的可访问性属性(真实 `<button>``aria-label`、焦点环、44×44 触控区、`pointer-events-auto`)原样保留。
- 验证:新增合同测试固定新位置(断言控件位于 composer 块内、使用 `bottom-full`、不再出现 `sticky`),并把 BUG-218 / BUG-252 两个文件里按缩进切片的定位锚点改成与缩进无关;两文件 23 条断言全绿。几何结论这次经过浏览器实测:用一份复刻 `.chat-panel` / `.conversation` / `.composer-wrap` 真实规则的静态页面同时渲染新旧两种写法,量得旧写法距输入框 160px、新写法 11px,与线上截图相符;量完即删,未留在仓库。`tsc``eslint` 清洁。未做的验证:**没有在真实登录会话里目视确认**(需要长对话与账户),复刻页面只覆盖了本条涉及的布局规则。
- 防复发:滚动容器留了 `padding-bottom` 时,它的 `position: sticky` 子元素永远无法贴到容器可视底边——sticky 受包含块内容盒夹持,调 `bottom` 偏移不解决问题。悬浮在输入框上方的控件应挂在输入框容器上(`bottom: 100%`),而不是挂在滚动容器里,这样才不依赖任何预留高度常量。另:BUG-218 当时已写明“浏览器内的视觉位置未经人工目视确认”,本条正是那句话对应的实际后果——纯源码合同测试能固定 DOM 与属性,固定不了几何位置,涉及定位的改动必须实测。
- 相关记录:BUG-218(引入该按钮与锚定逻辑)、BUG-252(曾把它记作 BUG-217 新增,并留下焦点丢失的待跟进项,本轮未处理)
- 修复版本:本地未提交候选
+13 -13
View File
@@ -3466,19 +3466,6 @@ export default function Home() {
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
{jumpToLatestVisible && (
<div className="pointer-events-none sticky bottom-3 z-10 flex h-0 items-end justify-center">
<button
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
type="button"
aria-label="跳到最新"
onClick={conversationAnchor.anchorToLatest}
>
<ArrowDown aria-hidden="true" className="size-4" />
</button>
</div>
)}
</div>
)}
@@ -3504,6 +3491,19 @@ export default function Home() {
)}
{!rectificationSurfaceOpen && !onboardingFormActive && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
{jumpToLatestVisible && (
<div className="pointer-events-none absolute inset-x-0 bottom-full flex justify-center pb-3">
<button
className="pointer-events-auto inline-flex min-h-11 min-w-11 items-center gap-1.5 rounded-full border border-border bg-canvas px-4 text-sm text-ink shadow-md transition-colors outline-none hover:bg-canvas-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
type="button"
aria-label="跳到最新"
onClick={conversationAnchor.anchorToLatest}
>
<ArrowDown aria-hidden="true" className="size-4" />
</button>
</div>
)}
{activeSuggestions.length > 0 && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
@@ -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 && (", "</div>\n )}");
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
const buttonTag = sourceBetween(jumpControl, "<button", ">");
// Given: a real button in document order, never removed from the tab sequence.
@@ -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 && (", "</div>\n )}");
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
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 ", "<ChatComposer");
const jumpControl = sourceBetween(composerWrap, "{jumpToLatestVisible && (", "</button>");
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\)/);