Skip to content
Merged
28 changes: 28 additions & 0 deletions packages/app/e2e/session/session-renderer-diagnostics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,25 @@ async function resetTimelineToTop(page: Page) {
expect(found, "session timeline viewport should exist").toBe(true)
}

async function markTimelinePointerGesture(page: Page) {
const found = await page.evaluate(
({ scrollViewportSelector, turnListSelector }) => {
const list = document.querySelector(turnListSelector)
const viewport = list?.closest(scrollViewportSelector)
if (!(viewport instanceof HTMLElement)) return false
viewport.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
pointerId: 1,
}),
)
return true
},
{ scrollViewportSelector, turnListSelector: sessionTurnListSelector },
)
expect(found, "session timeline viewport should exist").toBe(true)
}

async function sendVisiblePrompt(input: { page: Page; text: string }) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
Expand Down Expand Up @@ -236,6 +255,7 @@ test("captures renderer diagnostics while guarding send scroll position", async
const promptText = `diagnostics guard ${Date.now()}`
await sendVisiblePrompt({ page, text: promptText })
await expect(page.locator(sessionMessageItemSelector).last()).toContainText(promptText, { timeout: 30_000 })
await markTimelinePointerGesture(page)
await resetTimelineToTop(page)
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80)

Expand All @@ -254,6 +274,14 @@ test("captures renderer diagnostics while guarding send scroll position", async

const events = await readRendererDiagnostics(page)
expect(events.some((event) => event.name === "session.action.submit")).toBe(true)
expect(
events.some(
(event) =>
event.name === "session.timeline.scroll_controller" &&
event.data?.accepted === false &&
event.data?.reason === "submit_restore_latest_after_top_reset",
),
).toBe(true)
expect(events.some((event) => event.name === "session.timeline.mount")).toBe(true)
expect(events.some((event) => event.name === "session.timeline.visible")).toBe(true)
expect(events.filter((event) => event.name === "session.timeline.mount")).toHaveLength(1)
Expand Down
3 changes: 1 addition & 2 deletions packages/app/e2e/session/session-scroll-position.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,8 @@ test("keeps the latest turn in view when sending from an old message hash", asyn
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeGreaterThan(100)

const token = `scroll_latest_${Date.now()}`
const beforeCount = await page.locator(sessionMessageItemSelector).count()
await sendVisiblePrompt({ page, text: `reply with ${token}` })
await expect(page.locator(sessionMessageItemSelector)).toHaveCount(beforeCount + 1, { timeout: 30_000 })
await expect(page.locator(sessionMessageItemSelector).last()).toContainText(token, { timeout: 30_000 })

await expect.poll(() => page.url()).not.toContain("#message-")
await expect
Expand Down
10 changes: 5 additions & 5 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,7 @@ export default function Page() {
const resumeScroll = timelineInteraction.resumeScroll
const scheduleScrollState = timelineInteraction.scheduleScrollState
const scrollDock = timelineInteraction.scrollDock
const resumeScrollIfFollowing = () => {
if (scrollDock.scroll.bottom) resumeScroll()
}
const submitLatest = timelineInteraction.submitLatest
const setScrollRef = timelineInteraction.setScrollRef

useSessionKeyboardFocus({
Expand Down Expand Up @@ -572,9 +570,9 @@ export default function Page() {
onNewSessionWorktreeReset={newSessionWorktree.reset}
onSubmit={() => {
comments.clear()
resumeScrollIfFollowing()
submitLatest()
}}
onResponseSubmit={resumeScrollIfFollowing}
onResponseSubmit={submitLatest}
onModeChange={ctx?.onModeChange}
selectedSkill={ctx?.selectedSkill}
followup={
Expand Down Expand Up @@ -647,6 +645,8 @@ export default function Page() {
markScrollGesture={timelineInteraction.markScrollGesture}
hasScrollGesture={activeMessage.hasScrollGesture}
markUserScroll={activeMessage.markUserScroll}
onTimelineScrollIntent={timelineInteraction.onTimelineScrollIntent}
onTimelineScrollObservation={timelineInteraction.onTimelineScrollObservation}
historyWindow={historyWindow}
centered={centered()}
setContentRef={scrollDock.setContentRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ export function SessionComposerRegion(props: {
const dockSpring = useSpring(() => (dockOpen() ? 1 : 0), DOCK_MOTION)
const dockProgress = createMemo(() => Math.max(0, Math.min(1, dockSpring())))
const dockMounted = createMemo(() => dockOpen() || dockProgress() > 0.001)
const dockKind = createMemo(() => {
if (props.state.questionRequest()) return "question"
if (props.state.permissionRequest()) return "permission"
if (dockMounted()) return "todo"
if (rolled()) return "revert"
if (props.followup?.items.length) return "followup"
if (showComposer()) return "prompt"
return "composer"
})

const openParent = () => {
const id = parentID()
Expand All @@ -114,6 +123,7 @@ export function SessionComposerRegion(props: {
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
data-variant={home() ? "home" : "session"}
data-dock-kind={dockKind()}
classList={{
"w-full flex flex-col justify-center items-center pointer-events-none": true,
"absolute inset-x-0 bottom-0 pb-6": !home(),
Expand Down
105 changes: 92 additions & 13 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ 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 } from "@opencode-ai/ui/scroll-view"
import { ScrollView, type ScrollViewScrollIntent } 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 { taskDescription } from "@/pages/session/task-description"
import {
turnFetchSignature,
Expand Down Expand Up @@ -118,29 +126,65 @@ const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
return nested
}

const markBoundaryGesture = (input: {
const boundaryGesture = (input: {
root: HTMLDivElement
target: EventTarget | null
delta: number
onMarkScrollGesture: (target?: EventTarget | null) => void
}) => {
const target = boundaryTarget(input.root, input.target)
if (target === input.root) {
input.onMarkScrollGesture(input.root)
return
}
if (
shouldMarkBoundaryGesture({
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),
}
}

type StageConfig = {
init: number
batch: number
Expand Down Expand Up @@ -255,6 +299,8 @@ export function MessageTimeline(props: {
onUserScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onTimelineScrollIntent: (intent: TimelineScrollIntent) => void
onTimelineScrollObservation: (observation: TimelineScrollObservation) => TimelineScrollControllerResult
Comment thread
coderabbitai[bot] marked this conversation as resolved.
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
Expand Down Expand Up @@ -903,6 +949,9 @@ export function MessageTimeline(props: {
</div>
<ScrollView
viewportRef={props.setScrollRef}
onScrollIntent={(intent) => {
props.onTimelineScrollIntent(scrollViewIntentToTimelineIntent(intent))
}}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
Expand All @@ -911,6 +960,18 @@ export function MessageTimeline(props: {
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 })
}}
onTouchStart={(e) => {
Expand All @@ -926,6 +987,18 @@ export function MessageTimeline(props: {
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,
})
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
}}
onTouchEnd={() => {
Expand All @@ -939,9 +1012,13 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
const el = e.currentTarget
const metrics = collectTimelineScrollMetrics(el)
const controllerResult = props.onTimelineScrollObservation({
type: "scroll_sample",
metrics,
})
if (!controllerResult.accepted) return
const max = Math.max(0, el.scrollHeight - el.clientHeight)
pendingScrollSample = {
scroll_top: el.scrollTop,
Expand All @@ -967,6 +1044,8 @@ export function MessageTimeline(props: {
}).catch(() => {})
})
}
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
if (!props.hasScrollGesture()) return
props.onUserScroll()
props.onAutoScrollHandleScroll()
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/pages/session/session-main-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export function SessionMainView(props: {
markScrollGesture: TimelineProps["onMarkScrollGesture"]
hasScrollGesture: TimelineProps["hasScrollGesture"]
markUserScroll: TimelineProps["onUserScroll"]
onTimelineScrollIntent: TimelineProps["onTimelineScrollIntent"]
onTimelineScrollObservation: TimelineProps["onTimelineScrollObservation"]
historyWindow: ReturnType<typeof createSessionHistoryWindow>
centered: boolean
setContentRef: TimelineProps["setContentRef"]
Expand Down Expand Up @@ -149,6 +151,8 @@ export function SessionMainView(props: {
onMarkScrollGesture={props.markScrollGesture}
hasScrollGesture={props.hasScrollGesture}
onUserScroll={props.markUserScroll}
onTimelineScrollIntent={props.onTimelineScrollIntent}
onTimelineScrollObservation={props.onTimelineScrollObservation}
onTurnBackfillScroll={props.historyWindow.onScrollerScroll}
onAutoScrollInteraction={props.autoScroll.handleInteraction}
centered={props.centered}
Expand Down
Loading
Loading