Skip to content
127 changes: 21 additions & 106 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView, type ScrollViewScrollIntent } from "@opencode-ai/ui/scroll-view"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { collectTimelineScrollMetrics } from "@/pages/session/session-timeline-scroll-anchors"
import {
classifyTimelineScrollGesture,
type TimelineScrollControllerResult,
type TimelineScrollIntent,
type TimelineScrollMetrics,
type TimelineScrollObservation,
} from "@/pages/session/session-timeline-scroll-controller"
import {
createTouchTimelineScrollIntent,
createWheelTimelineScrollIntent,
scrollViewIntentToTimelineIntent,
shouldMarkLegacyScrollIntent,
shouldMarkTimelineBoundaryGesture,
} from "@/pages/session/session-timeline-scroll-intents"
import { createTimelineStaging } from "@/pages/session/session-timeline-staging"
import { taskDescription } from "@/pages/session/task-description"
import { buildTurnMessagesByUserID, emptyAssistantMessages } from "@/pages/session/session-messages"
Expand Down Expand Up @@ -120,78 +124,6 @@ const messageComments = (parts: Part[]): MessageComment[] =>

export { taskDescription }

const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
if (!nested || nested === root) return root
if (!(nested instanceof HTMLElement)) return root
return nested
}

const boundaryGesture = (input: {
root: HTMLDivElement
target: EventTarget | null
delta: number
}) => {
const target = boundaryTarget(input.root, input.target)
if (target === input.root) return { nestedScrollable: false, atNestedBoundary: true }
return {
nestedScrollable: true,
atNestedBoundary: shouldMarkBoundaryGesture({
delta: input.delta,
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
}),
}
}

const markBoundaryGesture = (input: {
root: HTMLDivElement
target: EventTarget | null
delta: number
onMarkScrollGesture: (target?: EventTarget | null) => void
}) => {
const boundary = boundaryGesture(input)
if (!boundary.nestedScrollable || boundary.atNestedBoundary) {
input.onMarkScrollGesture(input.root)
}
}

const scrollViewMetricsToTimelineMetrics = (metrics: {
scrollTop: number
scrollHeight: number
clientHeight: number
}): TimelineScrollMetrics => {
const max = Math.max(0, metrics.scrollHeight - metrics.clientHeight)
const distanceFromBottom = Math.max(0, max - metrics.scrollTop)
return {
scrollTop: metrics.scrollTop,
scrollHeight: metrics.scrollHeight,
clientHeight: metrics.clientHeight,
distanceFromTop: metrics.scrollTop,
distanceFromBottom,
nearTop: metrics.scrollTop <= 12,
nearBottom: distanceFromBottom <= 2,
}
}

const scrollViewIntentToTimelineIntent = (intent: ScrollViewScrollIntent): TimelineScrollIntent => {
if (intent.type === "keyboard_scroll") {
return { type: "keyboard_scroll", key: intent.key, source: "scroll_view" }
}
return {
type: intent.type,
source: "scroll_view",
metrics: scrollViewMetricsToTimelineMetrics(intent.metrics),
}
}

const shouldMarkLegacyScrollIntent = (intent: ScrollViewScrollIntent) => {
if (intent.type === "keyboard_scroll") return true
return intent.type === "scrollbar_drag_start"
}

