diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 625207e86..5abf573bc 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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" @@ -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 @@ -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 @@ -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 diff --git a/packages/app/src/pages/session/session-timeline-scroll-intents.test.ts b/packages/app/src/pages/session/session-timeline-scroll-intents.test.ts new file mode 100644 index 000000000..d420de5c8 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-intents.test.ts @@ -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, + }, + }) + }) +}) diff --git a/packages/app/src/pages/session/session-timeline-scroll-intents.ts b/packages/app/src/pages/session/session-timeline-scroll-intents.ts new file mode 100644 index 000000000..ba94dbced --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-scroll-intents.ts @@ -0,0 +1,150 @@ +import type { ScrollViewScrollIntent } from "@opencode-ai/ui/scroll-view" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture" +import { + classifyTimelineScrollGesture, + type TimelineScrollIntent, + type TimelineScrollMetrics, +} from "@/pages/session/session-timeline-scroll-controller" + +export type TimelineBoundaryGesture = { + nestedScrollable: boolean + atNestedBoundary: boolean +} + +const scrollMetricThresholds = { + nearTop: 12, + nearBottom: 2, +} + +export function timelineBoundaryTarget(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 +} + +export function timelineBoundaryGesture(input: { + root: HTMLElement + target: EventTarget | null + delta: number +}): TimelineBoundaryGesture { + const target = timelineBoundaryTarget(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, + }), + } +} + +export function shouldMarkTimelineBoundaryGesture(boundary: TimelineBoundaryGesture) { + return !boundary.nestedScrollable || boundary.atNestedBoundary +} + +export function 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 <= scrollMetricThresholds.nearTop, + nearBottom: distanceFromBottom <= scrollMetricThresholds.nearBottom, + } +} + +export function 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), + } +} + +export function shouldMarkLegacyScrollIntent(intent: ScrollViewScrollIntent) { + if (intent.type === "keyboard_scroll") return true + return intent.type === "scrollbar_drag_start" +} + +export type TimelineGestureIntentResult = { + delta: number + boundary: TimelineBoundaryGesture + intent: TimelineScrollIntent +} + +export function createWheelTimelineScrollIntent(input: { + root: HTMLElement + target: EventTarget | null + deltaY: number + deltaMode: number +}): TimelineGestureIntentResult | undefined { + const delta = normalizeWheelDelta({ + deltaY: input.deltaY, + deltaMode: input.deltaMode, + rootHeight: input.root.clientHeight, + }) + if (!delta) return + + return createPointerTimelineScrollIntent({ + type: "wheel_scroll", + root: input.root, + target: input.target, + delta, + }) +} + +export function createTouchTimelineScrollIntent(input: { + root: HTMLElement + target: EventTarget | null + delta: number +}): TimelineGestureIntentResult | undefined { + if (!input.delta) return + return createPointerTimelineScrollIntent({ + type: "touch_scroll", + root: input.root, + target: input.target, + delta: input.delta, + }) +} + +function createPointerTimelineScrollIntent(input: { + type: "wheel_scroll" | "touch_scroll" + root: HTMLElement + target: EventTarget | null + delta: number +}): TimelineGestureIntentResult { + const boundary = timelineBoundaryGesture({ + root: input.root, + target: input.target, + delta: input.delta, + }) + const gesture = classifyTimelineScrollGesture({ + deltaY: input.delta, + viewportHeight: input.root.clientHeight, + nestedScrollable: boundary.nestedScrollable, + atNestedBoundary: boundary.atNestedBoundary, + }) + return { + delta: input.delta, + boundary, + intent: { + type: input.type, + source: "timeline", + ...gesture, + }, + } +}