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,