mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 05:53:34 +00:00
fix(app): correct virtual range clamping
This commit is contained in:
parent
686a3cf26a
commit
036c12d921
7 changed files with 77 additions and 628 deletions
2
bun.lock
2
bun.lock
|
|
@ -1066,7 +1066,6 @@
|
|||
"patchedDependencies": {
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
|
|
@ -1076,6 +1075,7 @@
|
|||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,79 +1,12 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { startTimelineDiagnostics, type TimelineDiagnostics } from "../utils/timeline-cdp-diagnostics"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/RequestDocks"
|
||||
const projectID = "proj_request_docks"
|
||||
const sessionID = "ses_request_docks"
|
||||
const title = "Request dock regression"
|
||||
const activeAssistantID = "msg_0079_b13_request_assistant"
|
||||
const activeToolID = "prt_0079_request_question"
|
||||
const activeCallID = "call_0079_request_question"
|
||||
const questionID = "question-focus-return"
|
||||
const questionPrompts = [
|
||||
{
|
||||
header: "Focus path",
|
||||
question: "How was focus changing immediately before the timeline jumped?",
|
||||
options: [
|
||||
{ label: "Already in app", description: "The app stayed focused before answering." },
|
||||
{ label: "Returned to app", description: "The app regained focus before answering." },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
type RequestTimelineEvent = {
|
||||
directory: string
|
||||
payload: {
|
||||
id: string
|
||||
} & (
|
||||
| {
|
||||
type: "question.asked"
|
||||
properties: {
|
||||
id: string
|
||||
sessionID: string
|
||||
questions: typeof questionPrompts
|
||||
tool: { messageID: string; callID: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: "question.replied"
|
||||
properties: { sessionID: string; requestID: string; answers: string[][] }
|
||||
}
|
||||
| {
|
||||
type: "message.part.updated"
|
||||
properties: { sessionID: string; part: Record<string, unknown>; time: number }
|
||||
}
|
||||
| {
|
||||
type: "message.updated"
|
||||
properties: { sessionID: string; info: Record<string, unknown> }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type QuestionTimelineProbe = Window & {
|
||||
__questionTimelineProbe?: {
|
||||
blank: boolean
|
||||
samples: number
|
||||
stop: boolean
|
||||
submitted: boolean
|
||||
transitionSamples: number
|
||||
}
|
||||
}
|
||||
|
||||
const timelineDiagnostics = new WeakMap<Page, TimelineDiagnostics>()
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
if (process.env.TIMELINE_CDP_TRACE !== "1") return
|
||||
timelineDiagnostics.set(page, await startTimelineDiagnostics(page, testInfo))
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await timelineDiagnostics.get(page)?.stop()
|
||||
timelineDiagnostics.delete(page)
|
||||
})
|
||||
|
||||
test("shows a pending question dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
|
|
@ -167,197 +100,11 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||
})
|
||||
|
||||
test("keeps an active split timeline visible when a focused question closes", async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
// Match the observed long session, assistant chain, and large review history before the focused dock handoff.
|
||||
const messages = timelineMessages(80)
|
||||
const activeMessage = messages.at(-1)!
|
||||
const activeTool = activeMessage.parts[0]!
|
||||
const responses: { requestID: string; answers?: string[][] }[] = []
|
||||
const transport = await installSseTransport<RequestTimelineEvent>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServer(page, {
|
||||
messages,
|
||||
questions: [],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
vcsDiff: reviewDiffs(2_754),
|
||||
onQuestionReply: async (input) => {
|
||||
responses.push(input)
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
id: "evt_question_replied",
|
||||
type: "question.replied",
|
||||
properties: { sessionID, requestID: questionID, answers: [["Already in app"]] },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
id: "evt_tool_completed",
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
part: {
|
||||
...activeTool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { questions: questionPrompts },
|
||||
output: "Questions answered",
|
||||
title: "Questions answered",
|
||||
metadata: { answers: [["Already in app"]] },
|
||||
time: { start: 1700000791000, end: 1700000795000 },
|
||||
},
|
||||
},
|
||||
time: 1700000795000,
|
||||
},
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await transport.burst([
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
id: "evt_assistant_completed",
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
...activeMessage.info,
|
||||
time: { ...activeMessage.info.time, completed: 1700000796000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
id: "evt_continued_assistant",
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
...activeMessage.info,
|
||||
id: "msg_0079_b14_request_assistant",
|
||||
time: { created: 1700000797000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1700, height: 1220 })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await reviewToggle.click()
|
||||
await expect(review).toBeVisible()
|
||||
await reviewToggle.click()
|
||||
await expect(review).toHaveCount(0)
|
||||
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const firstPart = page.locator(`[data-timeline-part-id="${messages[0]!.parts[0]!.id}"]`)
|
||||
const lastTextPart = page.locator('[data-timeline-part-id="prt_0079_12_request_assistant"]')
|
||||
const lastPart = page.locator(`[data-timeline-part-id="${activeToolID}"]`)
|
||||
await expect(lastTextPart).toBeInViewport()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport()
|
||||
await expect(firstPart).toHaveCount(0)
|
||||
await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3)
|
||||
await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3)
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
id: "evt_question_asked",
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: questionID,
|
||||
sessionID,
|
||||
questions: questionPrompts,
|
||||
tool: { messageID: activeAssistantID, callID: activeCallID },
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByRole("radio", { name: /Already in app/ })).toBeFocused()
|
||||
await expect(lastTextPart).toBeInViewport()
|
||||
await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3)
|
||||
await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3)
|
||||
await page.evaluate(() => {
|
||||
const state = { blank: false, samples: 0, stop: false, submitted: false, transitionSamples: 0 }
|
||||
;(window as QuestionTimelineProbe).__questionTimelineProbe = state
|
||||
const submit = [...document.querySelectorAll<HTMLButtonElement>('[data-component="dock-prompt"] button')].find(
|
||||
(button) => button.textContent?.trim() === "Submit",
|
||||
)
|
||||
submit?.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
state.submitted = true
|
||||
state.transitionSamples = 0
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
const sample = () => {
|
||||
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-virtual-content]"),
|
||||
)
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = [...(viewport?.querySelectorAll<HTMLElement>("[data-timeline-part-id]") ?? [])].some((part) => {
|
||||
const rect = part.getBoundingClientRect()
|
||||
return !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
if (view && view.width > 0 && view.height > 0 && !visible) state.blank = true
|
||||
state.samples++
|
||||
if (state.submitted) state.transitionSamples++
|
||||
if (!state.stop) requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
await page.waitForFunction(() => ((window as QuestionTimelineProbe).__questionTimelineProbe?.samples ?? 0) >= 2)
|
||||
await question.getByRole("radio", { name: /Already in app/ }).click()
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
|
||||
await expect.poll(() => responses).toEqual([{ requestID: questionID, answers: [["Already in app"]] }])
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="session-composer"]')).toBeVisible()
|
||||
await expect(page.locator('[data-component="toast-v2"]')).toHaveCount(0)
|
||||
await expect(lastPart).toBeInViewport()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeInViewport()
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await expect.poll(() => distanceFromBottom(scroller)).toBeGreaterThan(-3)
|
||||
await expect.poll(() => distanceFromBottom(scroller)).toBeLessThan(3)
|
||||
expect(await visibleTimelineRows(scroller)).toBeGreaterThan(0)
|
||||
const probe = await page.evaluate(() => {
|
||||
const state = (window as QuestionTimelineProbe).__questionTimelineProbe!
|
||||
state.stop = true
|
||||
return state
|
||||
})
|
||||
expect(probe).toMatchObject({ blank: false })
|
||||
expect(probe.submitted).toBe(true)
|
||||
expect(probe.transitionSamples).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
requests: {
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
messages?: { info: { id: string }; parts: { id: string }[] }[]
|
||||
sessionStatus?: unknown
|
||||
vcsDiff?: unknown[]
|
||||
onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise<unknown>
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
|
|
@ -398,131 +145,11 @@ async function mockServer(
|
|||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: (_, limit, before) => {
|
||||
const messages = requests.messages ?? []
|
||||
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
},
|
||||
message: (_, messageID) => requests.messages?.find((message) => message.info.id === messageID),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
permissions: requests.permissions,
|
||||
questions: requests.questions,
|
||||
sessionStatus: requests.sessionStatus,
|
||||
onQuestionReply: requests.onQuestionReply,
|
||||
vcsDiff: requests.vcsDiff,
|
||||
fileList: () => [],
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ general: { newLayoutDesigns: true, shellToolPartsExpanded: true } }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function timelineMessages(turns: number) {
|
||||
return Array.from({ length: turns }, (_, index) => {
|
||||
const key = String(index).padStart(4, "0")
|
||||
const userID = `msg_${key}_a_request_user`
|
||||
const active = index === turns - 1
|
||||
const user = {
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `prt_${key}_request_user`,
|
||||
sessionID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: `Request turn ${index}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
const assistants = Array.from({ length: active ? 14 : 1 }, (_, assistantIndex) => {
|
||||
const suffix = active ? `b${String(assistantIndex).padStart(2, "0")}` : "b"
|
||||
const assistantID = `msg_${key}_${suffix}_request_assistant`
|
||||
const current = active && assistantIndex === 13
|
||||
const created = 1700000001000 + index * 10_000 + assistantIndex * 100
|
||||
return {
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created, ...(current ? {} : { completed: created + 50 }) },
|
||||
parentID: userID,
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "opencode",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0,
|
||||
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
parts: current
|
||||
? [
|
||||
{
|
||||
id: activeToolID,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: activeCallID,
|
||||
tool: "question",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { questions: questionPrompts },
|
||||
metadata: {},
|
||||
time: { start: 1700000791000 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: `prt_${key}_${assistantIndex}_request_assistant`,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: `Assistant response ${index}.${assistantIndex}. ${"Long timeline content. ".repeat(12)}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
return [user, ...assistants]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function distanceFromBottom(scroller: ReturnType<Page["locator"]>) {
|
||||
return scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
|
||||
}
|
||||
|
||||
function reviewDiffs(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const file = `src/focus-${String(index).padStart(4, "0")}.ts`
|
||||
return {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const focused = false\n+export const focused = true\n`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function visibleTimelineRows(scroller: ReturnType<Page["locator"]>) {
|
||||
return scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
}).length
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export interface MockServerConfig {
|
|||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
onQuestionReply?: (input: { requestID: string; answers?: string[][] }) => unknown | Promise<unknown>
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||
|
|
@ -111,18 +110,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const permissionRespondMatch = path.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||
if (permissionRespondMatch && route.request().method() === "POST") return json(route, true)
|
||||
|
||||
const questionReplyMatch = path.match(/^\/question\/([^/]+)\/reply$/)
|
||||
if (questionReplyMatch && route.request().method() === "POST") {
|
||||
const body = route.request().postDataJSON() as { answers?: string[][] }
|
||||
return json(
|
||||
route,
|
||||
(await config.onQuestionReply?.({ requestID: questionReplyMatch[1]!, answers: body.answers })) ?? true,
|
||||
)
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export async function installSseTransport<T>(
|
|||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(request)
|
||||
return originalFetch(input, init)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
import type { CDPSession, Page, TestInfo } from "@playwright/test"
|
||||
|
||||
export async function startTimelineDiagnostics(page: Page, testInfo: TestInfo) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
const pauses: unknown[] = []
|
||||
const scripts: { url: string; scriptID: string }[] = []
|
||||
const breakpoints = new Map<string, string>()
|
||||
const setup: Promise<void>[] = []
|
||||
let handling = Promise.resolve()
|
||||
|
||||
await cdp.send("Debugger.enable")
|
||||
|
||||
cdp.on("Debugger.scriptParsed", (event) => {
|
||||
if (!/tanstack|solid-virtual|virtual-core/i.test(event.url)) return
|
||||
scripts.push({ url: event.url, scriptID: event.scriptId })
|
||||
setup.push(
|
||||
installSourceBreakpoints(cdp, event.scriptId, breakpoints).catch((error) => {
|
||||
pauses.push({ type: "breakpoint-install-error", url: event.url, error: String(error) })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
cdp.on("Debugger.paused", (event) => {
|
||||
handling = handling.then(async () => {
|
||||
const frame = event.callFrames[0]
|
||||
const state = frame
|
||||
? await cdp
|
||||
.send("Debugger.evaluateOnCallFrame", {
|
||||
callFrameId: frame.callFrameId,
|
||||
expression: `(() => {
|
||||
const root = [...document.querySelectorAll('.scroll-view__viewport')].find((element) =>
|
||||
element.querySelector('[data-timeline-virtual-content]')
|
||||
)
|
||||
const indexes = root ? [...root.querySelectorAll('[data-index]')].map((item) => Number(item.getAttribute('data-index'))) : []
|
||||
const owner = this && typeof this === 'object' && 'scrollOffset' in this ? this : undefined
|
||||
return {
|
||||
now: performance.now(),
|
||||
dom: root ? {
|
||||
scrollTop: root.scrollTop,
|
||||
scrollHeight: root.scrollHeight,
|
||||
clientHeight: root.clientHeight,
|
||||
indexes,
|
||||
} : null,
|
||||
virtualizer: owner ? {
|
||||
scrollOffset: owner.scrollOffset,
|
||||
scrollAdjustments: owner.scrollAdjustments,
|
||||
intendedScrollOffset: owner._intendedScrollOffset,
|
||||
isScrolling: owner.isScrolling,
|
||||
range: owner.range,
|
||||
totalSize: typeof owner.getTotalSize === 'function' ? owner.getTotalSize() : undefined,
|
||||
} : null,
|
||||
active: document.activeElement ? {
|
||||
tag: document.activeElement.tagName,
|
||||
component: document.activeElement.getAttribute('data-component'),
|
||||
slot: document.activeElement.getAttribute('data-slot'),
|
||||
text: document.activeElement.textContent?.trim().slice(0, 80),
|
||||
} : null,
|
||||
}
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
})
|
||||
.then((result) => result.result.value)
|
||||
.catch((error) => ({ error: String(error) }))
|
||||
: undefined
|
||||
pauses.push({
|
||||
type: "pause",
|
||||
reason: event.reason,
|
||||
data: event.data,
|
||||
hitBreakpoints: event.hitBreakpoints?.map((id) => breakpoints.get(id) ?? id),
|
||||
state,
|
||||
stack: event.callFrames.slice(0, 20).map((item) => ({
|
||||
functionName: item.functionName,
|
||||
url: item.url,
|
||||
line: item.location.lineNumber + 1,
|
||||
column: (item.location.columnNumber ?? 0) + 1,
|
||||
})),
|
||||
})
|
||||
await cdp.send("Debugger.resume").catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
await page.addInitScript(() => {
|
||||
type TraceWindow = Window & { __timelineDomTrace?: unknown[] }
|
||||
const output: unknown[] = []
|
||||
;(window as TraceWindow).__timelineDomTrace = output
|
||||
let last = ""
|
||||
let observed: HTMLElement | undefined
|
||||
const active = () => {
|
||||
const element = document.activeElement
|
||||
if (!(element instanceof HTMLElement)) return null
|
||||
return {
|
||||
tag: element.tagName,
|
||||
component: element.dataset.component,
|
||||
slot: element.dataset.slot,
|
||||
text: element.textContent?.trim().slice(0, 80),
|
||||
}
|
||||
}
|
||||
const root = () =>
|
||||
[...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-virtual-content]"),
|
||||
)
|
||||
const record = (type: string, detail: Record<string, unknown> = {}) => {
|
||||
const element = root()
|
||||
const indexes = element
|
||||
? [...element.querySelectorAll<HTMLElement>("[data-index]")].map((item) => Number(item.dataset.index))
|
||||
: []
|
||||
output.push({
|
||||
type,
|
||||
at: performance.now(),
|
||||
scrollTop: element?.scrollTop,
|
||||
scrollHeight: element?.scrollHeight,
|
||||
clientHeight: element?.clientHeight,
|
||||
indexes,
|
||||
active: active(),
|
||||
question: !!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'),
|
||||
review: !!document.querySelector('#review-panel [data-component="session-review-v2"]'),
|
||||
...detail,
|
||||
})
|
||||
}
|
||||
const watch = () => {
|
||||
const element = root()
|
||||
if (element && element !== observed) {
|
||||
observed = element
|
||||
new ResizeObserver(() => record("resize")).observe(element)
|
||||
}
|
||||
requestAnimationFrame(watch)
|
||||
}
|
||||
const sample = () => {
|
||||
const element = root()
|
||||
const indexes = element
|
||||
? [...element.querySelectorAll<HTMLElement>("[data-index]")].map((item) => Number(item.dataset.index))
|
||||
: []
|
||||
const next = JSON.stringify([
|
||||
element?.scrollTop,
|
||||
element?.scrollHeight,
|
||||
element?.clientHeight,
|
||||
indexes[0],
|
||||
indexes.at(-1),
|
||||
document.activeElement?.getAttribute("data-slot"),
|
||||
!!document.querySelector('[data-component="dock-prompt"][data-kind="question"]'),
|
||||
])
|
||||
if (next !== last) {
|
||||
last = next
|
||||
record("frame")
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
document.addEventListener("scroll", (event) => record("scroll", { trusted: event.isTrusted }), true)
|
||||
document.addEventListener("focusin", (event) => record("focusin", { trusted: event.isTrusted }), true)
|
||||
document.addEventListener("focusout", (event) => record("focusout", { trusted: event.isTrusted }), true)
|
||||
requestAnimationFrame(watch)
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
|
||||
await cdp.send("Tracing.start", {
|
||||
categories: "devtools.timeline,blink.user_timing,v8.execute,disabled-by-default-devtools.timeline.stack",
|
||||
options: "sampling-frequency=10000",
|
||||
transferMode: "ReturnAsStream",
|
||||
})
|
||||
|
||||
return {
|
||||
async stop() {
|
||||
await Promise.allSettled(setup)
|
||||
await handling
|
||||
const complete = new Promise<string>((resolve, reject) => {
|
||||
cdp.once("Tracing.tracingComplete", (event) => {
|
||||
if (!event.stream) {
|
||||
reject(new Error("CDP tracing completed without a stream"))
|
||||
return
|
||||
}
|
||||
resolve(event.stream)
|
||||
})
|
||||
})
|
||||
await cdp.send("Tracing.end")
|
||||
const stream = await complete
|
||||
const trace = await readCdpStream(cdp, stream)
|
||||
const dom = await page
|
||||
.evaluate(() => (window as Window & { __timelineDomTrace?: unknown[] }).__timelineDomTrace ?? [])
|
||||
.catch(() => [])
|
||||
const { writeFile } = await import("node:fs/promises")
|
||||
const tracePath = "C:\\tmp\\opencode\\timeline-devtools-trace.json"
|
||||
const debuggerPath = "C:\\tmp\\opencode\\timeline-cdp-debugger.json"
|
||||
const domPath = "C:\\tmp\\opencode\\timeline-dom-events.json"
|
||||
await Promise.all([
|
||||
writeFile(tracePath, trace),
|
||||
writeFile(debuggerPath, JSON.stringify({ test: testInfo.title, scripts, pauses }, null, 2)),
|
||||
writeFile(domPath, JSON.stringify(dom, null, 2)),
|
||||
])
|
||||
await cdp.send("Debugger.disable").catch(() => {})
|
||||
await cdp.detach().catch(() => {})
|
||||
console.log(`TIMELINE_TRACE ${JSON.stringify({ tracePath, debuggerPath, domPath, pauses: pauses.length })}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type TimelineDiagnostics = Awaited<ReturnType<typeof startTimelineDiagnostics>>
|
||||
|
||||
async function installSourceBreakpoints(cdp: CDPSession, scriptID: string, labels: Map<string, string>) {
|
||||
const targets = [
|
||||
["offset-observer", "this.scrollOffset = offset"],
|
||||
["resize-item", "this.resizeItem = (index, size)"],
|
||||
["apply-scroll-adjustment", "applyScrollAdjustment(delta, behavior)"],
|
||||
["scroll-to-offset", "this._scrollToOffset = (offset"],
|
||||
["scroll-to-end", "this.scrollToEnd ="],
|
||||
] as const
|
||||
for (const [label, query] of targets) {
|
||||
const matches = await cdp.send("Debugger.searchInContent", { scriptId: scriptID, query })
|
||||
for (const match of matches.result) {
|
||||
const result = await cdp.send("Debugger.setBreakpoint", {
|
||||
location: { scriptId: scriptID, lineNumber: match.lineNumber },
|
||||
})
|
||||
labels.set(result.breakpointId, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCdpStream(cdp: CDPSession, handle: string) {
|
||||
const chunks: Buffer[] = []
|
||||
while (true) {
|
||||
const chunk = await cdp.send("IO.read", { handle })
|
||||
chunks.push(Buffer.from(chunk.data, chunk.base64Encoded ? "base64" : "utf8"))
|
||||
if (chunk.eof) break
|
||||
}
|
||||
await cdp.send("IO.close", { handle })
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
|
@ -66,19 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("clamps an oversized initial offset to a full final viewport range", () => {
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
test("clamps oversized offsets with scroll margin and padding changes", () => {
|
||||
const options = (paddingEnd: number) => ({
|
||||
count: 20,
|
||||
estimateSize: () => 60,
|
||||
initialOffset: Number.MAX_SAFE_INTEGER,
|
||||
initialRect: { width: 800, height: 600 },
|
||||
scrollMargin: 64,
|
||||
paddingEnd,
|
||||
overscan: 1,
|
||||
getScrollElement: () => null,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
})
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
|
||||
|
||||
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
|
||||
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
|
||||
|
||||
virtualizer.setOptions(options(600))
|
||||
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
|
||||
})
|
||||
|
||||
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
|
||||
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644
|
||||
index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..830f646f5bd80f4e00f442291ad6e4c6c508630d 100644
|
||||
--- a/dist/cjs/index.cjs
|
||||
+++ b/dist/cjs/index.cjs
|
||||
@@ -723,10 +723,12 @@ class Virtualizer {
|
||||
@@ -716,17 +716,20 @@ class Virtualizer {
|
||||
this.getMeasurements(),
|
||||
this.getSize(),
|
||||
this.getScrollOffset(),
|
||||
- this.options.lanes
|
||||
+ this.options.lanes,
|
||||
+ this.options.paddingEnd
|
||||
],
|
||||
- (measurements, outerSize, scrollOffset, lanes) => {
|
||||
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
|
||||
if (measurements.length === 0 || outerSize === 0) {
|
||||
this.range = null;
|
||||
return null;
|
||||
}
|
||||
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
|
||||
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0);
|
||||
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
|
||||
this.range = calculateRangeImpl(
|
||||
measurements,
|
||||
|
|
@ -16,15 +26,51 @@ index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff
|
|||
lanes,
|
||||
// Pass the typed array so binary search + forward-walk can read
|
||||
// start/end directly from Float64Array, skipping the Proxy traps.
|
||||
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
|
||||
index c61ee17752565253f795c7fc7d57e86237ecbb52..430c622a18c69407df9f1ca82ee9440e9be287b7 100644
|
||||
--- a/dist/cjs/index.d.cts
|
||||
+++ b/dist/cjs/index.d.cts
|
||||
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
} | null;
|
||||
- updateDeps(newDeps: [VirtualItem[], number, number, number]): void;
|
||||
+ updateDeps(newDeps: [VirtualItem[], number, number, number, number]): void;
|
||||
};
|
||||
getVirtualIndexes: {
|
||||
(): number[];
|
||||
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
|
||||
index b03abab604eb6578f6f56ff92c489259cfaf8f19..940310f9215ba5bf666c5b71e92dddbbbc85635d 100644
|
||||
--- a/dist/esm/index.d.ts
|
||||
+++ b/dist/esm/index.d.ts
|
||||
@@ -144,7 +144,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
} | null;
|
||||
- updateDeps(newDeps: [VirtualItem[], number, number, number]): void;
|
||||
+ updateDeps(newDeps: [VirtualItem[], number, number, number, number]): void;
|
||||
};
|
||||
getVirtualIndexes: {
|
||||
(): number[];
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8f363fff6 100644
|
||||
index 3032c0ca457582be3f47923cba1f7d92c848745c..6e52124048fb3bc7b7ee37e763354b33593a6285 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -721,10 +721,12 @@ class Virtualizer {
|
||||
@@ -714,17 +714,20 @@ class Virtualizer {
|
||||
this.getMeasurements(),
|
||||
this.getSize(),
|
||||
this.getScrollOffset(),
|
||||
- this.options.lanes
|
||||
+ this.options.lanes,
|
||||
+ this.options.paddingEnd
|
||||
],
|
||||
- (measurements, outerSize, scrollOffset, lanes) => {
|
||||
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
|
||||
if (measurements.length === 0 || outerSize === 0) {
|
||||
this.range = null;
|
||||
return null;
|
||||
}
|
||||
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0);
|
||||
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0);
|
||||
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset);
|
||||
this.range = calculateRangeImpl(
|
||||
measurements,
|
||||
|
|
@ -35,14 +81,22 @@ index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8
|
|||
// Pass the typed array so binary search + forward-walk can read
|
||||
// start/end directly from Float64Array, skipping the Proxy traps.
|
||||
diff --git a/src/index.ts b/src/index.ts
|
||||
index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..faef81e8e567424146143242408eec35a634cf04 100644
|
||||
index 7ad79aacd591c3a4f7855c91b2727a59a9579bca..201406779ee85d2f2e5b01a8ea284aa9a5f694e2 100644
|
||||
--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -1373,10 +1373,12 @@ export class Virtualizer<
|
||||
@@ -1367,16 +1367,19 @@ export class Virtualizer<
|
||||
this.getSize(),
|
||||
this.getScrollOffset(),
|
||||
this.options.lanes,
|
||||
+ this.options.paddingEnd,
|
||||
],
|
||||
- (measurements, outerSize, scrollOffset, lanes) => {
|
||||
+ (measurements, outerSize, scrollOffset, lanes, _paddingEnd) => {
|
||||
if (measurements.length === 0 || outerSize === 0) {
|
||||
this.range = null
|
||||
return null
|
||||
}
|
||||
+ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0)
|
||||
+ const maxScrollOffset = Math.max(this.options.scrollMargin + this.getTotalSize() - outerSize, 0)
|
||||
+ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset)
|
||||
this.range = calculateRangeImpl(
|
||||
measurements,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue