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 <cursoragent@cursor.com>
This commit is contained in:
+13
-13
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user