web+ios+mac: jump-to-latest button when scrolled up in chat (#906)
* web+ios+mac: jump-to-latest button when scrolled up in chat Shows a small down-arrow button bottom-right of the feed once the reader has scrolled a moderate distance up, letting them jump straight back to the newest message. Web tracks scroll gap directly; iOS/Mac read the LazyVStack container's frame (never culled by lazy rendering, unlike a leaf sentinel row) via a coordinate-space geometry probe. Co-Authored-By: Claude <noreply@anthropic.com> * jump-to-latest: title/tooltip + consistent accessible name Adds a title attribute (web) / .help() tooltip (Mac) and aligns the button's accessible name to "Jump to latest" across web, iOS, and Mac. Co-Authored-By: Claude <noreply@anthropic.com> ---------
Co-authored-by: Claude <noreply@anthropic.com>
5 files changed,
+153
−9
changelog/2026-07-14-jump-to-latest.md+3 −0ios/LChat/Views/NativeChatScreen.swift+50 −0ios/LChatMac/NativeFeedView.swift+54 −0web/static/apps/chat.js+44 −7web/static/shell.js+2 −2
diff --git a/changelog/2026-07-14-jump-to-latest.md b/changelog/2026-07-14-jump-to-latest.md| new file mode 100644 |
| index 0000000..42a773b |
| --- /dev/null |
| +++ b/changelog/2026-07-14-jump-to-latest.md |
| @@ -0,0 +1,3 @@ |
| +🤖 Scroll up a moderate distance in a chat and a small down-arrow button now |
| +appears in the bottom-right corner of the feed — tap it to jump straight back |
| +to the newest message instead of scrolling all the way down yourself. |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 98e19d9..7230b0f 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -169,6 +169,14 @@ struct NativeChatScreen: View { |
| } |
| // Whether the bottom sentinel is on screen — gates the scroll pins. |
| @State private var bottomVisible = false |
| + // Jump-to-latest: true once the reader has scrolled a MODERATE distance |
| + // up (see jumpButtonGap) — much wider than the pin logic's near-bottom |
| + // checks above, so the button doesn't appear for merely reading the |
| + // newest couple of messages. Set only at the threshold crossing (see the |
| + // sentinel's geometry probe in `feed`), so scrolling itself doesn't |
| + // trigger extra re-renders. |
| + @State private var jumpButtonVisible = false |
| + static let jumpButtonGap: CGFloat = 400 |
| // The composer inset's measured height — its growth re-pins the feed (see |
| // the onChange in `feed`): the inset resolves a frame or two AFTER the |
| // opening pin, which left the newest message hiding under the composer. |
| @@ -678,6 +686,11 @@ struct NativeChatScreen: View { |
| settled = true |
| } |
| + private func updateJumpButtonVisible(gap: CGFloat) { |
| + let show = gap > Self.jumpButtonGap |
| + if show != jumpButtonVisible { jumpButtonVisible = show } |
| + } |
| + |
| private var feed: some View { |
| GeometryReader { geo in |
| ScrollViewReader { proxy in |
| @@ -755,6 +768,22 @@ struct NativeChatScreen: View { |
| // Short chats read from the TOP: without this the bottom |
| // anchor bottom-aligns content shorter than the viewport. |
| .frame(minHeight: geo.size.height, alignment: .top) |
| + // Jump-button geometry probe: the CONTAINER's frame, not the |
| + // bottom sentinel's — a leaf row can be pruned by LazyVStack |
| + // once it's far enough off-screen, so reading gap off the |
| + // sentinel would freeze the wrong value the moment it's |
| + // culled, well before a real "moderate scroll" threshold. The |
| + // LazyVStack's own extent is never culled — SwiftUI needs it |
| + // for the scroll math regardless of which rows are realised — |
| + // so its maxY always reflects the true content bottom. State |
| + // only flips at the threshold crossing, so scrolling itself |
| + // never re-renders the feed. |
| + .background(GeometryReader { g in |
| + let gap = g.frame(in: .named("chatFeed")).maxY - geo.size.height |
| + Color.clear |
| + .onAppear { updateJumpButtonVisible(gap: gap) } |
| + .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) } |
| + }) |
| // Keyed on the LAST id: new arrivals animate, history prepends |
| // (which change the first id, not the last) don't. |
| .animation(landed ? .spring(response: 0.42, dampingFraction: 0.8) : nil, |
| @@ -765,6 +794,7 @@ struct NativeChatScreen: View { |
| scroll.keyboardDismissMode = .interactive |
| }) |
| } |
| + .coordinateSpace(name: "chatFeed") |
| // Anchor to the bottom BEFORE first layout — a prewarmed model |
| // arrives with its messages already folded, and a scrollTo in |
| // onAppear races layout and loses (the land-at-top bug). The |
| @@ -876,6 +906,26 @@ struct NativeChatScreen: View { |
| } |
| } |
| .animation(.easeOut(duration: 0.12), value: model.ready && settled) |
| + // Jump-to-latest (web parity): bottom-right of the feed, shown |
| + // once jumpButtonVisible flips (the sentinel probe above). |
| + .overlay(alignment: .bottomTrailing) { |
| + if model.ready && settled && jumpButtonVisible { |
| + Button { |
| + withAnimation(.easeOut(duration: 0.2)) { |
| + proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| + } |
| + } label: { |
| + Image(systemName: "chevron.down") |
| + .font(.system(size: 16, weight: .semibold)) |
| + .frame(width: 40, height: 40) |
| + .floatingGlass(in: Circle()) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("Jump to latest") |
| + .padding(16) |
| + .transition(.opacity) |
| + } |
| + } |
| } |
| } |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 5bfccd9..45a9144 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -286,6 +286,16 @@ struct NativeFeedView: View { |
| // grace, so a cached/fast load never flashes an empty ring. |
| @State private var videoDownload: (id: String, progress: Double)? |
| + // Jump-to-latest: shown once the reader has scrolled a MODERATE distance |
| + // up — much wider than the 150pt auto-pin slack used elsewhere in this |
| + // file, so the button doesn't appear for merely reading the newest |
| + // couple of messages. Toggled by the sentinel's geometry probe below, |
| + // written only on the boolean's actual edges (not every scroll tick) so |
| + // it never becomes another per-frame @Published-style re-render source |
| + // (see the FrameStore comment above on why `frames` itself isn't state). |
| + @State private var jumpButtonVisible = false |
| + static let jumpButtonGap: CGFloat = 400 |
| + |
| var body: some View { |
| ScrollViewReader { proxy in |
| // A ScrollView + LazyVStack, NOT a List: List's NSTableView-backed |
| @@ -393,6 +403,23 @@ struct NativeFeedView: View { |
| // Short chats read from the TOP: without this the bottom |
| // anchor bottom-aligns content shorter than the viewport. |
| .frame(minHeight: max(0, viewportHeight), alignment: .top) |
| + // Jump-button geometry probe: the CONTAINER's frame, not the |
| + // bottom sentinel's — a leaf row gets pruned by LazyVStack |
| + // once it's far enough off-screen (see the per-row probe |
| + // above, which does exactly that on .onDisappear), so reading |
| + // gap off the sentinel would freeze the wrong value the |
| + // moment it's culled, well before a real "moderate scroll" |
| + // threshold. The VStack's own extent is never culled — SwiftUI |
| + // needs it for the scroll math regardless of which rows are |
| + // realised — so its maxY always reflects the true content |
| + // bottom. State only flips at the threshold crossing, so |
| + // scrolling itself never re-renders the feed. |
| + .background(GeometryReader { g in |
| + let gap = g.frame(in: .named("feed")).maxY - frames.viewport.height |
| + Color.clear |
| + .onAppear { updateJumpButtonVisible(gap: gap) } |
| + .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) } |
| + }) |
| } |
| .coordinateSpace(name: "feed") |
| .background(GeometryReader { g in |
| @@ -519,6 +546,28 @@ struct NativeFeedView: View { |
| ProgressView().controlSize(.small) |
| } |
| } |
| + // Jump-to-latest (web parity): bottom-right of the feed, shown |
| + // once jumpButtonVisible flips (the sentinel probe above). |
| + .overlay(alignment: .bottomTrailing) { |
| + if model.ready && settled && jumpButtonVisible { |
| + Button { |
| + withAnimation(.easeOut(duration: 0.2)) { |
| + proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| + } |
| + } label: { |
| + Image(systemName: "chevron.down") |
| + .font(.system(size: 16, weight: .semibold)) |
| + .frame(width: 40, height: 40) |
| + .background(.regularMaterial, in: Circle()) |
| + .overlay(Circle().strokeBorder(.quaternary, lineWidth: 1)) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("Jump to latest") |
| + .help("Jump to latest") // mouse-hover tooltip (the web button's title=) |
| + .padding(16) |
| + .transition(.opacity) |
| + } |
| + } |
| } |
| // The thread view lifts over the FEED pane only (the web's |
| // .chatapp-feedwrap overlay) — applied before the composer inset, so |
| @@ -608,6 +657,11 @@ struct NativeFeedView: View { |
| // registered feed belongs to the pane selected at drag time. |
| } |
| + private func updateJumpButtonVisible(gap: CGFloat) { |
| + let show = gap > Self.jumpButtonGap |
| + if show != jumpButtonVisible { jumpButtonVisible = show } |
| + } |
| + |
| // Register this feed as its pane's drop receiver for as long as it is |
| // mounted — visibility is the drop surface's problem, not ours. A |
| // default-command thread (text-only surface) or an instance-less mount |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index f865d91..2593d69 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -153,6 +153,10 @@ const feedGlideCancels = new WeakSet(); |
| export function feedNearBottom(feed, slack = 80) { |
| return feedGlides.has(feed) || feed.scrollHeight - feed.scrollTop - feed.clientHeight < slack; |
| } |
| +// How far the reader has to scroll up before the "jump to latest" button |
| +// appears — deliberately much wider than feedNearBottom's 60px auto-pin |
| +// slack, so the button doesn't show for merely reading the newest message. |
| +const JUMP_BUTTON_GAP = 400; |
| export function feedGlideToBottom(feed, jump) { |
| if (!feedGlideCancels.has(feed)) { |
| feedGlideCancels.add(feed); |
| @@ -582,6 +586,7 @@ export const app = defineApp({ |
| padding-top: calc(18px + var(--native-top-inset, 0px)); |
| padding-bottom: calc(18px + var(--native-bottom-inset, 0px)); |
| } |
| +.native-embed .feed-jump-btn { bottom: calc(18px + var(--native-bottom-inset, 0px)); } |
| .chatapp-feed { |
| flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; |
| padding: 18px; display: flex; flex-direction: column; gap: 4px; |
| @@ -594,6 +599,20 @@ export const app = defineApp({ |
| flex: 1 1 auto; min-height: 0; min-width: 0; position: relative; |
| display: flex; flex-direction: column; |
| } |
| +/* Jump-to-latest: bottom-right of the feed, shown once the reader has |
| + scrolled a moderate distance up (see JUMP_BUTTON_GAP). Sits below the |
| + reply-thread overlay's z-index (30) so that view still covers it. */ |
| +.feed-jump-btn { |
| + position: absolute; right: 18px; bottom: 18px; z-index: 20; |
| + width: 40px; height: 40px; padding: 0; border-radius: 50%; |
| + display: inline-flex; align-items: center; justify-content: center; |
| + background: var(--bubble-them); color: var(--ink); |
| + box-shadow: 0 6px 20px var(--shadow); |
| + transition: opacity 0.15s ease, transform 0.15s ease; |
| +} |
| +.feed-jump-btn svg { width: 20px; height: 20px; } |
| +.feed-jump-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| +@media (prefers-reduced-motion: reduce) { .feed-jump-btn { transition: none; } } |
| ` + CARD_CSS, |
| // Incremental render: the skeleton (feed / banners / composer) is built once |
| @@ -737,6 +756,7 @@ export const app = defineApp({ |
| // history so it lands instantly on your message, not after a long scroll. |
| feedGlideToBottom(feed, firstBuild || forcePin); |
| } |
| + if (root._updateJumpBtn) root._updateJumpBtn(); |
| // An open reply-thread view folds from this same state — re-derive and |
| // re-render it every state change so new replies/reactions/edits land live |
| @@ -2274,6 +2294,21 @@ function buildSkeleton(el, ctx) { |
| feedWrap.className = "chatapp-feedwrap"; |
| feedWrap.appendChild(feed); |
| + // Jump-to-latest: the classic chat affordance for getting back to the |
| + // newest message without hunting for it — shown/hidden by the feed's |
| + // "scroll" listener below (and re-checked after each render, since new |
| + // messages can widen the gap without a scroll event). |
| + const jumpBtn = document.createElement("button"); |
| + jumpBtn.type = "button"; |
| + jumpBtn.className = "feed-jump-btn hidden"; |
| + // aria-label carries the accessible name; title mirrors it as a mouse-hover |
| + // tooltip (icon-only button — see docs/accessibility.md's icon-label rule). |
| + jumpBtn.setAttribute("aria-label", "Jump to latest"); |
| + jumpBtn.title = "Jump to latest"; |
| + jumpBtn.innerHTML = icon("chevronDown"); |
| + jumpBtn.onclick = () => feedGlideToBottom(feed, false); |
| + feedWrap.appendChild(jumpBtn); |
| + |
| const float = document.createElement("div"); |
| float.className = "composer-float"; |
| const preview = document.createElement("div"); |
| @@ -2403,13 +2438,15 @@ function buildSkeleton(el, ctx) { |
| // callback would already show it past the threshold and wrongly bail. The |
| // `< 60` matches render()'s pin check. |
| let feedAtBottom = true; |
| - feed.addEventListener( |
| - "scroll", |
| - () => { |
| - feedAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60; |
| - }, |
| - { passive: true }, |
| - ); |
| + const updateFeedScrollState = () => { |
| + const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight; |
| + feedAtBottom = gap < 60; |
| + jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP); |
| + }; |
| + feed.addEventListener("scroll", updateFeedScrollState, { passive: true }); |
| + // render() re-checks after every message-list update too — new messages |
| + // widen the gap (or a glide-to-bottom closes it) without firing "scroll". |
| + root._updateJumpBtn = updateFeedScrollState; |
| new ResizeObserver(() => { |
| if (!feedAtBottom) return; |
| // WebKit restores the pre-resize scrollTop at *layout* time — which lands |
diff --git a/web/static/shell.js b/web/static/shell.js| index 840a79a..b3af3b0 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -846,7 +846,7 @@ const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server en |
| const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; |
| // The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md). |
| -import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js"; |
| +import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js"; |
| const app = document.getElementById("app"); |
| @@ -4860,7 +4860,7 @@ setChatAppHost({ |
| } |
| return null; |
| }, |
| - icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT }, |
| + icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT, chevronDown: ICON_CHEVRON_DOWN }, |
| // Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted, |
| // labelled, and run by the host — the app just mounts the returned row. |
| actionChips: (actions) => { |