fix(app): stabilize virtual session timeline interactions (#28422)
This commit is contained in:
parent
9495ecd536
commit
f023c63a60
14 changed files with 1062 additions and 177 deletions
1
bun.lock
1
bun.lock
|
|
@ -722,6 +722,7 @@
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@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",
|
"@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",
|
"@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": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.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",
|
"@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",
|
||||||
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<string, string>) {
|
||||||
|
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, "")
|
||||||
|
}
|
||||||
|
|
@ -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<string, unknown> & { id: string; role: "user" | "assistant" }; parts: Record<string, unknown>[] }
|
||||||
|
|
||||||
|
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<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
|
||||||
|
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
|
||||||
|
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
|
||||||
|
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
||||||
|
const textRow = text?.closest<HTMLElement>('[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<string, unknown>) {
|
||||||
|
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<string, string>) {
|
||||||
|
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, "")
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,6 @@ export type TimelineRowMap = {
|
||||||
userMessageID: string
|
userMessageID: string
|
||||||
group: PartGroup
|
group: PartGroup
|
||||||
previousAssistantPart: boolean
|
previousAssistantPart: boolean
|
||||||
lastAssistantPart: boolean
|
|
||||||
}
|
}
|
||||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||||
Retry: { userMessageID: string }
|
Retry: { userMessageID: string }
|
||||||
|
|
@ -50,7 +49,6 @@ export namespace TimelineRow {
|
||||||
userMessageID: string
|
userMessageID: string
|
||||||
group: PartGroup
|
group: PartGroup
|
||||||
previousAssistantPart: boolean
|
previousAssistantPart: boolean
|
||||||
lastAssistantPart: boolean
|
|
||||||
}> {}
|
}> {}
|
||||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||||
userMessageID: string
|
userMessageID: string
|
||||||
|
|
@ -151,8 +149,6 @@ export namespace Timeline {
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
||||||
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
|
|
||||||
|
|
||||||
if (comments.length > 0)
|
if (comments.length > 0)
|
||||||
rows.push(
|
rows.push(
|
||||||
new TimelineRow.CommentStrip({
|
new TimelineRow.CommentStrip({
|
||||||
|
|
@ -195,7 +191,6 @@ export namespace Timeline {
|
||||||
userMessageID: userMessage.id,
|
userMessageID: userMessage.id,
|
||||||
group: item.group,
|
group: item.group,
|
||||||
previousAssistantPart: assistantGroupIndex > 0,
|
previousAssistantPart: assistantGroupIndex > 0,
|
||||||
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
assistantGroupIndex += 1
|
assistantGroupIndex += 1
|
||||||
|
|
|
||||||
|
|
@ -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 { createStore, produce } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
|
@ -245,7 +257,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||||
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
|
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -415,8 +427,7 @@ export function MessageTimeline(props: {
|
||||||
if (rows.length === 0) return rows
|
if (rows.length === 0) return rows
|
||||||
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
||||||
})
|
})
|
||||||
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
|
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
|
||||||
const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
|
|
||||||
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
||||||
const messageRowIndex = createMemo(() => {
|
const messageRowIndex = createMemo(() => {
|
||||||
const result = new Map<string, number>()
|
const result = new Map<string, number>()
|
||||||
|
|
@ -427,6 +438,14 @@ export function MessageTimeline(props: {
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
const lastAssistantGroupKey = createMemo(() => {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
timelineRows().forEach((row) => {
|
||||||
|
if (row._tag !== "AssistantPart") return
|
||||||
|
result.set(row.userMessageID, row.group.key)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
const keepMounted = createMemo(() => {
|
const keepMounted = createMemo(() => {
|
||||||
const id = activeMessageID()
|
const id = activeMessageID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
@ -540,6 +559,7 @@ export function MessageTimeline(props: {
|
||||||
const [bar, setBar] = createStore({
|
const [bar, setBar] = createStore({
|
||||||
ms: pace(640),
|
ms: pace(640),
|
||||||
})
|
})
|
||||||
|
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
let head: HTMLDivElement | 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 isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
|
||||||
|
|
||||||
|
const measureTimeline = () => {
|
||||||
|
virtualizer?.measure()
|
||||||
|
anchorMeasuredBottom()
|
||||||
|
}
|
||||||
|
|
||||||
function anchorMeasuredBottom() {
|
function anchorMeasuredBottom() {
|
||||||
if (!listRoot) return false
|
if (!listRoot) return false
|
||||||
if (!measuredBottomAnchored) 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 getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||||
|
|
||||||
const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
|
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
|
||||||
if (row.group.type === "context") {
|
if (row().group.type === "context") {
|
||||||
const parts = createMemo(() => {
|
const parts = createMemo(() => {
|
||||||
const group = row.group
|
const group = row().group
|
||||||
if (group.type !== "context") return emptyTools
|
if (group.type !== "context") return emptyTools
|
||||||
return group.refs
|
return group.refs
|
||||||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||||
.filter((part): part is ToolPart => part?.type === "tool")
|
.filter((part): part is ToolPart => part?.type === "tool")
|
||||||
})
|
})
|
||||||
|
|
||||||
return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
|
return (
|
||||||
|
<ContextToolGroup
|
||||||
|
parts={parts()}
|
||||||
|
busy={workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key}
|
||||||
|
onSizeChange={measureTimeline}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = createMemo(() => {
|
const message = createMemo(() => {
|
||||||
const group = row.group
|
const group = row().group
|
||||||
if (group.type !== "part") return
|
if (group.type !== "part") return
|
||||||
return messageByID().get(group.ref.messageID)
|
return messageByID().get(group.ref.messageID)
|
||||||
})
|
})
|
||||||
const part = createMemo(() => {
|
const part = createMemo(() => {
|
||||||
const group = row.group
|
const group = row().group
|
||||||
if (group.type !== "part") return
|
if (group.type !== "part") return
|
||||||
return getMsgPart(group.ref.messageID, group.ref.partID)
|
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 (
|
return (
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
|
|
@ -1012,14 +1048,13 @@ export function MessageTimeline(props: {
|
||||||
<MessagePart
|
<MessagePart
|
||||||
part={part()}
|
part={part()}
|
||||||
message={message()}
|
message={message()}
|
||||||
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
|
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||||
turnDurationMs={turnDurationMs(row.userMessageID)}
|
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||||
defaultOpen={partDefaultOpen(
|
defaultOpen={defaultOpen()}
|
||||||
part(),
|
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||||
settings.general.shellToolPartsExpanded(),
|
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||||
settings.general.editToolPartsExpanded(),
|
|
||||||
)}
|
|
||||||
deferToolContent={false}
|
deferToolContent={false}
|
||||||
|
virtualizeDiff={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1028,25 +1063,25 @@ export function MessageTimeline(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
|
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
|
||||||
const anchor = () => {
|
const anchor = () => {
|
||||||
const row = input.row
|
const row = input.row()
|
||||||
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
||||||
}
|
}
|
||||||
const previousUserMessage = () => {
|
const previousUserMessage = () => {
|
||||||
const row = input.row
|
const row = input.row()
|
||||||
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
||||||
}
|
}
|
||||||
const previousAssistantPart = () => {
|
const previousAssistantPart = () => {
|
||||||
const row = input.row
|
const row = input.row()
|
||||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
|
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
|
||||||
data-message-id={input.row.userMessageID}
|
data-message-id={input.row().userMessageID}
|
||||||
data-timeline-row={input.row._tag}
|
data-timeline-row={input.row()._tag}
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||||
|
|
@ -1062,14 +1097,15 @@ export function MessageTimeline(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
|
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
|
||||||
switch (row._tag) {
|
switch (row()._tag) {
|
||||||
case "CommentStrip": {
|
case "CommentStrip": {
|
||||||
|
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
|
||||||
const comments = createMemo(() =>
|
const comments = createMemo(() =>
|
||||||
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={commentStripRow}>
|
||||||
<div class="w-full px-4 md:px-5 pb-2">
|
<div class="w-full px-4 md:px-5 pb-2">
|
||||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
|
|
@ -1102,17 +1138,18 @@ export function MessageTimeline(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "UserMessage": {
|
case "UserMessage": {
|
||||||
|
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
|
||||||
const message = createMemo(() => {
|
const message = createMemo(() => {
|
||||||
const m = messageByID().get(row.userMessageID)
|
const m = messageByID().get(userMessageRow().userMessageID)
|
||||||
if (m?.role === "user") return m
|
if (m?.role === "user") return m
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={userMessageRow}>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<div data-slot="session-turn-message-content" aria-live="off">
|
<div data-slot="session-turn-message-content" aria-live="off">
|
||||||
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
|
<Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1121,13 +1158,14 @@ export function MessageTimeline(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "TurnDivider": {
|
case "TurnDivider": {
|
||||||
|
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={turnDividerRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<div data-slot="session-turn-compaction">
|
<div data-slot="session-turn-compaction">
|
||||||
<MessageDivider
|
<MessageDivider
|
||||||
label={language.t(
|
label={language.t(
|
||||||
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1136,22 +1174,24 @@ export function MessageTimeline(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "AssistantPart": {
|
case "AssistantPart": {
|
||||||
|
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={assistantPartRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
|
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
|
||||||
{renderAssistantPartGroup(row)}
|
{renderAssistantPartGroup(assistantPartRow)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "Thinking": {
|
case "Thinking": {
|
||||||
|
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={thinkingRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<TimelineThinkingRow
|
<TimelineThinkingRow
|
||||||
reasoningHeading={row.reasoningHeading}
|
reasoningHeading={thinkingRow().reasoningHeading}
|
||||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1159,29 +1199,32 @@ export function MessageTimeline(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "Retry": {
|
case "Retry": {
|
||||||
|
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={retryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
|
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "DiffSummary": {
|
case "DiffSummary": {
|
||||||
|
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={diffSummaryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<TimelineDiffSummaryRow diffs={row.diffs} />
|
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "Error": {
|
case "Error": {
|
||||||
|
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={row}>
|
<TimelineRowFrame row={errorRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||||
<Card variant="error" class="error-card">
|
<Card variant="error" class="error-card">
|
||||||
{row.text}
|
{errorRow().text}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
|
|
@ -1192,12 +1235,8 @@ export function MessageTimeline(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineRowView(props: { rowKey: string }) {
|
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
|
||||||
return (
|
return renderTimelineRow(() => props.row)
|
||||||
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
|
|
||||||
{(item) => renderTimelineRow(item)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1529,7 +1568,7 @@ export function MessageTimeline(props: {
|
||||||
<Show when={scrollRoot()}>
|
<Show when={scrollRoot()}>
|
||||||
{(root) => (
|
{(root) => (
|
||||||
<Virtualizer
|
<Virtualizer
|
||||||
data={timelineRowKeys()}
|
data={timelineRows()}
|
||||||
cache={virtualCache()}
|
cache={virtualCache()}
|
||||||
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
||||||
scrollRef={root()}
|
scrollRef={root()}
|
||||||
|
|
@ -1549,7 +1588,7 @@ export function MessageTimeline(props: {
|
||||||
scheduleContentRoot(root())
|
scheduleContentRoot(root())
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(key) => <TimelineRowView rowKey={key} />}
|
{(row) => <TimelineRowView row={row} />}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ describe("apply patch file", () => {
|
||||||
])[0]
|
])[0]
|
||||||
|
|
||||||
expect(file).toBeDefined()
|
expect(file).toBeDefined()
|
||||||
expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
|
|
||||||
expect(text(file!.view, "deletions")).toBe("one\n")
|
expect(text(file!.view, "deletions")).toBe("one\n")
|
||||||
expect(text(file!.view, "additions")).toBe("two\n")
|
expect(text(file!.view, "additions")).toBe("two\n")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export interface BasicToolProps {
|
||||||
status?: string
|
status?: string
|
||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
forceOpen?: boolean
|
forceOpen?: boolean
|
||||||
defer?: boolean
|
defer?: boolean
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
|
|
@ -83,7 +85,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||||
open: props.defaultOpen ?? false,
|
open: props.defaultOpen ?? false,
|
||||||
ready: !props.defer && (props.defaultOpen ?? false),
|
ready: !props.defer && (props.defaultOpen ?? false),
|
||||||
})
|
})
|
||||||
const open = () => state.open
|
const open = () => props.open ?? state.open
|
||||||
const ready = () => state.ready
|
const ready = () => state.ready
|
||||||
const pending = () => props.status === "pending" || props.status === "running"
|
const pending = () => props.status === "pending" || props.status === "running"
|
||||||
const hasChildren = () => (props.defer ? "children" in props : props.children)
|
const hasChildren = () => (props.defer ? "children" in props : props.children)
|
||||||
|
|
@ -110,8 +112,15 @@ export function BasicTool(props: BasicToolProps) {
|
||||||
if (props.defer && open()) scheduleReady(true)
|
if (props.defer && open()) scheduleReady(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setOpen = (value: boolean) => {
|
||||||
|
if (props.open === undefined) setState("open", value)
|
||||||
|
props.onOpenChange?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.forceOpen) setState("open", true)
|
if (!props.forceOpen) return
|
||||||
|
if (open()) return
|
||||||
|
setOpen(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
|
|
@ -166,7 +175,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||||
const handleOpenChange = (value: boolean) => {
|
const handleOpenChange = (value: boolean) => {
|
||||||
if (pending()) return
|
if (pending()) return
|
||||||
if (props.locked && !value) return
|
if (props.locked && !value) return
|
||||||
setState("open", value)
|
setOpen(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const trigger = () => (
|
const trigger = () => (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||||
import {
|
import {
|
||||||
|
areFilesEqual,
|
||||||
|
areOptionsEqual,
|
||||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||||
type DiffLineAnnotation,
|
type DiffLineAnnotation,
|
||||||
type FileContents,
|
type FileContents,
|
||||||
|
|
@ -88,6 +90,7 @@ type DiffBaseProps<T> = FileDiffOptions<T> &
|
||||||
mode: "diff"
|
mode: "diff"
|
||||||
annotations?: DiffLineAnnotation<T>[]
|
annotations?: DiffLineAnnotation<T>[]
|
||||||
preloadedDiff?: DiffPreload<T>
|
preloadedDiff?: DiffPreload<T>
|
||||||
|
virtualize?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiffPairProps<T> = DiffBaseProps<T> & {
|
type DiffPairProps<T> = DiffBaseProps<T> & {
|
||||||
|
|
@ -123,7 +126,7 @@ const sharedKeys = [
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const textKeys = ["file", ...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
|
// Shared viewer hook
|
||||||
|
|
@ -482,17 +485,24 @@ function notifyRendered(opts: {
|
||||||
function renderViewer<I extends RenderTarget>(opts: {
|
function renderViewer<I extends RenderTarget>(opts: {
|
||||||
viewer: Viewer
|
viewer: Viewer
|
||||||
current: I | undefined
|
current: I | undefined
|
||||||
|
reset?: boolean
|
||||||
create: () => I
|
create: () => I
|
||||||
|
update?: (value: I) => void
|
||||||
assign: (value: I) => void
|
assign: (value: I) => void
|
||||||
draw: (value: I) => void
|
draw: (value: I) => void
|
||||||
onReady: () => void
|
onReady: () => void
|
||||||
}) {
|
}) {
|
||||||
clearReadyWatcher(opts.viewer.ready)
|
clearReadyWatcher(opts.viewer.ready)
|
||||||
opts.current?.cleanUp()
|
const reset = opts.reset === true && opts.current !== undefined
|
||||||
const next = opts.create()
|
if (reset) opts.current?.cleanUp()
|
||||||
opts.assign(next)
|
const next = reset || !opts.current ? opts.create() : opts.current
|
||||||
|
if (reset || !opts.current) {
|
||||||
opts.viewer.container.innerHTML = ""
|
opts.viewer.container.innerHTML = ""
|
||||||
|
opts.assign(next)
|
||||||
|
} else {
|
||||||
|
opts.update?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
opts.draw(next)
|
opts.draw(next)
|
||||||
|
|
||||||
applyViewerScheme(opts.viewer.getHost())
|
applyViewerScheme(opts.viewer.getHost())
|
||||||
|
|
@ -566,7 +576,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
|
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
||||||
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||||
|
|
||||||
const release = () => {
|
const release = () => {
|
||||||
|
|
@ -576,6 +586,10 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): Vi
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: () => {
|
get: () => {
|
||||||
|
if (!enabled()) {
|
||||||
|
release()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (shared) return shared.virtualizer
|
if (shared) return shared.virtualizer
|
||||||
|
|
||||||
const container = host()
|
const container = host()
|
||||||
|
|
@ -858,15 +872,14 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const opts = options()
|
const opts = options()
|
||||||
const workerPool = getWorkerPool("unified")
|
const workerPool = getWorkerPool("unified")
|
||||||
const isVirtual = virtual()
|
|
||||||
|
|
||||||
const virtualizer = virtuals.get()
|
const virtualizer = virtuals.get()
|
||||||
|
|
||||||
renderViewer({
|
renderViewer({
|
||||||
viewer,
|
viewer,
|
||||||
current: instance,
|
current: instance,
|
||||||
|
reset: instance !== undefined,
|
||||||
create: () =>
|
create: () =>
|
||||||
isVirtual && virtualizer
|
virtualizer
|
||||||
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
||||||
: new PierreFile<T>(opts, workerPool),
|
: new PierreFile<T>(opts, workerPool),
|
||||||
assign: (value) => {
|
assign: (value) => {
|
||||||
|
|
@ -907,6 +920,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||||
|
|
||||||
function DiffViewer<T>(props: DiffFileProps<T>) {
|
function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||||
let instance: FileDiff<T> | undefined
|
let instance: FileDiff<T> | undefined
|
||||||
|
let instanceVirtualizer: Virtualizer | undefined
|
||||||
|
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
|
||||||
|
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
|
||||||
|
let instanceFileDiff: FileDiffMetadata | undefined
|
||||||
|
let instanceBefore: FileContents | undefined
|
||||||
|
let instanceAfter: FileContents | undefined
|
||||||
let dragSide: DiffSelectionSide | undefined
|
let dragSide: DiffSelectionSide | undefined
|
||||||
let dragEndSide: DiffSelectionSide | undefined
|
let dragEndSide: DiffSelectionSide | undefined
|
||||||
let viewer!: Viewer
|
let viewer!: Viewer
|
||||||
|
|
@ -991,7 +1010,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||||
adapter,
|
adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
const virtuals = createSharedVirtualStrategy(
|
||||||
|
() => viewer.container,
|
||||||
|
() => local.virtualize !== false,
|
||||||
|
)
|
||||||
|
|
||||||
const large = createMemo(() => {
|
const large = createMemo(() => {
|
||||||
if (local.fileDiff) {
|
if (local.fileDiff) {
|
||||||
|
|
@ -1067,31 +1089,62 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||||
return sampledChecksum(contents)
|
return sampledChecksum(contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const before = local.before ? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) } : undefined
|
||||||
|
const after = local.after ? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) } : undefined
|
||||||
|
const targetChanged =
|
||||||
|
local.fileDiff !== undefined
|
||||||
|
? instanceFileDiff !== local.fileDiff
|
||||||
|
: instanceFileDiff !== undefined ||
|
||||||
|
before === undefined ||
|
||||||
|
after === undefined ||
|
||||||
|
instanceBefore === undefined ||
|
||||||
|
instanceAfter === undefined ||
|
||||||
|
!areFilesEqual(instanceBefore, before) ||
|
||||||
|
!areFilesEqual(instanceAfter, after)
|
||||||
|
// Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
|
||||||
|
// Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
|
||||||
|
const reset =
|
||||||
|
instance !== undefined &&
|
||||||
|
(instanceVirtualizer !== virtualizer ||
|
||||||
|
instanceWorkerPool !== workerPool ||
|
||||||
|
(virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
|
||||||
|
const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
|
||||||
|
|
||||||
renderViewer({
|
renderViewer({
|
||||||
viewer,
|
viewer,
|
||||||
current: instance,
|
current: instance,
|
||||||
|
reset,
|
||||||
create: () =>
|
create: () =>
|
||||||
virtualizer
|
virtualizer
|
||||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||||
: new FileDiff<T>(opts, workerPool),
|
: new FileDiff<T>(opts, workerPool),
|
||||||
|
update: (value) => value.setOptions(opts),
|
||||||
assign: (value) => {
|
assign: (value) => {
|
||||||
instance = value
|
instance = value
|
||||||
|
instanceVirtualizer = virtualizer
|
||||||
|
instanceWorkerPool = workerPool
|
||||||
|
instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
|
||||||
|
instanceFileDiff = local.fileDiff
|
||||||
|
instanceBefore = before
|
||||||
|
instanceAfter = after
|
||||||
},
|
},
|
||||||
draw: (value) => {
|
draw: (value) => {
|
||||||
if (local.fileDiff) {
|
if (local.fileDiff) {
|
||||||
value.render({
|
value.render({
|
||||||
fileDiff: local.fileDiff,
|
fileDiff: local.fileDiff,
|
||||||
|
forceRender,
|
||||||
lineAnnotations: [],
|
lineAnnotations: [],
|
||||||
containerWrapper: viewer.container,
|
containerWrapper: viewer.container,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!local.before || !local.after) return
|
if (!before || !after) return
|
||||||
|
|
||||||
value.render({
|
value.render({
|
||||||
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
|
oldFile: before,
|
||||||
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
|
newFile: after,
|
||||||
|
forceRender,
|
||||||
lineAnnotations: [],
|
lineAnnotations: [],
|
||||||
containerWrapper: viewer.container,
|
containerWrapper: viewer.container,
|
||||||
})
|
})
|
||||||
|
|
@ -1111,6 +1164,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
instance?.cleanUp()
|
instance?.cleanUp()
|
||||||
instance = undefined
|
instance = undefined
|
||||||
|
instanceVirtualizer = undefined
|
||||||
|
instanceWorkerPool = undefined
|
||||||
|
instanceVirtualHunkSeparators = undefined
|
||||||
|
instanceFileDiff = undefined
|
||||||
|
instanceBefore = undefined
|
||||||
|
instanceAfter = undefined
|
||||||
virtuals.cleanup()
|
virtuals.cleanup()
|
||||||
dragSide = undefined
|
dragSide = undefined
|
||||||
dragEndSide = undefined
|
dragEndSide = undefined
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,10 @@ export interface MessagePartProps {
|
||||||
message: MessageType
|
message: MessageType
|
||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
|
toolOpen?: boolean
|
||||||
|
onToolOpenChange?: (open: boolean) => void
|
||||||
deferToolContent?: boolean
|
deferToolContent?: boolean
|
||||||
|
virtualizeDiff?: boolean
|
||||||
showAssistantCopyPartID?: string | null
|
showAssistantCopyPartID?: string | null
|
||||||
turnDurationMs?: number
|
turnDurationMs?: number
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +293,7 @@ function getDirectory(path: string | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { IconProps } from "./icon"
|
import type { IconProps } from "./icon"
|
||||||
import { normalize } from "./session-diff"
|
import { normalize, resolveFileDiff } from "./session-diff"
|
||||||
|
|
||||||
export type ToolInfo = {
|
export type ToolInfo = {
|
||||||
icon: IconProps["name"]
|
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 i18n = useI18n()
|
||||||
const [open, setOpen] = createSignal(false)
|
const [open, setOpen] = createSignal(false)
|
||||||
const pending = createMemo(
|
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"),
|
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
|
||||||
)
|
)
|
||||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
setOpen(value)
|
||||||
|
props.onSizeChange?.()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={open()}
|
open={open()}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={handleOpenChange}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="tool-collapsible"
|
class="tool-collapsible"
|
||||||
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
|
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
|
||||||
|
|
@ -1261,7 +1268,10 @@ export function Part(props: MessagePartProps) {
|
||||||
message={props.message}
|
message={props.message}
|
||||||
hideDetails={props.hideDetails}
|
hideDetails={props.hideDetails}
|
||||||
defaultOpen={props.defaultOpen}
|
defaultOpen={props.defaultOpen}
|
||||||
|
toolOpen={props.toolOpen}
|
||||||
|
onToolOpenChange={props.onToolOpenChange}
|
||||||
deferToolContent={props.deferToolContent}
|
deferToolContent={props.deferToolContent}
|
||||||
|
virtualizeDiff={props.virtualizeDiff}
|
||||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||||
turnDurationMs={props.turnDurationMs}
|
turnDurationMs={props.turnDurationMs}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1278,7 +1288,10 @@ export interface ToolProps {
|
||||||
status?: string
|
status?: string
|
||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
deferContent?: boolean
|
deferContent?: boolean
|
||||||
|
virtualizeDiff?: boolean
|
||||||
forceOpen?: boolean
|
forceOpen?: boolean
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -1376,6 +1389,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
|
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 (
|
return (
|
||||||
<Show when={!hideQuestion()}>
|
<Show when={!hideQuestion()}>
|
||||||
|
|
@ -1399,6 +1414,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||||
error={error()}
|
error={error()}
|
||||||
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
|
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
|
||||||
defaultOpen={props.defaultOpen}
|
defaultOpen={props.defaultOpen}
|
||||||
|
open={controlledOpen()}
|
||||||
|
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||||
subtitle={taskSubtitle()}
|
subtitle={taskSubtitle()}
|
||||||
href={taskHref()}
|
href={taskHref()}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1417,7 +1434,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||||
status={part().state.status}
|
status={part().state.status}
|
||||||
hideDetails={props.hideDetails}
|
hideDetails={props.hideDetails}
|
||||||
defaultOpen={props.defaultOpen}
|
defaultOpen={props.defaultOpen}
|
||||||
|
open={controlledOpen()}
|
||||||
|
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||||
deferContent={props.deferToolContent}
|
deferContent={props.deferToolContent}
|
||||||
|
virtualizeDiff={props.virtualizeDiff}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
@ -1921,15 +1941,29 @@ ToolRegistry.register({
|
||||||
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
|
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
|
||||||
const filename = () => getFilename(props.input.filePath ?? "")
|
const filename = () => getFilename(props.input.filePath ?? "")
|
||||||
const pending = () => props.status === "pending" || props.status === "running"
|
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(() => {
|
const fileCompProps = createMemo(() => {
|
||||||
try {
|
try {
|
||||||
if (props.metadata?.filediff) {
|
const source = diffSource()
|
||||||
const diff = normalize({
|
if (source) {
|
||||||
...props.metadata?.filediff,
|
const fileDiff = resolveFileDiff(source)
|
||||||
status: "modified",
|
|
||||||
})
|
|
||||||
const fileDiff = diff.fileDiff
|
|
||||||
if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
|
if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -1987,7 +2021,7 @@ ToolRegistry.register({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div data-component="edit-content">
|
<div data-component="edit-content">
|
||||||
<Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
|
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
|
||||||
</div>
|
</div>
|
||||||
</ToolFileAccordion>
|
</ToolFileAccordion>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2171,6 +2205,7 @@ ToolRegistry.register({
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={fileComponent}
|
component={fileComponent}
|
||||||
mode="diff"
|
mode="diff"
|
||||||
|
virtualize={props.virtualizeDiff}
|
||||||
fileDiff={file.view.fileDiff}
|
fileDiff={file.view.fileDiff}
|
||||||
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||||
/>
|
/>
|
||||||
|
|
@ -2243,7 +2278,7 @@ ToolRegistry.register({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div data-component="apply-patch-file-diff">
|
<div data-component="apply-patch-file-diff">
|
||||||
<Dynamic component={fileComponent} mode="diff" fileDiff={single()!.view.fileDiff} />
|
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} />
|
||||||
</div>
|
</div>
|
||||||
</ToolFileAccordion>
|
</ToolFileAccordion>
|
||||||
</BasicTool>
|
</BasicTool>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { normalize, text } from "./session-diff"
|
import { normalize, resolveFileDiff, text } from "./session-diff"
|
||||||
|
|
||||||
describe("session diff", () => {
|
describe("session diff", () => {
|
||||||
test("keeps unified patch content", () => {
|
test("keeps unified patch content", () => {
|
||||||
|
|
@ -13,8 +13,8 @@ describe("session diff", () => {
|
||||||
}
|
}
|
||||||
const view = normalize(diff)
|
const view = normalize(diff)
|
||||||
|
|
||||||
expect(view.patch).toBe(diff.patch)
|
|
||||||
expect(view.fileDiff.name).toBe("a.ts")
|
expect(view.fileDiff.name).toBe("a.ts")
|
||||||
|
expect(view.fileDiff.isPartial).toBe(true)
|
||||||
expect(text(view, "deletions")).toBe("one\ntwo\n")
|
expect(text(view, "deletions")).toBe("one\ntwo\n")
|
||||||
expect(text(view, "additions")).toBe("one\nthree\n")
|
expect(text(view, "additions")).toBe("one\nthree\n")
|
||||||
})
|
})
|
||||||
|
|
@ -34,7 +34,52 @@ describe("session diff", () => {
|
||||||
expect(text(view, "additions")).toBe("one\nthree")
|
expect(text(view, "additions")).toBe("one\nthree")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("converts legacy content into a patch", () => {
|
test("keeps separated patch hunks partial without complete file contents", () => {
|
||||||
|
const fileDiff = resolveFileDiff({
|
||||||
|
file: "project.ts",
|
||||||
|
patch:
|
||||||
|
'Index: project.ts\n===================================================================\n--- project.ts\t\n+++ project.ts\t\n@@ -1,3 +1,2 @@\n import { and } from "drizzle-orm"\n-import { sql } from "drizzle-orm"\n import { ProjectTable } from "./project.sql"\n@@ -346,3 +345,3 @@\n import { Database } from "@/storage/db"\n-import { ProjectTable } from "./project.sql"\n+import { ProjectTable } from "../project/project.sql"\n import { SessionTable } from "../session/session.sql"\n',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fileDiff.isPartial).toBe(true)
|
||||||
|
expect(fileDiff.hunks).toHaveLength(2)
|
||||||
|
expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders headerless persisted patches", () => {
|
||||||
|
const view = normalize({
|
||||||
|
file: "a.ts",
|
||||||
|
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified" as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.fileDiff.name).toBe("a.ts")
|
||||||
|
expect(view.fileDiff.isPartial).toBe(true)
|
||||||
|
expect(text(view, "deletions")).toBe("old\n")
|
||||||
|
expect(text(view, "additions")).toBe("new\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not share headerless patch metadata between files", () => {
|
||||||
|
const patch = "@@ -1 +1 @@\n-old\n+new\n"
|
||||||
|
|
||||||
|
expect(resolveFileDiff({ file: "a.ts", patch }).name).toBe("a.ts")
|
||||||
|
expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps capped header-only patches partial", () => {
|
||||||
|
const fileDiff = resolveFileDiff({
|
||||||
|
file: "a.ts",
|
||||||
|
patch: "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fileDiff.name).toBe("a.ts")
|
||||||
|
expect(fileDiff.isPartial).toBe(true)
|
||||||
|
expect(fileDiff.hunks).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps full legacy content as a complete diff", () => {
|
||||||
const diff = {
|
const diff = {
|
||||||
file: "a.ts",
|
file: "a.ts",
|
||||||
before: "one\n",
|
before: "one\n",
|
||||||
|
|
@ -45,7 +90,7 @@ describe("session diff", () => {
|
||||||
}
|
}
|
||||||
const view = normalize(diff)
|
const view = normalize(diff)
|
||||||
|
|
||||||
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
|
expect(view.fileDiff.isPartial).toBe(false)
|
||||||
expect(text(view, "deletions")).toBe("one\n")
|
expect(text(view, "deletions")).toBe("one\n")
|
||||||
expect(text(view, "additions")).toBe("two\n")
|
expect(text(view, "additions")).toBe("two\n")
|
||||||
})
|
})
|
||||||
|
|
@ -61,7 +106,6 @@ describe("session diff", () => {
|
||||||
}
|
}
|
||||||
const view = normalize(diff)
|
const view = normalize(diff)
|
||||||
|
|
||||||
expect(view.patch).toBe(diff.patch)
|
|
||||||
expect(text(view, "deletions")).toBe("")
|
expect(text(view, "deletions")).toBe("")
|
||||||
expect(text(view, "additions")).toBe("")
|
expect(text(view, "additions")).toBe("")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
|
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
|
||||||
import { formatPatch, parsePatch, structuredPatch } from "diff"
|
import { parsePatch } from "diff"
|
||||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
type LegacyDiff = {
|
type LegacyDiff = {
|
||||||
|
|
@ -14,107 +14,35 @@ type LegacyDiff = {
|
||||||
|
|
||||||
type SnapshotDiff = SnapshotFileDiff & { file: string }
|
type SnapshotDiff = SnapshotFileDiff & { file: string }
|
||||||
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
|
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
|
||||||
|
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
|
||||||
|
|
||||||
export type ViewDiff = {
|
export type ViewDiff = {
|
||||||
file: string
|
file: string
|
||||||
patch: string
|
|
||||||
additions: number
|
additions: number
|
||||||
deletions: number
|
deletions: number
|
||||||
status?: "added" | "deleted" | "modified"
|
status?: "added" | "deleted" | "modified"
|
||||||
fileDiff: FileDiffMetadata
|
fileDiff: FileDiffMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = new Map<string, FileDiffMetadata>()
|
const diffCacheLimit = 16
|
||||||
|
const patchFileDiffCache = new Map<string, FileDiffMetadata>()
|
||||||
|
|
||||||
function patch(diff: ReviewDiff) {
|
export function resolveFileDiff(diff: DiffSource) {
|
||||||
if (typeof diff.patch === "string") {
|
if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
|
||||||
try {
|
return fileDiffFromContent(
|
||||||
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,
|
||||||
diff.file,
|
typeof diff.before === "string" ? diff.before : "",
|
||||||
"before" in diff && typeof diff.before === "string" ? diff.before : "",
|
typeof diff.after === "string" ? diff.after : "",
|
||||||
"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)
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalize(diff: ReviewDiff): ViewDiff {
|
export function normalize(diff: ReviewDiff): ViewDiff {
|
||||||
const next = patch(diff)
|
|
||||||
const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial)
|
|
||||||
return {
|
return {
|
||||||
file: diff.file,
|
file: diff.file,
|
||||||
patch: next.patch,
|
|
||||||
additions: diff.additions,
|
additions: diff.additions,
|
||||||
deletions: diff.deletions,
|
deletions: diff.deletions,
|
||||||
status: diff.status,
|
status: diff.status,
|
||||||
fileDiff,
|
fileDiff: resolveFileDiff(diff),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,3 +50,40 @@ export function text(diff: ViewDiff, side: "deletions" | "additions") {
|
||||||
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
|
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
|
||||||
return diff.fileDiff.additionLines.join("")
|
return diff.fileDiff.additionLines.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileDiffFromPatch(file: string, patch: string) {
|
||||||
|
const key = `${file}\0${patch}`
|
||||||
|
const hit = patchFileDiffCache.get(key)
|
||||||
|
if (hit) {
|
||||||
|
patchFileDiffCache.delete(key)
|
||||||
|
patchFileDiffCache.set(key, hit)
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = patchInput(file, patch)
|
||||||
|
const value = (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file)
|
||||||
|
patchFileDiffCache.set(key, value)
|
||||||
|
while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchInput(file: string, patch: string) {
|
||||||
|
try {
|
||||||
|
const parsed = parsePatch(patch)[0]
|
||||||
|
if (!parsed) return
|
||||||
|
if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
|
||||||
|
if (!parsed.hunks.length) return
|
||||||
|
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDiffFromContent(file: string, before: string, after: string) {
|
||||||
|
if (!before && !after) return emptyFileDiff(file)
|
||||||
|
return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyFileDiff(file: string) {
|
||||||
|
return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
|
||||||
error: string
|
error: string
|
||||||
title?: string
|
title?: string
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
href?: string
|
href?: string
|
||||||
}
|
}
|
||||||
|
|
@ -22,9 +24,22 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||||
open: props.defaultOpen ?? false,
|
open: props.defaultOpen ?? false,
|
||||||
copied: false,
|
copied: false,
|
||||||
})
|
})
|
||||||
const open = () => state.open
|
const open = () => props.open ?? state.open
|
||||||
const copied = () => state.copied
|
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(() => {
|
const name = createMemo(() => {
|
||||||
if (split.title) return split.title
|
if (split.title) return split.title
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
|
@ -81,7 +96,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||||
class="tool-collapsible"
|
class="tool-collapsible"
|
||||||
data-open={open() ? "true" : "false"}
|
data-open={open() ? "true" : "false"}
|
||||||
open={open()}
|
open={open()}
|
||||||
onOpenChange={(value) => setState("open", value)}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div data-component="tool-trigger">
|
<div data-component="tool-trigger">
|
||||||
|
|
|
||||||
93
patches/virtua@0.49.1.patch
Normal file
93
patches/virtua@0.49.1.patch
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue