eleven / ocho Public

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>
James Adam 7c5cb6763cab6898f3ee5a246153e364654be865 parent ae305ff
5 files changed, +153 −9
  • changelog/2026-07-14-jump-to-latest.md +3 −0
  • ios/LChat/Views/NativeChatScreen.swift +50 −0
  • ios/LChatMac/NativeFeedView.swift +54 −0
  • web/static/apps/chat.js +44 −7
  • web/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) => {