diff --git a/bun.lock b/bun.lock index 930ee33243..6c4b0e2b97 100644 --- a/bun.lock +++ b/bun.lock @@ -705,6 +705,7 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/package.json b/package.json index c4bd486840..69f0b9ae69 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch" } } diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts new file mode 100644 index 0000000000..50ea86e3c4 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -0,0 +1,277 @@ +import { expect, test, type Page, type Route } from "@playwright/test" + +const directory = "C:/OpenCode/ContextResizeRegression" +const projectID = "proj_context_resize_regression" +const sessionID = "ses_context_resize_regression" +const title = "Context resize regression" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } +const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] +const followingTextID = "prt_0104_text" + +type Message = { info: Record & { id: string; role: "user" | "assistant" }; parts: Record[] } + +const messages = [ + ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), + ...turn(10, true), +] + +test.describe("regression: session timeline context group resize", () => { + test("remeasures a recent explored context group before the next paint", async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await mockServer(page) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expect(page.getByRole("heading", { name: title })).toBeVisible() + await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible() + await settle(page) + + const samples = await sampleExpansion(page) + const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5) + + console.log("context resize samples", JSON.stringify(samples, null, 2)) + + expect(samples[0]?.overlap).toBe(0) + expect(visibleOverlap).toEqual([]) + expect(samples.at(-1)?.expanded).toBe("true") + }) +}) + +async function configurePage(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + showSessionProgressBar: true, + }, + }), + ) + }) +} + +async function sampleExpansion(page: Page) { + return page.evaluate( + ({ contextIDs, followingTextID }) => + new Promise< + { + frame: number + label: string + scrollTop: number + scrollHeight: number + contextBottom: number + textTop: number + overlap: number + gap: number + expanded: string | null + }[] + >((resolve) => { + const context = document.querySelector(`[data-timeline-part-ids="${contextIDs.join(",")}"]`) + const text = document.querySelector(`[data-timeline-part-id="${followingTextID}"]`) + const scroller = context?.closest(".scroll-view__viewport") + const trigger = context?.querySelector('[data-slot="collapsible-trigger"]') + const contextRow = context?.closest('[data-timeline-row="AssistantPart"]') + const textRow = text?.closest('[data-timeline-row="AssistantPart"]') + if (!context || !text || !scroller || !trigger || !contextRow || !textRow) throw new Error("missing regression nodes") + + scroller.scrollTop = scroller.scrollHeight + const samples: { + frame: number + label: string + scrollTop: number + scrollHeight: number + contextBottom: number + textTop: number + overlap: number + gap: number + expanded: string | null + }[] = [] + const capture = (frame: number, label: string) => { + const contextRect = contextRow.getBoundingClientRect() + const textRect = textRow.getBoundingClientRect() + samples.push({ + frame, + label, + scrollTop: Math.round(scroller.scrollTop * 10) / 10, + scrollHeight: Math.round(scroller.scrollHeight * 10) / 10, + contextBottom: Math.round(contextRect.bottom * 10) / 10, + textTop: Math.round(textRect.top * 10) / 10, + overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10), + gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10), + expanded: trigger.getAttribute("aria-expanded"), + }) + } + + capture(-1, "before") + trigger.click() + capture(0, "sync-after-click") + + let frame = 1 + const tick = () => { + capture(frame, "raf") + frame += 1 + if (frame > 8) { + resolve(samples) + return + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }), + { contextIDs, followingTextID }, + ) +} + +function turn(index: number, target: boolean): Message[] { + const userID = id("msg_user", index) + const assistantID = id("msg_assistant", index) + return [ + { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }], + }, + { + info: { + id: assistantID, + sessionID, + role: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 }, + parentID: userID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts: target + ? [ + contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }), + contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }), + contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }), + contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }), + { + id: followingTextID, + sessionID, + messageID: assistantID, + type: "text", + text: "This assistant text is immediately after the explored context group.", + }, + ] + : [ + { + id: id("prt_text", index), + sessionID, + messageID: assistantID, + type: "text", + text: `Assistant filler ${index}. ${"filler ".repeat(60)}`, + }, + ], + }, + ] +} + +function contextTool(partID: string, messageID: string, tool: string, input: Record) { + return { + id: partID, + sessionID, + messageID, + type: "tool", + callID: `call_${partID}`, + tool, + state: { + status: "completed", + input, + output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`, + title: input.filePath || input.path || input.pattern || "completed", + metadata: {}, + time: { start: 1700000000000, end: 1700000000100 }, + }, + } +} + +async function mockServer(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + if (url.port !== targetPort) return route.fallback() + + const path = url.pathname + if (path === "/global/event" || path === "/event") return sse(route) + if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path)) return json(route, {}) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, []) + if (path === "/provider") return json(route, provider()) + if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" }) + if (path === "/project") return json(route, [project()]) + if (path === "/project/current") return json(route, project()) + if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (path === "/session") return json(route, [session()]) + if (path === `/session/${sessionID}`) return json(route, session()) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) + if (path === `/session/${sessionID}/message`) return json(route, messages) + return json(route, {}) + }) +} + +async function settle(page: Page) { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) +} + +function id(prefix: string, index: number) { + return `${prefix}_${String(index).padStart(4, "0")}` +} + +function project() { + return { id: projectID, worktree: directory, vcs: "git", name: "context-resize-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] } +} + +function session() { + return { id: sessionID, slug: "context-resize-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } } +} + +function provider() { + return { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + } +} + +function json(route: Route, body: unknown, headers?: Record) { + return route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers }, + body: JSON.stringify(body ?? null), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 9935a62073..930516296a 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -573,6 +573,11 @@ export function MessageTimeline(props: { const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4 + const measureTimeline = () => { + virtualizer?.measure() + anchorMeasuredBottom() + } + function anchorMeasuredBottom() { if (!listRoot) return false if (!measuredBottomAnchored) return false @@ -1003,7 +1008,13 @@ export function MessageTimeline(props: { .filter((part): part is ToolPart => part?.type === "tool") }) - return + return ( + + ) } const message = createMemo(() => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d06d9a2fb7..7e5ae7e51d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -931,7 +931,7 @@ export function AssistantMessageDisplay(props: { ) } -export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { +export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) { const i18n = useI18n() const [open, setOpen] = createSignal(false) const pending = createMemo( @@ -939,11 +939,15 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), ) const summary = createMemo(() => contextToolSummary(props.parts)) + const handleOpenChange = (value: boolean) => { + setOpen(value) + props.onSizeChange?.() + } return ( part.id).join(",")} diff --git a/patches/virtua@0.49.1.patch b/patches/virtua@0.49.1.patch new file mode 100644 index 0000000000..1d8833cd7b --- /dev/null +++ b/patches/virtua@0.49.1.patch @@ -0,0 +1,63 @@ +diff --git a/lib/solid/index.jsx b/lib/solid/index.jsx +index 029201a2c8..e3c4c0ca3a 100644 +--- a/lib/solid/index.jsx ++++ b/lib/solid/index.jsx +@@ -1085,6 +1085,7 @@ const createResizer = (store, isHorizontal) => { + let viewportElement; + const sizeKey = isHorizontal ? "width" : "height"; + const mountedIndexes = new WeakMap(); ++ const mountedItems = new Map(); + const resizeObserver = createResizeObserver((entries) => { + const resizes = []; + for (const { target, contentRect } of entries) { +@@ -1111,12 +1112,27 @@ const createResizer = (store, isHorizontal) => { + }, + $observeItem: (el, i) => { + mountedIndexes.set(el, i); ++ mountedItems.set(i, el); + resizeObserver._observe(el); + return () => { + mountedIndexes.delete(el); ++ if (mountedItems.get(i) === el) { ++ mountedItems.delete(i); ++ } + resizeObserver._unobserve(el); + }; + }, ++ $measureItems: () => { ++ const resizes = []; ++ mountedItems.forEach((el, index) => { ++ if (!el.offsetParent) ++ return; ++ resizes.push([index, el.getBoundingClientRect()[sizeKey]]); ++ }); ++ if (resizes.length) { ++ store.$update(ACTION_ITEM_RESIZE, resizes); ++ } ++ }, + $dispose: resizeObserver._dispose, + }; + }; +@@ -1380,6 +1396,7 @@ const Virtualizer = (props) => { + findItemIndex: store.$findItemIndex, + getItemOffset: store.$getItemOffset, + getItemSize: store.$getItemSize, ++ measure: resizer.$measureItems, + scrollToIndex: scroller.$scrollToIndex, + scrollTo: scroller.$scrollTo, + scrollBy: scroller.$scrollBy, +diff --git a/lib/solid/Virtualizer.d.ts b/lib/solid/Virtualizer.d.ts +index 144dd7fba8..819aab92c5 100644 +--- a/lib/solid/Virtualizer.d.ts ++++ b/lib/solid/Virtualizer.d.ts +@@ -38,6 +38,10 @@ export interface VirtualizerHandle { + * @param index index of item + */ + getItemSize(index: number): number; ++ /** ++ * Synchronously measure currently mounted items and update cached item sizes. ++ */ ++ measure(): void; + /** + * Scroll to the item specified by index. + * @param index index of item