diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 663d61d157..7b965e5858 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -125,7 +125,10 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
- el.scrollTop = beforeTop + delta
+ // With column-reverse, adding content at the top doesn't shift the
+ // viewport because scroll origin is at the bottom. Subtract delta
+ // to maintain position (beforeTop is negative or zero).
+ el.scrollTop = beforeTop - delta
})
}
@@ -209,7 +212,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
- if (el.scrollTop >= turnScrollThreshold) return
+ // With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
+ if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -1028,7 +1032,8 @@ export default function Page() {
const overflow = max > 1
// If auto-scroll is tracking the bottom, always report bottom: true
// to prevent the scroll-down arrow from flashing during height animations
- const bottom = !overflow || el.scrollTop >= max - 2 || !autoScroll.userScrolled()
+ // With column-reverse, scrollTop=0 is at the bottom
+ const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1119,9 +1124,8 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
- const stick = el
- ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
- : false
+ // With column-reverse, near bottom = scrollTop near 0
+ const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next
diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts
index b2af4bb834..69e26e8dc3 100644
--- a/packages/app/src/pages/session/message-gesture.test.ts
+++ b/packages/app/src/pages/session/message-gesture.test.ts
@@ -28,10 +28,11 @@ describe("shouldMarkBoundaryGesture", () => {
})
test("marks when scrolling beyond top boundary", () => {
+ // column-reverse: scrollTop=-590 means 590px from bottom (10px from top, max=600)
expect(
shouldMarkBoundaryGesture({
delta: -40,
- scrollTop: 10,
+ scrollTop: -590,
scrollHeight: 1000,
clientHeight: 400,
}),
@@ -39,10 +40,11 @@ describe("shouldMarkBoundaryGesture", () => {
})
test("marks when scrolling beyond bottom boundary", () => {
+ // column-reverse: scrollTop=-20 means 20px from bottom
expect(
shouldMarkBoundaryGesture({
delta: 50,
- scrollTop: 580,
+ scrollTop: -20,
scrollHeight: 1000,
clientHeight: 400,
}),
@@ -50,10 +52,11 @@ describe("shouldMarkBoundaryGesture", () => {
})
test("does not mark when nested scroller can consume movement", () => {
+ // column-reverse: scrollTop=-400 means 400px from bottom (middle of scroll)
expect(
shouldMarkBoundaryGesture({
delta: 20,
- scrollTop: 200,
+ scrollTop: -400,
scrollHeight: 1000,
clientHeight: 400,
}),
diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts
index 731cb1bdeb..03ae724edb 100644
--- a/packages/app/src/pages/session/message-gesture.ts
+++ b/packages/app/src/pages/session/message-gesture.ts
@@ -14,8 +14,9 @@ export const shouldMarkBoundaryGesture = (input: {
if (max <= 1) return true
if (!input.delta) return false
- if (input.delta < 0) return input.scrollTop + input.delta <= 0
+ // With column-reverse: scrollTop=0 at bottom, -max at top
+ if (input.delta < 0) return input.scrollTop + input.delta <= -max
- const remaining = max - input.scrollTop
+ const remaining = -input.scrollTop // distance from bottom
return input.delta > remaining
}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 96add9a0f2..224516ef38 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -574,6 +574,7 @@ export function MessageTimeline(props: {
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
+
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
index da5506faf0..19ce8f2aed 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -47,7 +47,8 @@ export const useSessionHashScroll = (input: {
const b = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
- const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
+ // With column-reverse, scrollTop is negative — don't clamp to 0
+ const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}
diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css
index f6a49e241c..8429f318c3 100644
--- a/packages/ui/src/components/scroll-view.css
+++ b/packages/ui/src/components/scroll-view.css
@@ -9,6 +9,8 @@
overflow-y: auto;
scrollbar-width: none;
outline: none;
+ display: flex;
+ flex-direction: column-reverse;
}
.scroll-view__viewport::-webkit-scrollbar {
diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx
index 52ed39a465..58017238f7 100644
--- a/packages/ui/src/components/scroll-view.tsx
+++ b/packages/ui/src/components/scroll-view.tsx
@@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) {
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
- const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
+ // With column-reverse: scrollTop=0 is at bottom, negative = scrolled up
+ // Normalize so 0 = at top, maxScrollTop = at bottom
+ const normalizedScrollTop = maxScrollTop + scrollTop
+ const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0
- // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
+ // Ensure thumb stays within bounds
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
@@ -147,11 +150,13 @@ export function ScrollView(props: ScrollViewProps) {
break
case "Home":
e.preventDefault()
- viewportRef.scrollTo({ top: 0, behavior: "smooth" })
+ // With column-reverse, top of content = -(scrollHeight - clientHeight)
+ viewportRef.scrollTo({ top: -(viewportRef.scrollHeight - viewportRef.clientHeight), behavior: "smooth" })
break
case "End":
e.preventDefault()
- viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+ // With column-reverse, bottom of content = 0
+ viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "ArrowUp":
e.preventDefault()
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index f047ee8c58..7faf5a6621 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -32,7 +32,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
- return el.scrollHeight - el.clientHeight - el.scrollTop
+ // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
+ return Math.abs(el.scrollTop)
}
const canScroll = (el: HTMLElement) => {
@@ -54,13 +55,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
- const next = Math.max(0, el.scrollHeight - el.clientHeight)
- if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) {
+ // With column-reverse, scrollTop=0 is at the bottom
+ if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
- el.scrollTop = next
+ el.scrollTop = 0
markProgrammatic()
}
@@ -78,13 +79,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
cancelSmooth()
if (store.userScrolled) setStore("userScrolled", false)
- const next = Math.max(0, el.scrollHeight - el.clientHeight)
- if (Math.abs(el.scrollTop - next) <= AUTO_SCROLL_EPSILON) {
+ // With column-reverse, scrollTop=0 is at the bottom
+ if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
- scrollAnim = animate(el.scrollTop, next, {
+ scrollAnim = animate(el.scrollTop, 0, {
type: "spring",
visualDuration: 0.35,
bounce: 0,
@@ -248,7 +249,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
- el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)
+ // With column-reverse, scrollTop=0 is at the bottom
+ el.scrollTop = 0
markProgrammatic()
},
userScrolled: () => store.userScrolled,