Apply PR #28422: fix(app): stabilize virtual session timeline interactions

This commit is contained in:
opencode-agent[bot] 2026-05-22 09:47:48 +00:00
commit c07535370f
13 changed files with 1146 additions and 164 deletions

View file

@ -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:",

View file

@ -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"
}
}

View file

@ -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, "")
}

View file

@ -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, "")
}

View file

@ -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

View file

@ -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 (
<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>
)
}
@ -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<string, number>()
@ -427,6 +438,14 @@ export function MessageTimeline(props: {
})
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 id = activeMessageID()
if (!id) return
@ -540,6 +559,7 @@ export function MessageTimeline(props: {
const [bar, setBar] = createStore({
ms: pace(640),
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
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<TimelineRowMap["AssistantPart"]>) => {
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 <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 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 (
<Show when={message()}>
@ -1012,14 +1048,13 @@ export function MessageTimeline(props: {
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(
part(),
settings.general.shellToolPartsExpanded(),
settings.general.editToolPartsExpanded(),
)}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false}
virtualizeDiff={false}
/>
)}
</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 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 (
<div
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row.userMessageID}
data-timeline-row={input.row._tag}
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
data-message-id={input.row().userMessageID}
data-timeline-row={input.row()._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
@ -1062,14 +1097,15 @@ export function MessageTimeline(props: {
)
}
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
switch (row._tag) {
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
switch (row()._tag) {
case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() =>
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
)
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={commentStripRow}>
<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="flex w-max min-w-full justify-end gap-2">
@ -1102,17 +1138,18 @@ export function MessageTimeline(props: {
)
}
case "UserMessage": {
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(row.userMessageID)
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
{(message) => (
<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">
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
<Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
</div>
</div>
)}
@ -1121,13 +1158,14 @@ export function MessageTimeline(props: {
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
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-compaction">
<MessageDivider
label={language.t(
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
</div>
@ -1136,22 +1174,24 @@ export function MessageTimeline(props: {
)
}
case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
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-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
{renderAssistantPartGroup(row)}
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
{renderAssistantPartGroup(assistantPartRow)}
</div>
</div>
</TimelineRowFrame>
)
}
case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={thinkingRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={row.reasoningHeading}
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
@ -1159,29 +1199,32 @@ export function MessageTimeline(props: {
)
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={retryRow}>
<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>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={diffSummaryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={row.diffs} />
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
</div>
</TimelineRowFrame>
)
}
case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={errorRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{row.text}
{errorRow().text}
</Card>
</div>
</TimelineRowFrame>
@ -1192,12 +1235,8 @@ export function MessageTimeline(props: {
}
}
function TimelineRowView(props: { rowKey: string }) {
return (
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
{(item) => renderTimelineRow(item)}
</Show>
)
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
return renderTimelineRow(() => props.row)
}
return (
@ -1529,7 +1568,7 @@ export function MessageTimeline(props: {
<Show when={scrollRoot()}>
{(root) => (
<Virtualizer
data={timelineRowKeys()}
data={timelineRows()}
cache={virtualCache()}
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
scrollRef={root()}
@ -1549,7 +1588,7 @@ export function MessageTimeline(props: {
scheduleContentRoot(root())
}}
>
{(key) => <TimelineRowView rowKey={key} />}
{(row) => <TimelineRowView row={row} />}
</Virtualizer>
)}
</Show>

View file

@ -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 = () => (

View file

@ -88,6 +88,7 @@ type DiffBaseProps<T> = FileDiffOptions<T> &
mode: "diff"
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: DiffPreload<T>
virtualize?: boolean
}
type DiffPairProps<T> = DiffBaseProps<T> & {
@ -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<I extends RenderTarget>(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<ReturnType<typeof acquireVirtualizer>> | 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<T>(props: TextFileProps<T>) {
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
let renderMode: Virtualizer | "plain" | undefined
let viewer!: Viewer
const [local, others] = splitProps(props, textKeys)
@ -861,16 +874,20 @@ function TextViewer<T>(props: TextFileProps<T>) {
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<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
update: (value) => value.setOptions(opts),
assign: (value) => {
instance = value
renderMode = nextRenderMode
},
draw: (value) => {
const contents = text()
@ -895,6 +912,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
renderMode = undefined
virtuals.cleanup()
})
@ -907,6 +925,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
function DiffViewer<T>(props: DiffFileProps<T>) {
let instance: FileDiff<T> | undefined
let renderMode: Virtualizer | "plain" | undefined
let dragSide: DiffSelectionSide | undefined
let dragEndSide: DiffSelectionSide | undefined
let viewer!: Viewer
@ -991,7 +1010,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
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<T>(props: DiffFileProps<T>) {
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<T>(props: DiffFileProps<T>) {
renderViewer({
viewer,
current: instance,
reset: renderMode !== undefined && renderMode !== nextRenderMode,
create: () =>
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
update: (value) => value.setOptions(opts),
assign: (value) => {
instance = value
renderMode = nextRenderMode
},
draw: (value) => {
if (local.fileDiff) {
@ -1111,6 +1137,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
renderMode = undefined
virtuals.cleanup()
dragSide = undefined
dragEndSide = undefined

View file

@ -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 (
<Collapsible
open={open()}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.parts.map((part) => 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 (
<Show when={!hideQuestion()}>
@ -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}
/>
</Match>
</Switch>
@ -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({
}
>
<div data-component="edit-content">
<Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
</div>
</ToolFileAccordion>
</Show>
@ -2171,6 +2205,7 @@ ToolRegistry.register({
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={file.view.fileDiff}
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
/>
@ -2243,7 +2278,7 @@ ToolRegistry.register({
}
>
<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>
</ToolFileAccordion>
</BasicTool>

View file

@ -14,6 +14,14 @@ type LegacyDiff = {
type SnapshotDiff = SnapshotFileDiff & { file: string }
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
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<string, FileDiffMetadata>()
const diffCacheLimit = 16
const patchFileDiffCache = new Map<string, FileDiffMetadata>()
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<K, V>(cache: Map<K, V>, key: K) {
const value = cache.get(key)
if (value === undefined) return
cache.delete(key)
cache.set(key, value)
return value
}
function setMapCache<K, V>(cache: Map<K, V>, 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),
}
}

View file

@ -183,7 +183,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const open = () => props.open ?? store.open
const items = createMemo<Item[]>(() =>
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(() => {

View file

@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "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<string, string> = {
@ -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}
>
<Collapsible.Trigger>
<div data-component="tool-trigger">

View 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;
}