fix(app): restore staging render path and nested scroll boundary checks

This commit is contained in:
Kit Langton
2026-03-03 11:28:15 -05:00
parent 4e05d3487c
commit 83de487dcd
3 changed files with 71 additions and 12 deletions

View File

@@ -23,23 +23,25 @@ describe("shouldMarkBoundaryGesture", () => {
scrollTop: 0,
scrollHeight: 300,
clientHeight: 300,
mode: "normal",
}),
).toBe(true)
})
test("marks when scrolling beyond top boundary", () => {
// column-reverse: scrollTop=-590 means 590px from bottom (10px from top, max=600)
test("marks when scrolling beyond top boundary in reversed mode", () => {
// column-reverse: scrollTop=-590 means 10px from top (max=600)
expect(
shouldMarkBoundaryGesture({
delta: -40,
scrollTop: -590,
scrollHeight: 1000,
clientHeight: 400,
mode: "reversed",
}),
).toBe(true)
})
test("marks when scrolling beyond bottom boundary", () => {
test("marks when scrolling beyond bottom boundary in reversed mode", () => {
// column-reverse: scrollTop=-20 means 20px from bottom
expect(
shouldMarkBoundaryGesture({
@@ -47,18 +49,55 @@ describe("shouldMarkBoundaryGesture", () => {
scrollTop: -20,
scrollHeight: 1000,
clientHeight: 400,
mode: "reversed",
}),
).toBe(true)
})
test("does not mark when nested scroller can consume movement", () => {
// column-reverse: scrollTop=-400 means 400px from bottom (middle of scroll)
test("does not mark when reversed scroller can consume movement", () => {
expect(
shouldMarkBoundaryGesture({
delta: 20,
scrollTop: -400,
scrollHeight: 1000,
clientHeight: 400,
mode: "reversed",
}),
).toBe(false)
})
test("marks when scrolling beyond top boundary in normal mode", () => {
expect(
shouldMarkBoundaryGesture({
delta: -40,
scrollTop: 10,
scrollHeight: 1000,
clientHeight: 400,
mode: "normal",
}),
).toBe(true)
})
test("marks when scrolling beyond bottom boundary in normal mode", () => {
expect(
shouldMarkBoundaryGesture({
delta: 50,
scrollTop: 580,
scrollHeight: 1000,
clientHeight: 400,
mode: "normal",
}),
).toBe(true)
})
test("does not mark when normal scroller can consume movement", () => {
expect(
shouldMarkBoundaryGesture({
delta: 20,
scrollTop: 300,
scrollHeight: 1000,
clientHeight: 400,
mode: "normal",
}),
).toBe(false)
})

View File

@@ -9,14 +9,22 @@ export const shouldMarkBoundaryGesture = (input: {
scrollTop: number
scrollHeight: number
clientHeight: number
mode?: "reversed" | "normal"
}) => {
const max = input.scrollHeight - input.clientHeight
if (max <= 1) return true
if (!input.delta) return false
// With column-reverse: scrollTop=0 at bottom, -max at top
if (input.delta < 0) return input.scrollTop + input.delta <= -max
const mode = input.mode ?? "reversed"
if (mode === "normal") {
const top = Math.max(0, Math.min(max, input.scrollTop))
if (input.delta < 0) return -input.delta > top
const bottom = max - top
return input.delta > bottom
}
const remaining = -input.scrollTop // distance from bottom
return input.delta > remaining
const top = max + Math.max(-max, Math.min(0, input.scrollTop))
if (input.delta < 0) return -input.delta > top
const bottom = -Math.max(-max, Math.min(0, input.scrollTop))
return input.delta > bottom
}

View File

@@ -73,6 +73,12 @@ const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
return nested
}
const boundaryMode = (root: HTMLDivElement, target: HTMLElement) => {
if (target === root) return "reversed" as const
if (target.dataset.scrollDirection === "reversed") return "reversed" as const
return "normal" as const
}
const markBoundaryGesture = (input: {
root: HTMLDivElement
target: EventTarget | null
@@ -90,6 +96,7 @@ const markBoundaryGesture = (input: {
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
mode: boundaryMode(input.root, target),
})
) {
input.onMarkScrollGesture(input.root)
@@ -157,15 +164,19 @@ function createTimelineStaging(input: TimelineStageInput) {
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
cancel()
const switched = active !== sessionKey
if (switched) {
active = sessionKey
setReadySession("")
}
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
if (staging && !switched) return
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
if (staging && !switched && shouldStage && frame !== undefined) return
cancel()
if (shouldStage) setReadySession("")
if (!shouldStage) {
setState({
@@ -182,6 +193,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
let count = Math.min(total, input.config.init)
if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -252,7 +264,6 @@ export function MessageTimeline(props: {
const dialog = useDialog()
const language = useLanguage()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -304,6 +315,7 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
const rendered = createMemo(() => staging.messages().map((message) => message.id))
const [title, setTitle] = createStore({
draft: "",