Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export const sessionComposerColumnSelector = '[data-component="session-composer-
export const sessionTimelineColumnSelector = '[data-component="session-timeline-column"]'
export const sessionTurnListSelector = '[data-slot="session-turn-list"]'
export const sessionMessageItemSelector = "[data-message-id]"
export const scrollViewSelector = '[data-component="scroll-view"]'
export const scrollViewportSelector = '[data-component="scroll-viewport"]'
export const scrollThumbSelector = '[data-component="scroll-thumb"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
Expand Down
146 changes: 146 additions & 0 deletions packages/app/e2e/session/session-renderer-diagnostics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import {
promptSelector,
scrollThumbSelector,
scrollViewSelector,
scrollViewportSelector,
sessionMessageItemSelector,
sessionTurnListSelector,
Expand Down Expand Up @@ -145,6 +147,69 @@ 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 timelineThumbBox(page: Page) {
return page.evaluate(
({ scrollThumbSelector, scrollViewSelector, scrollViewportSelector, turnListSelector }) => {
const list = document.querySelector(turnListSelector)
const viewport = list?.closest(scrollViewportSelector)
const root = viewport?.closest(scrollViewSelector)
const thumb = root?.querySelector(scrollThumbSelector)
if (!(thumb instanceof HTMLElement)) return null
const rect = thumb.getBoundingClientRect()
return {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
}
},
{ scrollThumbSelector, scrollViewSelector, scrollViewportSelector, turnListSelector: sessionTurnListSelector },
)
}

async function holdTimelineThumbDragBy(page: Page, deltaY: number) {
const box = await timelineThumbBox(page)
expect(box, "session timeline thumb should exist").not.toBeNull()
const x = box!.x + box!.width / 2
const y = box!.y + Math.min(box!.height / 2, 12)
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x, y + deltaY, { steps: 8 })
}

async function dragTimelineThumbBy(page: Page, deltaY: number) {
await holdTimelineThumbDragBy(page, deltaY)
await page.mouse.up()
}

async function waitForTimelineFrame(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
}),
)
}

async function sendVisiblePrompt(input: { page: Page; text: string }) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
Expand Down Expand Up @@ -236,6 +301,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 +320,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 All @@ -270,6 +344,78 @@ test("captures renderer diagnostics while guarding send scroll position", async
})
})

test("honors scrollbar thumb drag after submit instead of restoring latest", async ({ page, project }) => {
test.setTimeout(120_000)

await installRendererDiagnosticsCapture(page)
await project.open()
const sdk = project.sdk

await withSession(sdk, `e2e scrollbar drag latest ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await seedSessionTurns({ sdk, sessionID: session.id, count: 18 })

await project.gotoSession(session.id)
await expect(page.locator(sessionMessageItemSelector)).toHaveCount(10, { timeout: 30_000 })
await scrollTimelineToBottom(page)
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(40)

const promptText = `scrollbar drag guard ${Date.now()}`
await sendVisiblePrompt({ page, text: promptText })
await expect(page.locator(sessionMessageItemSelector).last()).toContainText(promptText, { timeout: 30_000 })

await holdTimelineThumbDragBy(page, -180)
try {
await resetTimelineToTop(page)
await expect
.poll(async () => {
const events = await readRendererDiagnostics(page)
return events.some(
(event) =>
event.name === "session.timeline.scroll_controller" &&
event.data?.observation_type === "scroll_sample" &&
event.data?.near_top === true,
)
})
.toBe(true)
await waitForTimelineFrame(page)
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeGreaterThan(200)
} finally {
await page.mouse.up().catch(() => {})
}

const events = await readRendererDiagnostics(page)
const dragIndex = events.findIndex(
(event) =>
event.name === "session.timeline.scroll_controller" && event.data?.intent_type === "scrollbar_drag_start",
)
expect(dragIndex).toBeGreaterThanOrEqual(0)
expect(
events
.slice(dragIndex)
.some(
(event) =>
event.name === "session.timeline.scroll_controller" &&
event.data?.accepted === false &&
event.data?.reason === "submit_restore_latest_after_top_reset",
),
).toBe(false)
expect(
events
.slice(dragIndex)
.some(
(event) =>
event.name === "session.scroll.sample" &&
event.data?.user_scrolled === true &&
(numberData(event, "distance_from_bottom") ?? 0) > 200,
),
).toBe(true)

await dragTimelineThumbBy(page, 10_000)
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80)
})
})

test("keeps long timeline stable across worktree exit follow-up", async ({ page, project, llm }) => {
test.setTimeout(180_000)

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
Loading
Loading