Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions packages/app/e2e/session/session-scroll-position.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ type CapturedPageError = {
detail?: string
}

type CapturedDiagnosticEvent = {
name: string
route_session_id?: string
visible_session_id?: string
timeline_session_id?: string
trace_id?: string
data?: Record<string, unknown>
}

function timelineMetrics(page: Page) {
return page.evaluate(
({ scrollViewportSelector, turnListSelector }) => {
Expand Down Expand Up @@ -83,6 +92,18 @@ async function scrollTimelineToBottom(page: Page) {
expect(found, "session timeline viewport should exist").toBe(true)
}

async function wheelTimelineUpWeakly(page: Page) {
const box = await page.locator(sessionTurnListSelector).evaluate((list, scrollViewportSelector) => {
const viewport = list.closest(scrollViewportSelector)
if (!(viewport instanceof HTMLElement)) return null
const rect = viewport.getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}, scrollViewportSelector)
expect(box, "session timeline viewport should exist").not.toBeNull()
await page.mouse.move(box!.x, box!.y)
await page.mouse.wheel(0, -48)
}

async function installTimelineScrollProbe(page: Page) {
await page.evaluate(
({ maxSamples, scrollViewportSelector, turnListSelector }) => {
Expand Down Expand Up @@ -208,6 +229,35 @@ async function readPageErrorProbe(page: Page) {
}) as Promise<CapturedPageError[]>
}

async function installRendererDiagnosticsCapture(page: Page) {
await page.addInitScript(() => {
const win = window as typeof window & {
__pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[]
api?: {
emitRendererDiagnostic?: (event: CapturedDiagnosticEvent) => Promise<void>
}
}
win.__pawwork_renderer_diagnostics = []
const originalEmit = win.api?.emitRendererDiagnostic?.bind(win.api)
win.api = {
...(win.api ?? {}),
emitRendererDiagnostic: async (event) => {
win.__pawwork_renderer_diagnostics?.push(JSON.parse(JSON.stringify(event)))
await originalEmit?.(event)
},
}
})
}

async function readRendererDiagnostics(page: Page) {
return page.evaluate(() => {
const win = window as typeof window & {
__pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[]
}
return win.__pawwork_renderer_diagnostics ?? []
}) as Promise<CapturedDiagnosticEvent[]>
}

function collectPageErrors(page: Page) {
const errors: CapturedPageError[] = []
const describe = (value: unknown) => {
Expand Down Expand Up @@ -524,6 +574,78 @@ test("does not jump to the top after mod-enter submit from an old message hash",
})
})

test("keeps latest pinned when weak upward wheel lands during answer completion", async ({
page,
project,
assistant,
}) => {
test.setTimeout(120_000)

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

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

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

let releaseReply!: () => void
const replyReady = new Promise<void>((resolve) => {
releaseReply = resolve
})
const token = `latest_weak_wheel_${Date.now()}`
const beforeCalls = await assistant.calls()
await assistant.hold(token, replyReady)

const tallPrompt = [
`reply with ${token}`,
...Array.from({ length: 18 }, (_, line) => `extra context line ${line} ${"content ".repeat(10)}`),
]
.join(" ")
.trim()

await sendVisiblePrompt({ page, text: tallPrompt, submitKey: `${modKey}+Enter` })
await expect.poll(() => assistant.calls(), { timeout: 30_000 }).toBeGreaterThan(beforeCalls)

const diagnosticCheckpoint = (await readRendererDiagnostics(page)).length
await wheelTimelineUpWeakly(page)
releaseReply()

await expect(page.locator(sessionMessageItemSelector).last()).toContainText(token, { timeout: 30_000 })
await expect
.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom, { timeout: 30_000 })
.toBeLessThan(60)

const events = (await readRendererDiagnostics(page))
.slice(diagnosticCheckpoint)
.filter((event) => event.timeline_session_id === session.id)
expect(
events.some(
(event) =>
event.name === "session.timeline.scroll_controller" &&
event.data?.reason === "latest_protected_weak_upward_ignored" &&
event.data?.ignored_intent_reason === "latest_protected_weak_upward_ignored" &&
event.data?.mode_after === "following_latest",
),
).toBe(true)
expect(
events.some(
(event) =>
event.name === "session.timeline.scroll_controller" &&
event.data?.reason === "user_upward_navigation" &&
event.data?.mode_after === "reading_history",
),
).toBe(false)
})
})

