diff --git a/bun.lock b/bun.lock index fe206af0e6..62e72f7294 100644 --- a/bun.lock +++ b/bun.lock @@ -722,6 +722,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 6e3b811e16..aa9e9fc102 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@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", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch" + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch" } } diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts new file mode 100644 index 0000000000..18e63e2a1f --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -0,0 +1,353 @@ +import { expect, test, type Locator, type Page, type Route } from "@playwright/test" + +const directory = "C:/OpenCode/TimelineStateRegression" +const projectID = "proj_timeline_state_regression" +const sessionID = "ses_timeline_state_regression" +const userMessageID = "msg_user_regression" +const assistantMessageID = "msg_assistant_regression" +const editPartID = "prt_0001_edit" +const textPartID = "prt_9999_text" +const title = "Timeline collapse state regression" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type EventPayload = { + directory: string + payload: Record +} + +declare global { + interface Window { + __timelineDiffProbe: { + reset: () => void + shadowRoots: () => number + } + } +} + +const userMessage = { + info: { + id: userMessageID, + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [ + { + id: "prt_user_text", + sessionID, + messageID: userMessageID, + type: "text", + text: "Please edit the file.", + }, + ], +} + +const editPart = { + id: editPartID, + sessionID, + messageID: assistantMessageID, + type: "tool", + callID: "call_edit_regression", + tool: "edit", + state: { + status: "completed", + input: { filePath: "src/regression.ts" }, + output: "Edited src/regression.ts", + title: "src/regression.ts", + metadata: { + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: "export const value = 'before'\n", + after: "export const value = 'after'\n", + }, + diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n", + }, + time: { start: 1700000001000, end: 1700000002000 }, + }, +} + +const streamedTextPart = { + id: textPartID, + sessionID, + messageID: assistantMessageID, + type: "text", + text: "Streaming added a later assistant text part.", +} + +const assistantMessage = { + info: { + id: assistantMessageID, + sessionID, + role: "assistant", + time: { created: 1700000001000 }, + parentID: userMessageID, + 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", + }, + parts: [editPart], +} + +test.describe("regression: session timeline local row state", () => { + test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => { + const events: EventPayload[] = [] + await mockServer(page, events) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expect(page.getByRole("heading", { name: title })).toBeVisible() + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + await expect(wrapper).toBeVisible() + await expectExpanded(wrapper, true) + + await wrapper.evaluate((element) => { + ;(element as HTMLElement).dataset.regressionMarker = "before-stream" + }) + await wrapper.locator('[data-slot="collapsible-trigger"]').first().click() + await expectExpanded(wrapper, false) + + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { part: streamedTextPart }, + }, + }) + + await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) + + expect(await readToolState(page)).toEqual({ + expanded: false, + row: "AssistantPart", + streamedTextVisible: true, + }) + }) + + test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => { + const events: EventPayload[] = [] + await installDiffProbe(page) + await mockServer(page, events) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expect(page.getByRole("heading", { name: title })).toBeVisible() + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + await expect(wrapper).toBeVisible() + await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible() + await markDiffProbe(page) + + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { part: streamedTextPart }, + }, + }) + + await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) + expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) + + await markDiffProbe(page) + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { part: editPartWithAdditions(2) }, + }, + }) + + await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible({ timeout: 10_000 }) + expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) + }) +}) + +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 expectExpanded(locator: Locator, expected: boolean) { + await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected) +} + +async function readToolState(page: Page) { + return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element, textPartID) => ({ + expanded: (() => { + const trigger = element.querySelector('[data-slot="collapsible-trigger"]') + const aria = trigger?.getAttribute("aria-expanded") + if (aria === "true") return true + if (aria === "false") return false + + const root = element.querySelector('[data-component="collapsible"]') + if (root?.hasAttribute("data-expanded")) return true + if (root?.hasAttribute("data-closed")) return false + + const content = element.querySelector('[data-slot="collapsible-content"]') + return !!content && content.getBoundingClientRect().height > 0 + })(), + row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), + streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), + }), textPartID) +} + +async function installDiffProbe(page: Page) { + await page.addInitScript(() => { + let shadowRootCount = 0 + const attachShadow = Element.prototype.attachShadow + Element.prototype.attachShadow = function (init) { + shadowRootCount += 1 + return attachShadow.call(this, init) + } + window.__timelineDiffProbe = { + reset: () => { + shadowRootCount = 0 + }, + shadowRoots: () => shadowRootCount, + } + }) +} + +async function markDiffProbe(page: Page) { + await page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element) => { + const tool = element as HTMLElement + const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + if (!file) throw new Error("missing edit diff file") + + tool.dataset.timelineProbe = "before" + file.dataset.timelineProbe = "before" + window.__timelineDiffProbe.reset() + }) +} + +async function readDiffProbe(page: Page) { + return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element) => { + const tool = element as HTMLElement + const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + return { + fileMarker: file?.dataset.timelineProbe, + shadowRoots: window.__timelineDiffProbe.shadowRoots(), + toolMarker: tool.dataset.timelineProbe, + } + }) +} + +function editPartWithAdditions(additions: number) { + return { + ...editPart, + state: { + ...editPart.state, + metadata: { + ...editPart.state.metadata, + filediff: { + ...editPart.state.metadata.filediff, + additions, + }, + }, + }, + } +} + +function readExpanded(element: Element) { + const trigger = element.querySelector('[data-slot="collapsible-trigger"]') + const aria = trigger?.getAttribute("aria-expanded") + if (aria === "true") return true + if (aria === "false") return false + + const root = element.querySelector('[data-component="collapsible"]') + if (root?.hasAttribute("data-expanded")) return true + if (root?.hasAttribute("data-closed")) return false + + const content = element.querySelector('[data-slot="collapsible-content"]') + return !!content && content.getBoundingClientRect().height > 0 +} + +async function mockServer(page: Page, events: EventPayload[]) { + 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") return sse(route, events.splice(0)) + if (path === "/global/config" || path === "/config" || path === "/provider/auth" || path === "/mcp" || path === "/session/status") 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, [userMessage, assistantMessage]) + return json(route, {}) + }) +} + +function project() { + return { id: projectID, worktree: directory, vcs: "git", name: "timeline-state-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] } +} + +function session() { + return { id: sessionID, slug: "timeline-state-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, events: EventPayload[]) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + headers: { "access-control-allow-origin": "*" }, + body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), + }) +} + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} 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.data.ts b/packages/app/src/pages/session/message-timeline.data.ts index c6294d3e38..0643a424ea 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/message-timeline.data.ts @@ -23,7 +23,6 @@ export type TimelineRowMap = { userMessageID: string group: PartGroup previousAssistantPart: boolean - lastAssistantPart: boolean } Thinking: { userMessageID: string; reasoningHeading?: string } Retry: { userMessageID: string } @@ -50,7 +49,6 @@ export namespace TimelineRow { userMessageID: string group: PartGroup previousAssistantPart: boolean - lastAssistantPart: boolean }> {} export class Thinking extends Data.TaggedClass("Thinking")<{ userMessageID: string @@ -151,8 +149,6 @@ export namespace Timeline { ), ] : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) - const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length - if (comments.length > 0) rows.push( new TimelineRow.CommentStrip({ @@ -195,7 +191,6 @@ export namespace Timeline { userMessageID: userMessage.id, group: item.group, previousAssistantPart: assistantGroupIndex > 0, - lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1, }), ) assistantGroupIndex += 1 diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index a39df7122a..209c0be985 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,16 @@ -import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js" +import { + createEffect, + createMemo, + createSignal, + For, + Index, + on, + onCleanup, + Show, + mapArray, + type Accessor, + type JSX, +} from "solid-js" import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" @@ -245,7 +257,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) { return (
- +
) } @@ -415,8 +427,7 @@ export function MessageTimeline(props: { if (rows.length === 0) return rows return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()]) }) - const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const))) - const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys }) + const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys }) const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys())) const messageRowIndex = createMemo(() => { const result = new Map() @@ -427,6 +438,14 @@ export function MessageTimeline(props: { }) return result }) + const lastAssistantGroupKey = createMemo(() => { + const result = new Map() + timelineRows().forEach((row) => { + if (row._tag !== "AssistantPart") return + result.set(row.userMessageID, row.group.key) + }) + return result + }) const keepMounted = createMemo(() => { const id = activeMessageID() if (!id) return @@ -540,6 +559,7 @@ export function MessageTimeline(props: { const [bar, setBar] = createStore({ ms: pace(640), }) + const [toolOpen, setToolOpen] = createStore>({}) let more: HTMLButtonElement | undefined let head: HTMLDivElement | undefined @@ -560,6 +580,11 @@ export function MessageTimeline(props: { const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4 + const measureTimeline = () => { + ;(virtualizer as (VirtualizerHandle & { measure?: () => void }) | undefined)?.measure?.() + anchorMeasuredBottom() + } + function anchorMeasuredBottom() { if (!listRoot) return false if (!measuredBottomAnchored) return false @@ -980,29 +1005,40 @@ export function MessageTimeline(props: { const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) - const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => { - if (row.group.type === "context") { + const renderAssistantPartGroup = (row: Accessor) => { + if (row().group.type === "context") { const parts = createMemo(() => { - const group = row.group + const group = row().group if (group.type !== "context") return emptyTools return group.refs .map((ref) => getMsgPart(ref.messageID, ref.partID)) .filter((part): part is ToolPart => part?.type === "tool") }) - return + return ( + + ) } const message = createMemo(() => { - const group = row.group + const group = row().group if (group.type !== "part") return return messageByID().get(group.ref.messageID) }) const part = createMemo(() => { - const group = row.group + const group = row().group if (group.type !== "part") return return getMsgPart(group.ref.messageID, group.ref.partID) }) + const defaultOpen = createMemo(() => { + const item = part() + if (!item) return + return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) + }) return ( @@ -1012,14 +1048,13 @@ export function MessageTimeline(props: { setToolOpen(part().id, open)} deferToolContent={false} + virtualizeDiff={false} /> )} @@ -1028,25 +1063,25 @@ export function MessageTimeline(props: { ) } - function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) { + function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { const anchor = () => { - const row = input.row + const row = input.row() return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) } const previousUserMessage = () => { - const row = input.row + const row = input.row() return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage } const previousAssistantPart = () => { - const row = input.row + const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart } return (
{ - switch (row._tag) { + const renderTimelineRow = (row: Accessor) => { + switch (row()._tag) { case "CommentStrip": { + const commentStripRow = row as Accessor> const comments = createMemo(() => - getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), + getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), ) return ( - +
@@ -1102,17 +1138,18 @@ export function MessageTimeline(props: { ) } case "UserMessage": { + const userMessageRow = row as Accessor> const message = createMemo(() => { - const m = messageByID().get(row.userMessageID) + const m = messageByID().get(userMessageRow().userMessageID) if (m?.role === "user") return m }) return ( - + {(message) => (
- +
)} @@ -1121,13 +1158,14 @@ export function MessageTimeline(props: { ) } case "TurnDivider": { + const turnDividerRow = row as Accessor> return ( - +
@@ -1136,22 +1174,24 @@ export function MessageTimeline(props: { ) } case "AssistantPart": { + const assistantPartRow = row as Accessor> return ( - +
-
- {renderAssistantPartGroup(row)} +
+ {renderAssistantPartGroup(assistantPartRow)}
) } case "Thinking": { + const thinkingRow = row as Accessor> return ( - +
@@ -1159,29 +1199,32 @@ export function MessageTimeline(props: { ) } case "Retry": { + const retryRow = row as Accessor> return ( - +
- +
) } case "DiffSummary": { + const diffSummaryRow = row as Accessor> return ( - +
- +
) } case "Error": { + const errorRow = row as Accessor> return ( - +
- {row.text} + {errorRow().text}
@@ -1192,12 +1235,8 @@ export function MessageTimeline(props: { } } - function TimelineRowView(props: { rowKey: string }) { - return ( - - {(item) => renderTimelineRow(item)} - - ) + function TimelineRowView(props: { row: TimelineRow.TimelineRow }) { + return renderTimelineRow(() => props.row) } return ( @@ -1529,7 +1568,7 @@ export function MessageTimeline(props: { {(root) => ( - {(key) => } + {(row) => } )} diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index cb9f9b17fe..213a48e11f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -29,6 +29,8 @@ export interface BasicToolProps { status?: string hideDetails?: boolean defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void forceOpen?: boolean defer?: boolean locked?: boolean @@ -83,7 +85,7 @@ export function BasicTool(props: BasicToolProps) { open: props.defaultOpen ?? false, ready: !props.defer && (props.defaultOpen ?? false), }) - const open = () => state.open + const open = () => props.open ?? state.open const ready = () => state.ready const pending = () => props.status === "pending" || props.status === "running" const hasChildren = () => (props.defer ? "children" in props : props.children) @@ -110,8 +112,15 @@ export function BasicTool(props: BasicToolProps) { if (props.defer && open()) scheduleReady(true) }) + const setOpen = (value: boolean) => { + if (props.open === undefined) setState("open", value) + props.onOpenChange?.(value) + } + createEffect(() => { - if (props.forceOpen) setState("open", true) + if (!props.forceOpen) return + if (open()) return + setOpen(true) }) createEffect( @@ -166,7 +175,7 @@ export function BasicTool(props: BasicToolProps) { const handleOpenChange = (value: boolean) => { if (pending()) return if (props.locked && !value) return - setState("open", value) + setOpen(value) } const trigger = () => ( diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 97d4d69f78..ebf1758b1a 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -88,6 +88,7 @@ type DiffBaseProps = FileDiffOptions & mode: "diff" annotations?: DiffLineAnnotation[] preloadedDiff?: DiffPreload + virtualize?: boolean } type DiffPairProps = DiffBaseProps & { @@ -123,7 +124,7 @@ const sharedKeys = [ ] as const const textKeys = ["file", ...sharedKeys] as const -const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const +const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const // --------------------------------------------------------------------------- // Shared viewer hook @@ -482,17 +483,24 @@ function notifyRendered(opts: { function renderViewer(opts: { viewer: Viewer current: I | undefined + reset?: boolean create: () => I + update?: (value: I) => void assign: (value: I) => void draw: (value: I) => void onReady: () => void }) { clearReadyWatcher(opts.viewer.ready) - opts.current?.cleanUp() - const next = opts.create() - opts.assign(next) + const reset = opts.reset === true && opts.current !== undefined + if (reset) opts.current?.cleanUp() + const next = reset || !opts.current ? opts.create() : opts.current + if (reset || !opts.current) { + opts.viewer.container.innerHTML = "" + opts.assign(next) + } else { + opts.update?.(next) + } - opts.viewer.container.innerHTML = "" opts.draw(next) applyViewerScheme(opts.viewer.getHost()) @@ -566,7 +574,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab } } -function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy { +function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy { let shared: NonNullable> | undefined const release = () => { @@ -576,6 +584,10 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): Vi return { get: () => { + if (!enabled()) { + release() + return + } if (shared) return shared.virtualizer const container = host() @@ -689,6 +701,7 @@ function ViewerShell(props: { function TextViewer(props: TextFileProps) { let instance: PierreFile | VirtualizedFile | undefined + let renderMode: Virtualizer | "plain" | undefined let viewer!: Viewer const [local, others] = splitProps(props, textKeys) @@ -861,16 +874,20 @@ function TextViewer(props: TextFileProps) { const isVirtual = virtual() const virtualizer = virtuals.get() + const nextRenderMode = isVirtual && virtualizer ? virtualizer : "plain" renderViewer({ viewer, current: instance, + reset: renderMode !== undefined && renderMode !== nextRenderMode, create: () => isVirtual && virtualizer ? new VirtualizedFile(opts, virtualizer, codeMetrics, workerPool) : new PierreFile(opts, workerPool), + update: (value) => value.setOptions(opts), assign: (value) => { instance = value + renderMode = nextRenderMode }, draw: (value) => { const contents = text() @@ -895,6 +912,7 @@ function TextViewer(props: TextFileProps) { onCleanup(() => { instance?.cleanUp() instance = undefined + renderMode = undefined virtuals.cleanup() }) @@ -907,6 +925,7 @@ function TextViewer(props: TextFileProps) { function DiffViewer(props: DiffFileProps) { let instance: FileDiff | undefined + let renderMode: Virtualizer | "plain" | undefined let dragSide: DiffSelectionSide | undefined let dragEndSide: DiffSelectionSide | undefined let viewer!: Viewer @@ -991,7 +1010,10 @@ function DiffViewer(props: DiffFileProps) { adapter, ) - const virtuals = createSharedVirtualStrategy(() => viewer.container) + const virtuals = createSharedVirtualStrategy( + () => viewer.container, + () => local.virtualize !== false, + ) const large = createMemo(() => { if (local.fileDiff) { @@ -1056,6 +1078,7 @@ function DiffViewer(props: DiffFileProps) { const opts = options() const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) const virtualizer = virtuals.get() + const nextRenderMode = virtualizer ?? "plain" const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" const done = preserve(viewer) @@ -1070,12 +1093,15 @@ function DiffViewer(props: DiffFileProps) { renderViewer({ viewer, current: instance, + reset: renderMode !== undefined && renderMode !== nextRenderMode, create: () => virtualizer ? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool) : new FileDiff(opts, workerPool), + update: (value) => value.setOptions(opts), assign: (value) => { instance = value + renderMode = nextRenderMode }, draw: (value) => { if (local.fileDiff) { @@ -1111,6 +1137,7 @@ function DiffViewer(props: DiffFileProps) { onCleanup(() => { instance?.cleanUp() instance = undefined + renderMode = undefined virtuals.cleanup() dragSide = undefined dragEndSide = undefined diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index f309ca5f68..ad3be26cb2 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -175,7 +175,10 @@ export interface MessagePartProps { message: MessageType hideDetails?: boolean defaultOpen?: boolean + toolOpen?: boolean + onToolOpenChange?: (open: boolean) => void deferToolContent?: boolean + virtualizeDiff?: boolean showAssistantCopyPartID?: string | null turnDurationMs?: number } @@ -290,7 +293,7 @@ function getDirectory(path: string | undefined) { } import type { IconProps } from "./icon" -import { normalize } from "./session-diff" +import { normalize, resolveFileDiff } from "./session-diff" export type ToolInfo = { icon: IconProps["name"] @@ -930,7 +933,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( @@ -938,11 +941,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(",")} @@ -1261,7 +1268,10 @@ export function Part(props: MessagePartProps) { message={props.message} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + toolOpen={props.toolOpen} + onToolOpenChange={props.onToolOpenChange} deferToolContent={props.deferToolContent} + virtualizeDiff={props.virtualizeDiff} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} /> @@ -1278,7 +1288,10 @@ export interface ToolProps { status?: string hideDetails?: boolean defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void deferContent?: boolean + virtualizeDiff?: boolean forceOpen?: boolean locked?: boolean } @@ -1376,6 +1389,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }) const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) + const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined) + const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) return ( @@ -1399,6 +1414,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { error={error()} title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined} defaultOpen={props.defaultOpen} + open={controlledOpen()} + onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} subtitle={taskSubtitle()} href={taskHref()} /> @@ -1417,7 +1434,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + open={controlledOpen()} + onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} deferContent={props.deferToolContent} + virtualizeDiff={props.virtualizeDiff} /> @@ -1921,15 +1941,29 @@ ToolRegistry.register({ const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") const pending = () => props.status === "pending" || props.status === "running" + const diffSource = createMemo( + () => { + const filediff = props.metadata?.filediff + if (!filediff) return + return { + file: filediff.file || props.input.filePath || "", + patch: typeof filediff.patch === "string" ? filediff.patch : undefined, + before: typeof filediff.before === "string" ? filediff.before : undefined, + after: typeof filediff.after === "string" ? filediff.after : undefined, + } + }, + undefined, + { + equals: (a, b) => + a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after, + }, + ) const fileCompProps = createMemo(() => { try { - if (props.metadata?.filediff) { - const diff = normalize({ - ...props.metadata?.filediff, - status: "modified", - }) - const fileDiff = diff.fileDiff + const source = diffSource() + if (source) { + const fileDiff = resolveFileDiff(source) if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" } } } catch {} @@ -1987,7 +2021,7 @@ ToolRegistry.register({ } >
- +
@@ -2171,6 +2205,7 @@ ToolRegistry.register({ @@ -2243,7 +2278,7 @@ ToolRegistry.register({ } >
- +
diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 52ef0a4bbc..26c6e66e0f 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -14,6 +14,14 @@ type LegacyDiff = { type SnapshotDiff = SnapshotFileDiff & { file: string } type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +export type DiffSource = Pick +type PatchData = { + before: string + after: string + patch: string + patchIsPartial: boolean + fileDiff?: FileDiffMetadata +} export type ViewDiff = { file: string @@ -24,97 +32,226 @@ export type ViewDiff = { fileDiff: FileDiffMetadata } -const cache = new Map() +const diffCacheLimit = 16 +const patchFileDiffCache = new Map() +const contentPatchCache: { file: string; before: string; after: string; value: PatchData }[] = [] -function patch(diff: ReviewDiff) { - if (typeof diff.patch === "string") { - try { - const [patch] = parsePatch(diff.patch) - const beforeLines: Array<{ text: string; newline: boolean }> = [] - const afterLines: Array<{ text: string; newline: boolean }> = [] - let previous: "-" | "+" | " " | undefined - - const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1) - - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("\\")) { - if (previous === "-" || previous === " ") { - const before = beforeLines.at(-1) - if (before) before.newline = false - } - if (previous === "+" || previous === " ") { - const after = afterLines.at(-1) - if (after) after.newline = false - } - continue - } - - if (line.startsWith("-")) { - beforeLines.push({ text: line.slice(1), newline: true }) - previous = "-" - } else if (line.startsWith("+")) { - afterLines.push({ text: line.slice(1), newline: true }) - previous = "+" - } else { - // context line (starts with ' ') - beforeLines.push({ text: line.slice(1), newline: true }) - afterLines.push({ text: line.slice(1), newline: true }) - previous = " " - } - } - } - - return { - before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), - after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), - patch: diff.patch, - patchIsPartial, - } - } catch { - return { before: "", after: "", patch: diff.patch, patchIsPartial: false } - } - } - return { - before: "before" in diff && typeof diff.before === "string" ? diff.before : "", - after: "after" in diff && typeof diff.after === "string" ? diff.after : "", - patch: formatPatch( - structuredPatch( - diff.file, - diff.file, - "before" in diff && typeof diff.before === "string" ? diff.before : "", - "after" in diff && typeof diff.after === "string" ? diff.after : "", - "", - "", - { context: Number.MAX_SAFE_INTEGER }, - ), - ), - patchIsPartial: false, - } -} - -function file(file: string, patch: string, before: string, after: string, partial = false) { - const hit = cache.get(patch) - if (hit) return hit - - let value: FileDiffMetadata | undefined - if (partial) value = parsePatchFiles(patch)[0]?.files[0] - if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after }) - - cache.set(patch, value) +function mapCache(cache: Map, key: K) { + const value = cache.get(key) + if (value === undefined) return + cache.delete(key) + cache.set(key, value) return value } +function setMapCache(cache: Map, key: K, value: V) { + cache.delete(key) + cache.set(key, value) + while (cache.size > diffCacheLimit) cache.delete(cache.keys().next().value!) + return value +} + +function patch(diff: DiffSource) { + if (typeof diff.patch === "string") { + return { + before: "", + after: "", + patch: diff.patch, + patchIsPartial: false, + } + } + + return patchFromContent(diff) +} + +function patchFromContent(diff: DiffSource): PatchData { + const file = diff.file + const before = typeof diff.before === "string" ? diff.before : "" + const after = typeof diff.after === "string" ? diff.after : "" + const index = contentPatchCache.findIndex( + (entry) => entry.file === file && entry.before === before && entry.after === after, + ) + if (index !== -1) { + const entry = contentPatchCache[index]! + contentPatchCache.splice(index, 1) + contentPatchCache.push(entry) + return entry.value + } + + const value = contentPatch(file, before, after) + + contentPatchCache.push({ file, before, after, value }) + while (contentPatchCache.length > diffCacheLimit) contentPatchCache.shift() + return value +} + +function contentPatch(file: string, before: string, after: string): PatchData { + const replacement = replacementPatch(file, before, after) + if (replacement) return replacement + + const exact = structuredPatch(file, file, before, after, "", "", { + context: Number.MAX_SAFE_INTEGER, + })! + + const patch = formatPatch(exact) + const fileDiff = parsePatchFiles(patch)[0]?.files[0] + return { + before, + after, + patch, + patchIsPartial: false, + fileDiff: fileDiff ? { ...fileDiff, isPartial: false } : parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after }), + } +} + +function replacementPatch(file: string, before: string, after: string): PatchData | undefined { + const deletionLines = patchLines(before).map((line) => line.value + (line.newline ? "\n" : "")) + const additionLines = patchLines(after).map((line) => line.value + (line.newline ? "\n" : "")) + if (hasCommonLine(deletionLines, additionLines)) return + + const fileDiff = replacementFileDiff(file, before, after, deletionLines, additionLines) + return { + before, + after, + patch: replacementPatchText(file, fileDiff), + patchIsPartial: false, + fileDiff, + } +} + +function replacementFileDiff(file: string, before: string, after: string, deleted?: string[], added?: string[]): FileDiffMetadata { + const deletionLines = deleted ?? patchLines(before).map((line) => line.value + (line.newline ? "\n" : "")) + const additionLines = added ?? patchLines(after).map((line) => line.value + (line.newline ? "\n" : "")) + const deletionCount = deletionLines.length + const additionCount = additionLines.length + + return { + name: file, + type: deletionCount === 0 ? "new" : additionCount === 0 ? "deleted" : "change", + hunks: + deletionCount === 0 && additionCount === 0 + ? [] + : [ + { + collapsedBefore: 0, + splitLineCount: Math.max(deletionCount, additionCount), + splitLineStart: 0, + unifiedLineCount: deletionCount + additionCount, + unifiedLineStart: 0, + additionCount, + additionStart: additionCount === 0 ? 0 : 1, + additionLines: additionCount, + deletionCount, + deletionStart: deletionCount === 0 ? 0 : 1, + deletionLines: deletionCount, + deletionLineIndex: 0, + additionLineIndex: 0, + hunkContent: [ + { + type: "change", + additions: additionCount, + deletions: deletionCount, + additionLineIndex: 0, + deletionLineIndex: 0, + }, + ], + hunkSpecs: `@@ -${deletionCount === 0 ? 0 : 1},${deletionCount} +${additionCount === 0 ? 0 : 1},${additionCount} @@\n`, + noEOFCRAdditions: additionCount > 0 && !after.endsWith("\n"), + noEOFCRDeletions: deletionCount > 0 && !before.endsWith("\n"), + }, + ], + splitLineCount: Math.max(deletionCount, additionCount), + unifiedLineCount: deletionCount + additionCount, + isPartial: false, + deletionLines, + additionLines, + } +} + +function replacementPatchText(file: string, diff: FileDiffMetadata) { + const hunk = diff.hunks[0] + if (!hunk) return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n` + return ( + [ + `Index: ${file}`, + "===================================================================", + `--- ${file}\t`, + `+++ ${file}\t`, + hunk.hunkSpecs?.trimEnd() ?? `@@ -1,${diff.deletionLines.length} +1,${diff.additionLines.length} @@`, + ...diff.deletionLines.flatMap((line) => patchLine("-", line)), + ...diff.additionLines.flatMap((line) => patchLine("+", line)), + ].join("\n") + "\n" + ) +} + +function hasCommonLine(a: string[], b: string[]) { + if (a.length === 0 || b.length === 0) return false + const small = a.length < b.length ? a : b + const large = small === a ? b : a + const seen = new Set(small) + return large.some((line) => seen.has(line)) +} + +function patchLine(prefix: "-" | "+", line: string) { + if (line.endsWith("\n")) return [prefix + line.slice(0, -1)] + return [prefix + line, "\\ No newline at end of file"] +} + +function patchLines(value: string) { + if (!value) return [] + const parts = value.split("\n") + const trailing = value.endsWith("\n") + if (trailing) parts.pop() + return parts.map((line, index) => ({ + value: line, + newline: trailing || index < parts.length - 1, + })) +} + +function fileDiffFromPatch(patch: string) { + const hit = mapCache(patchFileDiffCache, patch) + if (hit) return hit + + let value: FileDiffMetadata | undefined + const info = patchInfo(patch) + if (info) { + const file = parsePatchFiles(patch)[0]?.files[0] + if (file) value = { ...file, isPartial: info.patchIsPartial } + } + if (value === undefined) value = parseDiffFromFile({ name: "", contents: "" }, { name: "", contents: "" }) + + return setMapCache(patchFileDiffCache, patch, value) +} + +function patchInfo(value: string) { + try { + return { + patchIsPartial: parsePatch(value).every((file) => file.hunks.every((hunk) => hunk.oldStart > 1)), + } + } catch { + return undefined + } +} + +function fileDiff(diff: DiffSource) { + if (typeof diff.patch === "string") return fileDiffFromPatch(diff.patch) + return patchFromContent(diff).fileDiff! +} + +export function resolveFileDiff(diff: DiffSource) { + return fileDiff(diff) +} + export function normalize(diff: ReviewDiff): ViewDiff { - const next = patch(diff) - const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial) return { file: diff.file, - patch: next.patch, + get patch() { + return patch(diff).patch + }, additions: diff.additions, deletions: diff.deletions, status: diff.status, - fileDiff, + fileDiff: fileDiff(diff), } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 1089587ee1..f8967ff81b 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -183,7 +183,7 @@ export const SessionReview = (props: SessionReviewProps) => { const open = () => props.open ?? store.open const items = createMemo(() => - list(props.diffs).map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })), + list(props.diffs).map((diff) => Object.assign(normalize(diff), { preloaded: diff.preloaded })), ) const files = createMemo(() => items().map((diff) => diff.file)) const grouped = createMemo(() => { diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index c85e7d55cd..b75671bae5 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit, "c error: string title?: string defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void subtitle?: string href?: string } @@ -22,9 +24,22 @@ export function ToolErrorCard(props: ToolErrorCardProps) { open: props.defaultOpen ?? false, copied: false, }) - const open = () => state.open + const open = () => props.open ?? state.open const copied = () => state.copied - const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"]) + const [split, rest] = splitProps(props, [ + "tool", + "error", + "title", + "defaultOpen", + "open", + "onOpenChange", + "subtitle", + "href", + ]) + const setOpen = (value: boolean) => { + if (props.open === undefined) setState("open", value) + props.onOpenChange?.(value) + } const name = createMemo(() => { if (split.title) return split.title const map: Record = { @@ -81,7 +96,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} - onOpenChange={(value) => setState("open", value)} + onOpenChange={setOpen} >
diff --git a/patches/virtua@0.49.1.patch b/patches/virtua@0.49.1.patch new file mode 100644 index 0000000000..d8064d3d01 --- /dev/null +++ b/patches/virtua@0.49.1.patch @@ -0,0 +1,93 @@ +diff --git a/lib/solid/Virtualizer.d.ts b/lib/solid/Virtualizer.d.ts +index 144dd7f..819aab9 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 +diff --git a/lib/solid/index.jsx b/lib/solid/index.jsx +index 029201a..3949cd4 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, + }; + }; +@@ -1354,6 +1370,8 @@ const Virtualizer = (props) => { + const range = createMemo((prev) => { + stateVersion(); + const next = store.$getRange(props.bufferSize); ++ next[0] = Math.max(0, next[0]); ++ next[1] = Math.min(props.data.length - 1, next[1]); + if (prev && isSameRange(prev, next)) { + return prev; + } +@@ -1380,6 +1398,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, +@@ -1417,6 +1436,11 @@ const Virtualizer = (props) => { + const indexes = []; + if (props.keepMounted) { + const mounted = new Set(props.keepMounted); ++ mounted.forEach((index) => { ++ if (index < 0 || index >= count) { ++ mounted.delete(index); ++ } ++ }); + for (let [i, j] = range(); i <= j; i++) { + mounted.add(i); + } +@@ -1528,6 +1552,8 @@ const WindowVirtualizer = (props) => { + const range = createMemo((prev) => { + stateVersion(); + const next = store.$getRange(props.bufferSize); ++ next[0] = Math.max(0, next[0]); ++ next[1] = Math.min(props.data.length - 1, next[1]); + if (prev && isSameRange(prev, next)) { + return prev; + }