From 792ead0febbcceb7a34c2717e6144635e73d5e8b Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 10 Aug 2026 01:35:35 -0400 Subject: [PATCH] fix: anchor the jump button to the composer Absolutely positioned inside .scroll it scrolled with the content, so it drifted into the middle of the thread. It now hangs off the composer's top edge as a labelled pill, fades rather than pops, and glides to the end on a capped tween instead of the browser's distance-paced smooth scroll. --- src/client/app.css | 41 +++++++++++++++++++++++++++------- src/client/app.js | 52 ++++++++++++++++++++++++++++++++++++++++--- src/client/index.html | 5 ++++- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/client/app.css b/src/client/app.css index 3e82d7b..4a078b5 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -1536,33 +1536,57 @@ body.selecting #selectBtn { padding: 1px 5px; } +/* Anchored to the top edge of the composer (see .dockinner), so it holds still + while the thread scrolls under it and stays put as the composer grows. Mono + and small, like the other meta labels — it's a hint, not an action to take. */ .jump { position: absolute; left: 50%; - transform: translateX(-50%); - bottom: 14px; - width: 34px; - height: 34px; - display: none; + bottom: 100%; + margin-bottom: 10px; + display: inline-flex; align-items: center; - justify-content: center; + gap: 5px; + padding: 5px 11px 5px 9px; + font-family: var(--mono); + font-size: 11.5px; + white-space: nowrap; color: var(--ink-muted); background: var(--bg-raise); border: 1px solid var(--rule-strong); border-radius: var(--radius-pill); cursor: pointer; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.14); + /* Hidden by default: it fades in rather than popping, and stays out of the + way of clicks (and the tab order) while it's away. */ + opacity: 0; + visibility: hidden; + transform: translate(-50%, 6px); transition: + opacity 0.14s ease, + transform 0.14s ease, + visibility 0.14s, background 0.12s ease, color 0.12s ease; } +.jump.show { + opacity: 1; + visibility: visible; + transform: translate(-50%, 0); +} .jump:hover { background: var(--bg-sunk); color: var(--ink); } .jump svg { - width: 18px; - height: 18px; + width: 13px; + height: 13px; + opacity: 0.7; +} +@media (prefers-reduced-motion: reduce) { + .jump { + transition: none; + } } /* ---- composer dock ----------------------------------------------------- */ @@ -1571,6 +1595,7 @@ body.selecting #selectBtn { background: var(--bg); } .dockinner { + position: relative; /* the jump button hangs off its top edge */ max-width: var(--measure); margin: 0 auto; padding: 8px 16px 16px; diff --git a/src/client/app.js b/src/client/app.js index aa99943..333f334 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -2455,12 +2455,58 @@ import { mountSidebar } from "./sidebar.js"; }); scroll.addEventListener("scroll", function () { atBottom = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40; - jump.style.display = atBottom ? "none" : "flex"; + jump.classList.toggle("show", !atBottom); }); + // Glide to the end rather than teleporting, so it's clear the thread moved + // rather than swapped — but on our clock, not the browser's. Native smooth + // scrolling paces itself by distance and takes well over a second down a long + // conversation. Streaming's own autoScroll stays instant; a tween per flush + // would fight itself. + // Snappy off the line, and distance barely lengthens it: a screen away and a + // hundred screens away should both feel like one flick. + var JUMP_BASE_MS = 80; + var JUMP_MS_PER_PX = 0.06; + var JUMP_MAX_MS = 500; + var jumpAnim = null; + function glideToBottom() { + if (jumpAnim) cancelAnimationFrame(jumpAnim); + jumpAnim = null; + var from = scroll.scrollTop; + var dist = scroll.scrollHeight - scroll.clientHeight - from; + if (dist <= 0 || matchMedia("(prefers-reduced-motion: reduce)").matches) { + scroll.scrollTop = scroll.scrollHeight; + return; + } + // Short hops shouldn't take as long as long ones, but nothing takes longer + // than the ceiling. + var ms = Math.min(JUMP_MAX_MS, JUMP_BASE_MS + dist * JUMP_MS_PER_PX); + var t0 = null; + jumpAnim = requestAnimationFrame(function step(now) { + if (t0 === null) t0 = now; + var p = Math.min(1, (now - t0) / ms); + var eased = 1 - (1 - p) ** 3; // easeOutCubic, same as the context gauge + // Re-read the end each frame: a live run can extend the thread mid-flight. + scroll.scrollTop = from + (scroll.scrollHeight - scroll.clientHeight - from) * eased; + if (p < 1) jumpAnim = requestAnimationFrame(step); + else { + jumpAnim = null; + scroll.scrollTop = scroll.scrollHeight; + } + }); + } + // Our own scrollTop writes fire `scroll`, so a wheel/touch is how we tell that + // the user changed their mind. Give way immediately when they do. + function cancelGlide() { + if (!jumpAnim) return; + cancelAnimationFrame(jumpAnim); + jumpAnim = null; + } + scroll.addEventListener("wheel", cancelGlide, { passive: true }); + scroll.addEventListener("touchstart", cancelGlide, { passive: true }); jump.addEventListener("click", function () { atBottom = true; - jump.style.display = "none"; - scroll.scrollTop = scroll.scrollHeight; + jump.classList.remove("show"); + glideToBottom(); }); // Keep the view pinned to the bottom while `atBottom` as the thread grows — // opening a large conversation, async enrich (code/math), and images all add diff --git a/src/client/index.html b/src/client/index.html index 2277b24..0b142bc 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -65,11 +65,14 @@
-
+
+