test("renders the full initial session window when switching sessions", async ({ page, project }) => {
test.setTimeout(120_000)

Expand Down
198 changes: 198 additions & 0 deletions packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ function appendMessage(viewport: HTMLElement, id: string, rect: RectInput) {
return message
}

function appendTimelineAnchor(parent: HTMLElement, key: string, rect: RectInput) {
const anchor = document.createElement("div")
anchor.dataset.timelineAnchor = key
stubRect(anchor, rect)
parent.appendChild(anchor)
return anchor
}

describe("session timeline scroll anchors", () => {
test("collects near-top and near-bottom metrics from explicit geometry", () => {
const { viewport } = makeViewport({
Expand Down Expand Up @@ -133,6 +141,75 @@ describe("session timeline scroll anchors", () => {
})
})

test("samples the visible timeline anchor nearest the viewport reading line before the message row", () => {
const { viewport } = makeViewport({
scrollTop: 260,
clientHeight: 400,
scrollHeight: 1600,
rect: { top: 100, bottom: 500 },
})
const message = appendMessage(viewport, "msg_anchor", { top: 100, bottom: 900 })
appendTimelineAnchor(message, "tool:above", { top: 104, bottom: 140 })
appendTimelineAnchor(message, "trow:stable", { top: 188, bottom: 260 })
appendTimelineAnchor(message, "tool:below", { top: 360, bottom: 460 })

expect(
sampleTimelineSafePosition({
viewport,
mode: "reading_history",
renderedStart: 4,
renderedCount: 10,
newestMessageID: "msg_newest",
}),
).toEqual({
kind: "reading",
anchorMessageID: "msg_anchor",
offsetFromViewportTop: 88,
renderedStart: 4,
renderedCount: 10,
primaryAnchor: {
key: "trow:stable",
offsetFromViewportTop: 88,
scope: "trow",
},
fallbackMessage: {
messageID: "msg_anchor",
offsetFromViewportTop: 0,
},
})
})

test("ignores hidden or edge-only timeline anchors while sampling reading position", () => {
const { viewport } = makeViewport({
scrollTop: 260,
clientHeight: 400,
scrollHeight: 1600,
rect: { top: 100, bottom: 500 },
})
const message = appendMessage(viewport, "msg_anchor", { top: 80, bottom: 900 })
appendTimelineAnchor(message, "tool:edge", { top: 499.5, bottom: 500 })
appendTimelineAnchor(message, "tool:zero", { top: 220, bottom: 220 })
const hidden = appendTimelineAnchor(message, "tool:hidden", { top: 180, bottom: 230 })
hidden.hidden = true
appendTimelineAnchor(message, "tool:visible", { top: 240, bottom: 300 })

expect(
sampleTimelineSafePosition({
viewport,
mode: "reading_history",
renderedStart: 4,
renderedCount: 10,
newestMessageID: "msg_newest",
}),
).toEqual(
expect.objectContaining({
kind: "reading",
anchorMessageID: "msg_anchor",
primaryAnchor: expect.objectContaining({ key: "tool:visible", scope: "tool" }),
}),
)
})

test("keeps target message as the sampled anchor while targeting", () => {
const { viewport } = makeViewport({
scrollTop: 260,
Expand Down Expand Up @@ -218,6 +295,127 @@ describe("session timeline scroll anchors", () => {
expect(scroller.scrollTop).toBe(456)
})

test("restores reading position using the primary timeline anchor before the message row", () => {
const scroller = makeViewport({
scrollTop: 400,
clientHeight: 400,
scrollHeight: 1400,
rect: { top: 100, bottom: 500 },
})
const message = appendMessage(scroller.viewport, "msg_anchor", { top: 160, bottom: 700 })
appendTimelineAnchor(message, "tool:part:1", { top: 220, bottom: 320 })

expect(
restoreTimelineSafePosition({
viewport: scroller.viewport,
position: {
kind: "reading",
anchorMessageID: "msg_anchor",
offsetFromViewportTop: 0,
renderedStart: 4,
renderedCount: 10,
primaryAnchor: {
key: "tool:part:1",
offsetFromViewportTop: 72,
scope: "tool",
},
fallbackMessage: {
messageID: "msg_anchor",
offsetFromViewportTop: 24,
},
},
}),
).toEqual({
ok: true,
restoredTo: expect.objectContaining({
kind: "reading",
primaryAnchor: expect.objectContaining({ key: "tool:part:1" }),
}),
})
expect(scroller.scrollTop).toBe(448)
})

test("falls back to the message row when the primary timeline anchor disappeared", () => {
const scroller = makeViewport({
scrollTop: 400,
clientHeight: 400,
scrollHeight: 1400,
rect: { top: 100, bottom: 500 },
})
appendMessage(scroller.viewport, "msg_anchor", { top: 180, bottom: 700 })

expect(
restoreTimelineSafePosition({
viewport: scroller.viewport,
position: {
kind: "reading",
anchorMessageID: "msg_anchor",
offsetFromViewportTop: 0,
renderedStart: 4,
renderedCount: 10,
primaryAnchor: {
key: "tool:part:missing",
offsetFromViewportTop: 72,
scope: "tool",
},
fallbackMessage: {
messageID: "msg_anchor",
offsetFromViewportTop: 24,
},
},
}),
).toEqual({
ok: true,
restoredTo: expect.objectContaining({ kind: "reading" }),
})
expect(scroller.scrollTop).toBe(456)
})

test("falls back to the trow anchor when a tool anchor disappears and the message row is re-keyed", () => {
const scroller = makeViewport({
scrollTop: 400,
clientHeight: 400,
scrollHeight: 1400,
rect: { top: 100, bottom: 500 },
})
const message = appendMessage(scroller.viewport, "msg_replaced", { top: 180, bottom: 700 })
appendTimelineAnchor(message, "trow:stable", { top: 210, bottom: 260 })

expect(
restoreTimelineSafePosition({
viewport: scroller.viewport,
position: {
kind: "reading",
anchorMessageID: "msg_placeholder",
offsetFromViewportTop: 0,
renderedStart: 4,
renderedCount: 10,
primaryAnchor: {
key: "tool:old-key",
offsetFromViewportTop: 72,
scope: "tool",
},
fallbackTrowAnchor: {
key: "trow:stable",
offsetFromViewportTop: 88,
scope: "trow",
},
fallbackMessage: {
messageID: "msg_placeholder",
offsetFromViewportTop: 24,
},
},
}),
).toEqual({
ok: true,
restoredTo: expect.objectContaining({
kind: "reading",
fallbackTrowAnchor: expect.objectContaining({ key: "trow:stable" }),
}),
})
expect(scroller.scrollTop).toBe(422)
})

test("restores nearest target only when it is outside the viewport", () => {
const scroller = makeViewport({
scrollTop: 100,
Expand Down
Loading