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\)/);