export function MessageTimeline(props: {
sessionID: string
sessionKey: string
Expand Down Expand Up @@ -870,26 +802,15 @@ export function MessageTimeline(props: {
props.onTimelineScrollIntent(scrollViewIntentToTimelineIntent(intent))
}}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
const result = createWheelTimelineScrollIntent({
root: e.currentTarget,
target: e.target,
deltaY: e.deltaY,
deltaMode: e.deltaMode,
rootHeight: root.clientHeight,
})
if (!delta) return
const boundary = boundaryGesture({ root, target: e.target, delta })
const gesture = classifyTimelineScrollGesture({
deltaY: delta,
viewportHeight: root.clientHeight,
nestedScrollable: boundary.nestedScrollable,
atNestedBoundary: boundary.atNestedBoundary,
})
props.onTimelineScrollIntent({
type: "wheel_scroll",
source: "timeline",
...gesture,
})
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
if (!result) return
props.onTimelineScrollIntent(result.intent)
if (shouldMarkTimelineBoundaryGesture(result.boundary)) props.onMarkScrollGesture(e.currentTarget)
}}
onTouchStart={(e) => {
touchGesture = e.touches[0]?.clientY
Expand All @@ -903,20 +824,14 @@ export function MessageTimeline(props: {
const delta = prev - next
if (!delta) return

const root = e.currentTarget
const boundary = boundaryGesture({ root, target: e.target, delta })
const gesture = classifyTimelineScrollGesture({
deltaY: delta,
viewportHeight: root.clientHeight,
nestedScrollable: boundary.nestedScrollable,
atNestedBoundary: boundary.atNestedBoundary,
})
props.onTimelineScrollIntent({
type: "touch_scroll",
source: "timeline",
...gesture,
const result = createTouchTimelineScrollIntent({
root: e.currentTarget,
target: e.target,
delta,
})
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
if (!result) return
props.onTimelineScrollIntent(result.intent)
if (shouldMarkTimelineBoundaryGesture(result.boundary)) props.onMarkScrollGesture(e.currentTarget)
}}
onTouchEnd={() => {
touchGesture = undefined
Expand Down
182 changes: 182 additions & 0 deletions packages/app/src/pages/session/session-timeline-scroll-intents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, test } from "bun:test"
import {
createTouchTimelineScrollIntent,
createWheelTimelineScrollIntent,
scrollViewIntentToTimelineIntent,
scrollViewMetricsToTimelineMetrics,
shouldMarkLegacyScrollIntent,
shouldMarkTimelineBoundaryGesture,
timelineBoundaryGesture,
} from "./session-timeline-scroll-intents"

const setMetric = (element: HTMLElement, name: "clientHeight" | "scrollHeight" | "scrollTop", value: number) => {
Object.defineProperty(element, name, { configurable: true, value })
}

const makeRoot = (height = 500) => {
const root = document.createElement("div")
setMetric(root, "clientHeight", height)
return root as HTMLDivElement
}

describe("scrollViewMetricsToTimelineMetrics", () => {
test("converts scroll view metrics into timeline metrics", () => {
expect(
scrollViewMetricsToTimelineMetrics({
scrollTop: 40,
scrollHeight: 240,
clientHeight: 100,
}),
).toEqual({
scrollTop: 40,
scrollHeight: 240,
clientHeight: 100,
distanceFromTop: 40,
distanceFromBottom: 100,
nearTop: false,
nearBottom: false,
})
})

test("clamps bottom distance and exposes top/bottom thresholds", () => {
expect(scrollViewMetricsToTimelineMetrics({ scrollTop: 0, scrollHeight: 80, clientHeight: 100 })).toMatchObject({
distanceFromBottom: 0,
nearTop: true,
nearBottom: true,
})

expect(scrollViewMetricsToTimelineMetrics({ scrollTop: 98, scrollHeight: 200, clientHeight: 100 })).toMatchObject({
distanceFromBottom: 2,
nearTop: false,
nearBottom: true,
})
})
})

describe("scrollViewIntentToTimelineIntent", () => {
test("keeps keyboard scroll intent discrete", () => {
expect(scrollViewIntentToTimelineIntent({ type: "keyboard_scroll", key: "Home" })).toEqual({
type: "keyboard_scroll",
key: "Home",
source: "scroll_view",
})
})

test("attaches timeline metrics to scrollbar intents", () => {
expect(
scrollViewIntentToTimelineIntent({
type: "scrollbar_drag_start",
metrics: { scrollTop: 10, scrollHeight: 210, clientHeight: 100 },
}),
).toEqual({
type: "scrollbar_drag_start",
source: "scroll_view",
metrics: {
scrollTop: 10,
scrollHeight: 210,
clientHeight: 100,
distanceFromTop: 10,
distanceFromBottom: 100,
nearTop: true,
nearBottom: false,
},
})
})

test("marks legacy scroll intents that still need gesture cancellation", () => {
expect(shouldMarkLegacyScrollIntent({ type: "keyboard_scroll", key: "ArrowUp" })).toBe(true)
expect(
shouldMarkLegacyScrollIntent({
type: "scrollbar_drag_start",
metrics: { scrollTop: 0, scrollHeight: 100, clientHeight: 100 },
}),
).toBe(true)
expect(
shouldMarkLegacyScrollIntent({
type: "scrollbar_drag_end",
metrics: { scrollTop: 0, scrollHeight: 100, clientHeight: 100 },
}),
).toBe(false)
})
})

describe("timelineBoundaryGesture", () => {
test("treats main timeline gestures as boundary gestures", () => {
const root = makeRoot()

const boundary = timelineBoundaryGesture({ root, target: root, delta: 20 })

expect(boundary).toEqual({ nestedScrollable: false, atNestedBoundary: true })
expect(shouldMarkTimelineBoundaryGesture(boundary)).toBe(true)
})

test("does not mark nested scrollables that can consume the gesture", () => {
const root = makeRoot()
const nested = document.createElement("div")
nested.setAttribute("data-scrollable", "")
setMetric(nested, "scrollTop", 100)
setMetric(nested, "scrollHeight", 300)
setMetric(nested, "clientHeight", 100)
root.append(nested)

const boundary = timelineBoundaryGesture({ root, target: nested, delta: 20 })

expect(boundary).toEqual({ nestedScrollable: true, atNestedBoundary: false })
expect(shouldMarkTimelineBoundaryGesture(boundary)).toBe(false)
})

test("marks nested scrollables once they hit their boundary", () => {
const root = makeRoot()
const nested = document.createElement("div")
nested.setAttribute("data-scrollable", "")
setMetric(nested, "scrollTop", 0)
setMetric(nested, "scrollHeight", 300)
setMetric(nested, "clientHeight", 100)
root.append(nested)

const boundary = timelineBoundaryGesture({ root, target: nested, delta: -20 })

expect(boundary).toEqual({ nestedScrollable: true, atNestedBoundary: true })
expect(shouldMarkTimelineBoundaryGesture(boundary)).toBe(true)
})
})

describe("timeline pointer intents", () => {
test("normalizes wheel gestures into timeline intents", () => {
const root = makeRoot(500)

expect(createWheelTimelineScrollIntent({ root, target: root, deltaY: -1, deltaMode: 2 })).toEqual({
delta: -500,
boundary: { nestedScrollable: false, atNestedBoundary: true },
intent: {
type: "wheel_scroll",
source: "timeline",
direction: "up",
strength: "strong",
nestedScrollable: false,
},
})
})

test("returns undefined for zero wheel and touch deltas", () => {
const root = makeRoot()

expect(createWheelTimelineScrollIntent({ root, target: root, deltaY: 0, deltaMode: 0 })).toBeUndefined()
expect(createTouchTimelineScrollIntent({ root, target: root, delta: 0 })).toBeUndefined()
})

test("normalizes touch deltas into timeline intents", () => {
const root = makeRoot()

expect(createTouchTimelineScrollIntent({ root, target: root, delta: 24 })).toMatchObject({
delta: 24,
boundary: { nestedScrollable: false, atNestedBoundary: true },
intent: {
type: "touch_scroll",
source: "timeline",
direction: "down",
nestedScrollable: false,
},
})
})
})
Loading
Loading