fix(app): reduce cold and warm session load work (#48223)

This commit is contained in:
Luke Parker
2026-09-10 09:04:50 +10:00
committed by GitHub
parent 08ac1e168c
commit 0f67a15f3b
32 changed files with 1570 additions and 249 deletions
@@ -0,0 +1,27 @@
# Session-export load benchmark
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
From `packages/app` in PowerShell:
```powershell
$env:PLAYWRIGHT_BUILD = '1'
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
```
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
@@ -0,0 +1,227 @@
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { stressSessionHref } from "./timeline-test-helpers"
import { startChromeTrace } from "../chrome-trace"
const file = process.env.LAGGY_SESSION_FILE
const session = file
? (JSON.parse(readFileSync(file, "utf8")) as {
info: {
id: string
projectID: string
title: string
model?: { id: string; providerID: string }
location: { directory: string }
time: { created: number; updated: number }
}
messages: SessionMessageInfo[]
})
: undefined
const sourceID = "ses_laggy_benchmark_source"
const sourceMessageID = "msg_laggy_benchmark_source"
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
const viewport = { width: 1440, height: 900 }
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
for (const mode of ["compact", "ungrouped"] as const) {
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
if (!session) return
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
const lastID = session.messages.findLast((message) => message.type === "user")!.id
const lastText = session.messages.findLast(
(message) =>
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
)!
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
const requests: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
if (process.env.LAGGY_HTTP === "1")
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
})
const detail = Object.fromEntries(
timelineCategories.map((category) => [
category,
{
...timelinePresets[2].value[category],
placement: mode === "compact" ? "grouped" : "separate",
},
]),
)
const directory = session.info.location.directory
if (process.env.LAGGY_HTTP !== "1")
await mockOpenCodeServer(page, {
directory,
project: {
id: session.info.projectID,
worktree: directory,
vcs: "git",
name: "session-benchmark",
time: session.info.time,
sandboxes: [],
},
provider: {
all: [
{
id: model.providerID,
name: model.providerID,
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
},
],
connected: [model.providerID],
default: { providerID: model.providerID, modelID: model.id },
},
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
pageMessages: (id, limit, before) => {
if (id !== session.info.id)
return {
items: [
{
id: sourceMessageID,
type: "user",
text: "Benchmark source",
time: { created: session.info.time.created },
},
],
}
if (history === "full") return { items: session.messages }
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
const start = Math.max(0, end - limit)
return {
items: session.messages.slice(start, end),
cursor: start > 0 ? session.messages[start].id : undefined,
}
},
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await page.addInitScript(
({ detail, directory, server, sessionIDs, dirBase64 }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{
detail,
directory,
server: process.env.PLAYWRIGHT_BASE_URL!,
sessionIDs: [sourceID, session.info.id],
dirBase64: base64Encode(directory),
},
)
await page.goto(stressSessionHref(sourceID))
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
expect(requests).toEqual([sourceID])
const startedAt = new Date().toISOString()
const samples = []
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
for (const [iteration, phase] of phases.entries()) {
const before = requests.length
const stopTrace =
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
? await startChromeTrace(page, `laggy-${history}-${mode}`)
: undefined
const result = await measureSessionSwitch(page, {
destinationIDs: session.messages.map((message) => message.id),
sourceIDs: [sourceMessageID],
lastID,
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
requireBottomAnchor: true,
href: stressSessionHref(session.info.id),
switch: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
},
})
await stopTrace?.()
await expectSessionTitle(page, session.info.title)
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
await expect(
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
).toHaveCount(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
if (phase === "warm") expect(requests.length - before).toBe(0)
samples.push({
iteration,
phase,
messageRequests: requests.length - before,
messageResources: await page.evaluate((sessionID) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter(
(entry) =>
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
)
.map((entry) => ({
limit: Number(new URL(entry.name).searchParams.get("limit")),
startMs: entry.startTime - start,
durationMs: entry.duration,
transferBytes: entry.transferSize,
}))
}, session.info.id),
...result,
})
if (iteration === phases.length - 1) {
mkdirSync(output, { recursive: true })
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
break
}
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
await expectSessionTitle(page, "Benchmark source")
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
}
expect(errors).toEqual([])
const result = {
pair: testInfo.repeatEachIndex,
startedAt,
mode,
history,
file,
messages: session.messages.length,
viewport,
browser: page.context().browser()!.version(),
detail,
samples,
}
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
report(
{ samples },
{
mode,
sampling: "cold/warm pair in one browser context",
pair: testInfo.repeatEachIndex,
messages: session.messages.length,
viewport,
data: history === "full" ? "full exported history" : "paginated exported history",
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
},
)
})
}
@@ -0,0 +1,63 @@
export {}
type Pair = {
mode: "compact" | "ungrouped"
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
}
const directory = Bun.argv[2]
if (!directory) throw new Error("Pass the directory containing session-load pairs")
const pairs = await Promise.all(
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
const cold = pair.samples.find((sample) => sample.phase === "cold")
const warm = pair.samples.find((sample) => sample.phase === "warm")
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
throw new Error(`Expected a completed cold/warm pair in ${file}`)
return {
mode: pair.mode,
cold: cold.firstCorrectObservedMs,
warm: warm.firstCorrectObservedMs,
requests: warm.messageRequests,
}
}),
)
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
const result = ["compact", "ungrouped"].flatMap((mode) => {
const selected = pairs.filter((pair) => pair.mode === mode)
if (!selected.length) return []
return [
{
mode,
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
},
]
})
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
console.table(
result.map((row) => ({
mode: row.mode,
pairs: row.cold.n,
coldMedianMs: Math.round(row.cold.median * 10) / 10,
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
warmMedianMs: Math.round(row.warm.median * 10) / 10,
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
warmMaxMs: Math.round(row.warm.max * 10) / 10,
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
})),
)
function stats(values: number[]) {
const sorted = values.toSorted((left, right) => left - right)
return {
n: sorted.length,
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
min: sorted[0],
max: sorted.at(-1)!,
}
}
@@ -16,6 +16,11 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
.toBe(0)
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
@@ -0,0 +1,315 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
})
test("focuses Find in the selected cached timeline", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
pageMessages: (id) => ({
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
const search = page.locator('[data-component="timeline-search-bar"] input')
await expect(search).toBeFocused()
await page.keyboard.type("History")
await expect(search).toHaveValue("History")
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.press("Escape")
await expect(search).toHaveCount(0)
})
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
const destination = "C:/OpenCode/OtherProject"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const reads: string[] = []
const output = { text: "Initial shell output\n" }
await mockOpenCodeServer(page, {
...fixture,
sessions: fixture.sessions.map((session) =>
session.id === fixture.targetID ? { ...session, directory: destination } : session,
),
pageMessages: (id) => ({
items:
id === fixture.sourceID
? ([
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
{
id: "msg_workspace_shell",
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2 },
content: [
{
type: "tool",
id: "call_workspace_shell",
name: "shell",
time: { created: 2 },
state: {
status: "running",
input: { command: "run checks" },
metadata: { shellID: "sh_workspace_source" },
},
},
],
},
] satisfies SessionMessageInfo[])
: [],
}),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")!
reads.push(directory)
if (directory !== fixture.directory)
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
return route.fulfill({
json: {
location: { directory },
data: {
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
cursor: output.text.length,
size: output.text.length,
truncated: false,
},
},
})
})
await installStressSessionTabs(page)
await page.addInitScript(
(detail) =>
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
},
}),
),
timelinePresets[2].value,
)
await page.goto(stressSessionHref(fixture.sourceID))
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
output.text += "Output after returning\n"
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
expect(reads.length).toBeGreaterThan(1)
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
} finally {
release.resolve()
}
})
test("loads the transcript code font before opening rich history", async ({ page }) => {
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
}),
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
try {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
release.resolve()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
} finally {
release.resolve()
}
})
for (const grouped of [true, false]) {
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
page,
}) => {
const events: OpenCodeEvent[] = []
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((id) => [
id,
[
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [
{
type: "tool",
id: `tool_${id}`,
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: `echo ${id}` },
metadata: {},
content: [{ type: "text", text: `Output for ${id}` }],
},
},
{ type: "text", text: `Answer for ${id}` },
],
},
] satisfies SessionMessageInfo[],
]),
)
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (id) => ({ items: messages[id] ?? [] }),
events: () => events.splice(0),
})
await installStressSessionTabs(page)
await page.addInitScript(
({ grouped, detail }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
timelineDetail: {
...detail,
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
},
},
}),
)
},
{ grouped, detail: timelinePresets[2].value },
)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
if (grouped)
await page
.locator(
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
)
.click()
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
await expect(shell).toHaveCount(0)
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
events.push({
id: "evt_cached_text",
created: 4,
type: "session.text.ended",
location: { directory: fixture.directory },
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
data: {
sessionID: fixture.sourceID,
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
ordinal: 0,
text: "Updated while inactive",
},
})
await page.setViewportSize({ width: 900, height: 650 })
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
await expect
.poll(() =>
page
.locator("[data-timeline-key]")
.evaluateAll((rows) =>
rows.every(
(row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
),
),
)
.toBe(true)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 20
const messagePageSize = 40
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
])
expect(roots).toEqual([])
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
for (const window of ["assistant-only", "mixed"] as const) {
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
// Both 20-message pages begin with an assistant; only page three supplies its parent.
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
const id = `msg_hydration_${index}`
const time = { created: 1700000000000 + index * 1_000 }
if (index === 0 || (window === "mixed" && index === 39))
if (index === 0 || (window === "mixed" && index === 59))
return { id, type: "user", time, text: `Prompt ${index}` }
return {
id,
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
time: { ...time, completed: time.created + 500 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
}
})
const gates = [21, 1].map((index) => ({
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
await gate.release.promise
},
pageMessages: (_, limit, before) => {
expect(limit).toBe(20)
expect(limit).toBe(before ? 20 : 40)
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
})
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
const markdown = tail.locator('[data-component="markdown"]')
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
const viewport = page.locator(".scroll-view__viewport", { has: tail })
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
})
const expectReadyTail = async () => {
await expect(content).toHaveCSS("visibility", "visible")
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
if (window === "mixed")
await expect(
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
).toBeInViewport()
const original = await markdown.elementHandle()
+6
View File
@@ -80,6 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const id = state.connections
let ended = false
let own: ReadableStreamDefaultController<Uint8Array> | undefined
let keepalive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
own = controller
@@ -89,11 +90,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
// Match the real server's idle stream so long scenarios do not
// trigger the client's 45-second stall watchdog and reload history.
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
request.signal.addEventListener(
"abort",
() => {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === controller) state.controller = undefined
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
},
@@ -103,6 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
cancel() {
if (ended) return
ended = true
clearInterval(keepalive)
if (state.controller === own) state.controller = undefined
},
})
@@ -34,6 +34,27 @@ function setup(input?: {
}
describe("createRequestQueue", () => {
test("starts a free slot before the caller continues its synchronous work", async () => {
const input = setup()
const response = input.queue.fetch("http://server/api/session")
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/session"])
expect(input.queue.inflight()).toBe(1)
input.pending[0]!.resolve()
await response
expect(input.queue.inflight()).toBe(0)
})
test("releases a free slot without sending an already-aborted request", async () => {
const input = setup()
const controller = new AbortController()
controller.abort()
await expect(input.queue.fetch("http://server/api/session", { signal: controller.signal })).rejects.toBeInstanceOf(
DOMException,
)
expect(input.pending).toHaveLength(0)
expect(input.queue.inflight()).toBe(0)
})
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
@@ -109,12 +130,10 @@ describe("createRequestQueue", () => {
const input = setup({ limit: 1, headersTimeoutMs: 10 })
const dead = input.queue.fetch("http://server/api/dead")
const next = input.queue.fetch("http://server/api/next")
await input.settle()
expect(input.queue.queued()).toBe(1)
const error = await dead.catch((cause: unknown) => cause)
expect(error).toBeInstanceOf(DOMException)
expect((error as DOMException).name).toBe("TimeoutError")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/dead", "/api/next"])
input.pending[1]!.resolve()
await expect(next).resolves.toBeInstanceOf(Response)
@@ -84,17 +84,23 @@ export function createRequestQueue(input: {
if (index === -1) return
waiting.splice(index, 1)[0]?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const acquire = (entry: Entry) => {
// A free slot must start fetch before the caller's synchronous UI work.
// Awaiting an already-resolved promise postpones that dispatch until after it.
if (canStart(entry)) {
inflight.add(entry)
return
}
return new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (canStart(entry)) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
}
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
@@ -103,7 +109,8 @@ export function createRequestQueue(input: {
// The event stream is long-lived; never count it against the request budget.
if (pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now(), slow: isSlowRequest(pathname) }
await acquire(entry)
const queued = acquire(entry)
if (queued) await queued
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
@@ -16,6 +16,8 @@ import { ModelState } from "./persistence"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -131,10 +133,12 @@ function createServerController(
projects: ReturnType<typeof createServerProjects>,
) {
const language = useLanguage()
const settings = useSettings()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
api: () => sdk.api,
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
+41 -31
View File
@@ -34,6 +34,7 @@ import { SessionIdentityHeader } from "./session-identity-header"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -219,6 +220,45 @@ export function SessionScreen(props: { session: SessionModel }) {
</Show>
)
const timelineView = createTimelineCache(
session,
(source, active) => (
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
session={source}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
<TimelineSearchBar controller={timelineSearch} />
</Show>
}
/>
),
() => conversationVisible() && messagesReady(),
)
const sessionPanelContent = () => (
<>
<ComposerDropzone
@@ -258,37 +298,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={isDesktop() && !messagesReady()}>
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
</Show>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
hideHeader={!isDesktop()}
session={session}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
search={<TimelineSearchBar controller={timelineSearch} />}
/>
)}
</Show>
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
</Match>
</Switch>
</div>
@@ -0,0 +1,58 @@
import {
createComputed,
createMemo,
createRoot,
getOwner,
on,
onCleanup,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import type { TimelineSessionSource } from "./controller"
export function createTimelineCache(
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
visible: Accessor<boolean>,
) {
const owner = getOwner()
const cache = createScopedCache(
(key) =>
createRoot((dispose) => {
const id = untrack(session.identity.sessionID)
const sessionKey = untrack(session.identity.sessionKey)
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
// A detached view retains its own inputs until it is selected again.
const select = <T>(read: Accessor<T>) =>
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
return {
value: render(
{
identity: {
params: session.identity.params,
sessionID: () => id,
sessionKey: () => sessionKey,
},
data: {
info: select(session.data.info),
parent: select(session.data.parent),
parentID: select(session.data.parentID),
status: select(session.data.status),
},
history: { messages: select(session.history.messages) },
},
active,
),
dispose,
}
}, owner),
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
)
onCleanup(cache.clear)
// Providers follow the selected Location even while its history is loading.
// Dispose detached views before their effects can read the new Location.
createComputed(on(session.identity.workspaceKey, cache.clear, { defer: true }))
return () => cache.get(session.identity.sessionKey()).value
}
@@ -349,6 +349,7 @@ export function SessionSummaryPanel(props: {
type MessageTimelineProps = {
hideHeader?: boolean
active?: boolean
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
@@ -459,6 +460,7 @@ function MessageTimelineView(
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
active: () => props.active !== false,
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
presentationKey: () => JSON.stringify(props.data.timelineDetail()),
projection,
@@ -552,6 +554,12 @@ function MessageTimelineView(
if (await props.action.rename(title.draft)) setTitle("editing", false)
}
createEffect(() => {
if (props.active !== false) return
setSummary(false)
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
})
const rowRenderer = createSessionTimelineRowRenderer({
sessionID: () => sessionID()!,
status: sessionStatus,
+5 -4
View File
@@ -11,17 +11,17 @@ export {
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history" | "ownership"> }) {
const data = useData()
const [resource] = createResource(
() => input.session.identity.sessionID(),
async (id) => {
if (!id) return
const key = input.session.identity.sessionKey()
const owner = input.session.ownership.capture()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
await enrichLeadingTurn({
current: () => input.session.identity.sessionKey() === key,
current: owner.current,
messages: () => data.session.message.list(id),
more: () => data.session.message.more(id),
loading: () => data.session.message.loading(id),
@@ -29,12 +29,13 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
return id
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || !resource.loading
return !id || data.session.message.list(id).length > 0 || (!resource.loading && resource.latest === id)
})
const more = () => {
const id = input.session.identity.sessionID()
@@ -16,6 +16,35 @@ test("matches only the scroll element or an ancestor containing it", () => {
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("restores a view observed before its first attachment", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const viewport = targetWindow.document.createElement("div")
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 240,
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const connections: boolean[] = []
const cleanup = observeElementOffsetReconnectAware(
instance,
(offset) => {
instance.scrollOffset = offset
},
() => connections.push(viewport.isConnected),
)
try {
mutations.append(targetWindow.document.body, viewport)
await frames(2, targetWindow)
expect(connections).toEqual([true])
expect(instance.scrollOffset).toBe(0)
} finally {
cleanup()
await targetWindow.happyDOM.close()
}
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
@@ -20,7 +20,8 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
cleanupOffset?.()
}
let removed = false
// Cached views can be constructed before their first attachment to the page.
let removed = !element.isConnected
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
@@ -2,6 +2,7 @@ import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
observeElementRect,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
@@ -49,6 +50,7 @@ type Projection = Pick<
>
type Input = {
active?: Accessor<boolean>
sessionKey: Accessor<string>
presentationKey?: Accessor<string>
projection: Projection
@@ -83,6 +85,7 @@ type ViewProps = {
export function createTimelineVirtualizer(input: Input) {
const language = useLanguage()
const active = () => input.active?.() !== false
const isDesktop = createMediaQuery("(min-width: 768px)")
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
const ownerSessionKey = input.sessionKey()
@@ -134,7 +137,7 @@ export function createTimelineVirtualizer(input: Input) {
!(
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 16 &&
row.group.refs.length <= 64 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
@@ -156,6 +159,7 @@ export function createTimelineVirtualizer(input: Input) {
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
let batchingColdSizes = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -163,13 +167,22 @@ export function createTimelineVirtualizer(input: Input) {
return rows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementRect: (instance, callback) => {
reportRect = callback
return observeElementRect(instance, (rect) => {
if (active()) callback(rect)
})
},
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
if (!active()) return
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (!active()) return
virtualContent?.querySelectorAll<HTMLDivElement>("[data-index]").forEach(virtualizer.measureElement)
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
@@ -181,6 +194,11 @@ export function createTimelineVirtualizer(input: Input) {
// its snapshot on attachment, but later explicit measurements must read layout
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
measureElement: (element, entry, instance) => {
if (!active() || !element.isConnected)
return (
instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element))) ??
fallbackItemSize
)
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
@@ -192,6 +210,7 @@ export function createTimelineVirtualizer(input: Input) {
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (!active()) return
if (batchingColdSizes && input.pinned()) return
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -221,6 +240,7 @@ export function createTimelineVirtualizer(input: Input) {
// Read the whole measurement delivery before committing reactive row sizes.
// Otherwise each row can render and force layout before the next is measured.
virtualizer.resizeItem = (index, size) => {
if (!active()) return
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
@@ -236,6 +256,7 @@ export function createTimelineVirtualizer(input: Input) {
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
if (!active()) return
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
batchingColdSizes = coldPending && input.pinned()
@@ -271,7 +292,21 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
if (virtualContent) input.setContentRef(virtualContent)
queueMicrotask(() => {
if (!active() || !root?.isConnected) return
// A detached view can miss its nonzero ResizeObserver delivery. Publish
// its real viewport before restoring the offset and admitting rows.
reportRect?.({ width: root.offsetWidth, height: root.offsetHeight })
if (input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
})
input.setRevealMessage?.((id, partID) => {
if (!active()) return
const partIndex = partID
? rows().findIndex(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === partID,
@@ -282,6 +317,7 @@ export function createTimelineVirtualizer(input: Input) {
virtualizer.scrollToIndex(index, { align: "center" })
})
input.setScrollToEnd?.(() => {
if (!active() || !listRoot()?.isConnected) return
input.onPin()
virtualizer.scrollToEnd()
})
@@ -292,6 +328,7 @@ export function createTimelineVirtualizer(input: Input) {
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
if (!active()) return
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
@@ -309,7 +346,7 @@ export function createTimelineVirtualizer(input: Input) {
)
}
const settleColdBottom = () => {
if (!coldPending || settleQueued) return
if (!active() || !coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
@@ -372,7 +409,7 @@ export function createTimelineVirtualizer(input: Input) {
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
input.setScrollRef(root)
if (active()) input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
}
@@ -429,6 +466,7 @@ export function createTimelineVirtualizer(input: Input) {
// under a viewport that was already there. Merely resting near the end is not enough, otherwise
// a later scroll would overwrite an upward intent expressed a pixel short of the bottom.
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (!active()) return
const root = event.currentTarget
const previousTop = scrollTop
const previousMaxScroll = maxScroll
@@ -454,15 +492,8 @@ export function createTimelineVirtualizer(input: Input) {
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
// Prepending history changes data-index, not the keyed element's identity.
// Its observer reads the current index and delivers any actual size change.
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
queueMicrotask(() => virtualizer.measureElement(null))
@@ -493,7 +524,7 @@ export function createTimelineVirtualizer(input: Input) {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = requestAnimationFrame(() => {
contentMeasureFrame = undefined
if (element.isConnected) virtualizer.measureElement(element)
if (active() && element.isConnected) virtualizer.measureElement(element)
})
})}
</div>
@@ -555,7 +586,7 @@ export function createTimelineVirtualizer(input: Input) {
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
input.setContentRef(element)
if (active()) input.setContentRef(element)
}}
style={{
height: `${virtualizer.getTotalSize()}px`,
@@ -590,9 +621,11 @@ export function createTimelineVirtualizer(input: Input) {
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
if (active()) {
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
}
})
return {
+5 -1
View File
@@ -288,8 +288,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
const mono = monoFontFamily(store.appearance?.mono)
root.style.setProperty("--font-family-mono", mono)
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
// Inline code can first appear during history backfill. Load its selected
// face with the shell so that font discovery does not resize that mount.
void document.fonts?.load(`440 13px ${mono}`).catch(() => undefined)
})
return {
+11 -10
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { useDialog } from "@opencode/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -306,19 +306,20 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => {
if (!catalogReady()) return
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
if (opt.title)
acc[id] = {
batch(() =>
registered().forEach((opt) => {
if (!opt.title) return
setCatalog(
actionId(opt.id),
reconcile({
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}
return acc
}, {} as CommandCatalog),
}),
)
}),
)
})
@@ -0,0 +1,41 @@
import { expect, test } from "bun:test"
import { createComputed, createRoot } from "solid-js"
import { OpenCode } from "@opencode/client/promise"
import { createData } from "@opencode/client/solid"
test("publishes an initial message page, its index, and its cursor together", async () => {
const observed: { ids: string[]; more: boolean; text: string | undefined }[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () =>
Response.json({
data: [{ id: "msg_page", type: "user", text: "History", time: { created: 1 } }],
cursor: { next: "older" },
}),
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
createComputed(() => {
const message = data.session.message.get("ses_page", "msg_page")
observed.push({
ids: data.session.message.list("ses_page").map((message) => message.id),
more: data.session.message.more("ses_page"),
text: message?.type === "user" ? message.text : undefined,
})
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_page")
expect(observed).toEqual([
{ ids: [], more: false, text: undefined },
{ ids: ["msg_page"], more: true, text: "History" },
])
} finally {
setup.dispose()
}
})
@@ -0,0 +1,167 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
import { createTimelineCache } from "../src/session/timeline/cache"
function setup() {
return createRoot((dispose) => {
const [state, setState] = createStore({
id: "ses_a",
directory: "/repo",
visible: true,
messages: {
ses_a: [{ id: "msg_a", type: "user", text: "First session", time: { created: 1 } }],
ses_b: [{ id: "msg_b", type: "user", text: "Second session", time: { created: 2 } }],
} as Record<string, SessionMessageInfo[]>,
})
const views = new Map<
string,
{ active: () => boolean; messages: () => SessionMessageInfo[]; element: HTMLDivElement }
>()
const disposed: string[] = []
const cache = createTimelineCache(
{
identity: {
params: {
get id() {
return state.id
},
serverKey: "local",
},
sessionID: () => state.id,
workspaceKey: () => SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory)),
sessionKey: () =>
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
},
data: {
info: createMemo(() => ({
id: state.id,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: state.directory },
})),
parent: () => undefined,
parentID: () => undefined,
status: () => ({ type: "idle" }),
},
history: { messages: () => state.messages[state.id] ?? [] },
},
(source, active) => {
const id = source.identity.sessionID()!
const element = document.createElement("div")
createMemo(() =>
element.setAttribute(
"data-messages",
source.history
.messages()
.map((message) => message.id)
.join(","),
),
)
views.set(id, { active, messages: source.history.messages, element })
onCleanup(() => disposed.push(id))
return element
},
() => state.visible,
)
return { state, setState, cache, views, disposed, dispose }
})
}
test("reuses a session view and refreshes its own history when selected again", () => {
const input = setup()
try {
const first = input.cache()
input.setState("id", "ses_b")
const second = input.cache()
expect(second).not.toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(false)
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_a")
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
input.setState("messages", "ses_a", [
{ id: "msg_c", type: "user", text: "Updated while inactive", time: { created: 3 } },
])
input.setState("id", "ses_a")
expect(input.cache()).toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(true)
expect(input.views.get("ses_a")!.element.dataset.messages).toBe("msg_c")
expect(input.views.get("ses_b")!.element.dataset.messages).toBe("msg_b")
expect(input.disposed).toEqual([])
} finally {
input.dispose()
}
expect(input.disposed.sort()).toEqual(["ses_a", "ses_b"])
})
test("suspends a detached mobile view and reuses it when the conversation returns", () => {
const input = setup()
try {
const first = input.cache()
input.setState("visible", false)
expect(input.views.get("ses_a")!.active()).toBe(false)
input.setState("visible", true)
expect(input.cache()).toBe(first)
expect(input.views.get("ses_a")!.active()).toBe(true)
} finally {
input.dispose()
}
})
test("disposes views whose Location-scoped providers no longer match", () => {
const input = setup()
try {
const first = input.cache()
input.setState("directory", "/other")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a", "ses_a"])
} finally {
input.dispose()
}
expect(input.disposed).toHaveLength(3)
})
test("disposes views on workspace changes while the destination is not rendered", () => {
const input = setup()
try {
const first = input.cache()
input.setState("visible", false)
input.setState("directory", "/other")
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
input.setState("visible", true)
expect(input.cache()).not.toBe(first)
} finally {
input.dispose()
}
expect(input.disposed).toEqual(["ses_a", "ses_a"])
})
test("evicts the least recently selected view and disposes all retained owners", () => {
const input = setup()
try {
const first = input.cache()
Array.from({ length: 15 }, (_, index) => `ses_${index}`).forEach((id) => {
input.setState("id", id)
input.cache()
})
input.setState("id", "ses_a")
expect(input.cache()).toBe(first)
input.setState("id", "ses_last")
input.cache()
expect(input.disposed).toEqual(["ses_0"])
input.setState("id", "ses_0")
input.cache()
expect(input.disposed).toEqual(["ses_0", "ses_1"])
} finally {
input.dispose()
}
expect(input.disposed).toHaveLength(18)
})
+12 -4
View File
@@ -57,6 +57,8 @@ type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent
export type CreateDataInput = {
readonly api: () => OpenCodeClient
readonly directory: string
/** Raw-message window used for an initial transcript read. Older pages retain their normal size. */
readonly initialMessageLimit?: () => number
readonly event: {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
@@ -1569,7 +1571,11 @@ export function createData(config: CreateDataInput) {
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
const response = await api().message.list({
sessionID,
limit: config.initialMessageLimit?.() ?? messagePageLimit,
order: "desc",
})
const fetched = response.data.toReversed()
// Same protection as the pending sync: a re-fetch racing an
// admission must not wipe its local transcript row.
@@ -1583,9 +1589,11 @@ export function createData(config: CreateDataInput) {
(item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)),
)
const messages = local.length === 0 ? fetched : [...fetched, ...local]
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
batch(() => {
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
})
})
},
more(sessionID: string) {
+38
View File
@@ -14,6 +14,44 @@ const session = (viewed: number): SessionInfo => ({
location: { directory: "/project" },
})
test("uses the configured initial window and retains normal cursor page sizes", async () => {
const requests: { limit: string | null; cursor: string | null }[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
const cursor = url.searchParams.get("cursor")
requests.push({ limit: url.searchParams.get("limit"), cursor })
return Response.json({
data: [{ id: cursor ? "msg_1" : "msg_2", type: "user", text: "History", time: { created: cursor ? 1 : 2 } }],
cursor: cursor ? {} : { next: "older" },
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
initialMessageLimit: () => 40,
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
await setup.data.session.message.sync("ses_refresh")
expect(requests).toEqual([{ limit: "40", cursor: null }])
await setup.data.session.message.loadMore("ses_refresh")
expect(requests).toEqual([
{ limit: "40", cursor: null },
{ limit: "20", cursor: "older" },
])
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_1", "msg_2"])
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
@@ -1,5 +1,36 @@
import { expect, story } from "../../storybook/playwright/story"
story("keeps file disclosures keyboard-accessible as the file list changes", async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups")
const patch = root.locator('[data-component="apply-patch-tool"]')
const first = patch.getByRole("button", { name: /a\.ts/ })
const second = patch.getByRole("button", { name: /b\.ts/ })
await expect(first).toHaveAttribute("aria-expanded", "false")
await first.focus()
await first.press("ArrowDown")
await expect(second).toBeFocused()
await second.press("ArrowDown")
await expect(first).toBeFocused()
await first.press("End")
await expect(second).toBeFocused()
await second.press("Home")
await expect(first).toBeFocused()
await first.press("Enter")
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patch.getByRole("region", { name: /a\.ts/ })).toBeVisible()
await first.press("Space")
await expect(first).toHaveAttribute("aria-expanded", "false")
await expect(patch.getByRole("region", { name: /a\.ts/ })).toHaveCount(0)
await root.getByRole("button", { name: "Finish follow-up patch", exact: true }).click()
const third = patch.getByRole("button", { name: /c\.ts/ })
await expect(third).toHaveAttribute("aria-expanded", "false")
await second.focus()
await second.press("ArrowDown")
await expect(third).toBeFocused()
await third.press("ArrowUp")
await expect(second).toBeFocused()
})
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
const root = await mount("current-tool-group--patch-follow-ups")
const group = root.locator('[data-component="collapsed-tool-group"]')
@@ -1,5 +1,23 @@
import { expect, story } from "../../storybook/playwright/story"
story("requests live output only when the shell is expanded", async ({ mount }) => {
const root = await mount("current-session-terminal-work--live-user-command", { args: { expanded: false } })
const shell = root.locator('[data-component="session-shell-message"]')
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(root.getByLabel("Output requests")).toHaveText("0")
await root.getByRole("button", { name: "Update output", exact: true }).click()
await expect(root.getByLabel("Output requests")).toHaveText("0")
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("ready\nnext line\n")
await expect(root.getByLabel("Output requests")).toHaveText(/^[1-9]\d*$/)
await trigger.click()
await expect(shell.locator('[data-component="bash-output"]')).toHaveCount(0)
await root.getByRole("button", { name: "Update output", exact: true }).click()
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("ready\nnext line\nnext line\n")
})
story("streams user shell output and retains the saved completion", async ({ mount }) => {
const root = await mount("current-session-terminal-work--live-user-command")
const shell = root.locator('[data-component="session-shell-message"]')
@@ -334,9 +334,18 @@ export function BasicTool(props: BasicToolProps) {
</div>
</Show>
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
<Show
when={props.locked && open() && !props.defer}
fallback={
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
}
>
<div data-slot="collapsible-content" data-expanded="">
{props.children}
</div>
</Show>
</Show>
</Collapsible>
)
@@ -69,7 +69,10 @@ export function createSessionTimelineRowRenderer(input: {
const patchPartKeys = new WeakMap<SessionMessageAssistant["content"][number], string>()
const patchOwners = createMemo(() => {
const owners = new Map<string, string>()
input.projection.rows().forEach((row) => {
const rows = input.projection.rows()
// Track status changes before a group is first opened: a failed patch can
// split an existing group without changing the projection's row identities.
rows.forEach((row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => {
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
@@ -97,10 +100,15 @@ export function createSessionTimelineRowRenderer(input: {
}
const copyContentID = (messageID: string) => {
if (workingTurn(messageID)) return null
return (input.projection.assistantMessagesByParent().get(messageID) ?? emptyAssistantMessages)
.toReversed()
.flatMap((message) => Timeline.contentEntries(message).toReversed())
.find((entry) => entry.content.type === "text" && !!entry.content.text.trim())?.id
const message = input.projection
.assistantMessagesByParent()
.get(messageID)
?.findLast((message) => message.content.some((content) => content.type === "text" && !!content.text.trim()))
return message
? Timeline.contentEntries(message).findLast(
(entry) => entry.content.type === "text" && !!entry.content.text.trim(),
)?.id
: undefined
}
const padding = () => input.padding?.() ?? "px-4 md:px-5"
const indexGroupContents = (refs: PartRef[]) => {
@@ -69,9 +69,10 @@ export const UserCommandCompleted = {
}
export const LiveUserCommand = {
args: { outcome: "exited", output: true },
args: { outcome: "exited", output: true, expanded: true },
argTypes: { outcome: { control: "select", options: ["exited", "nonzero", "timeout", "killed"] } },
render: (args: { outcome: "exited" | "nonzero" | "timeout" | "killed"; output: boolean }) => {
render: (args: { outcome: "exited" | "nonzero" | "timeout" | "killed"; output: boolean; expanded: boolean }) => {
const [stats, setStats] = createStore({ reads: 0 })
const [message, setMessage] = createSignal<SessionMessageShell>({
id: "msg_shell_live",
type: "shell",
@@ -83,6 +84,7 @@ export const LiveUserCommand = {
let output = args.output ? "ready\n" : ""
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<output aria-label="Output requests">{stats.reads}</output>
<button type="button" onClick={() => (output += "next line\n")}>
Update output
</button>
@@ -105,6 +107,7 @@ export const LiveUserCommand = {
directory="/workspace"
data={{ session: [], session_status: {}, session_diff: {} }}
shellOutput={async (input) => {
setStats("reads", (value) => value + 1)
if (message().status !== "running") throw new Error("Shell output unavailable")
if (input.id !== "shell_live" || input.location?.directory !== "/workspace") {
throw new Error("Unexpected shell output request")
@@ -123,7 +126,7 @@ export const LiveUserCommand = {
}
}}
>
<SessionShellMessage message={message()} defaultOpen />
<SessionShellMessage message={message()} defaultOpen={args.expanded} />
</DataProvider>
</section>
)
+234 -154
View File
@@ -1,9 +1,11 @@
// Current Session tool presentation grouped by visual family.
import {
Component,
batch,
createEffect,
createMemo,
createSignal,
createUniqueId,
For,
Match,
onCleanup,
@@ -19,8 +21,6 @@ import { type SessionSummary, useData } from "../context"
import { useFileComponent } from "@opencode/ui/context/file"
import { type UiI18n, useI18n } from "@opencode/ui/context/i18n"
import { BasicTool, GenericTool } from "../components/basic-tool"
import { Accordion } from "@opencode/ui/accordion"
import { StickyAccordionHeader } from "@opencode/ui/sticky-accordion-header"
import { Collapsible } from "@opencode/ui/collapsible"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon, type IconProps } from "@opencode/ui/icon"
@@ -526,7 +526,8 @@ export function CurrentContextToolGroup(props: {
? i18n.t("ui.tool.skill")
: part.name === "subagent"
? i18n.t("ui.tool.agent.default")
: getToolInfo(part.name, currentToolInput(part), currentToolMetadata(part)).title,
: getToolInfo(part.name, undefined, part.name === "websearch" ? currentToolMetadata(part) : undefined)
.title,
]
}),
),
@@ -557,40 +558,39 @@ export function CurrentContextToolGroup(props: {
}
})
const items = createMemo(() =>
props.parts.reduce<(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]>(
(groups, tool) => {
if (tool.type !== "tool") {
groups.push(tool)
return groups
}
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
Array.isArray(previous) &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
) {
previous.push(tool)
return groups
}
if (
tool.name === "skill" &&
tool.state.status !== "error" &&
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
Array.isArray(previous) &&
previous?.[0]?.name === "skill" &&
previous[0].state.status !== "error" &&
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
) {
previous.push(tool)
return groups
}
groups.push([tool])
(props.open ? props.parts : []).reduce<
(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]
>((groups, tool) => {
if (tool.type !== "tool") {
groups.push(tool)
return groups
},
[],
),
}
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
Array.isArray(previous) &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
) {
previous.push(tool)
return groups
}
if (
tool.name === "skill" &&
tool.state.status !== "error" &&
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
Array.isArray(previous) &&
previous?.[0]?.name === "skill" &&
previous[0].state.status !== "error" &&
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
) {
previous.push(tool)
return groups
}
groups.push([tool])
return groups
}, []),
)
const patchKeys = createMemo(() => {
const keys = new Map<SessionMessageAssistantTool, string>()
@@ -988,6 +988,7 @@ function FileTool(props: ToolProps & { title: string; count: number; children: J
onOpenChange={undefined}
locked
icon="code-lines"
hasContent
defer={false}
rail={false}
trigger={{
@@ -1000,6 +1001,88 @@ function FileTool(props: ToolProps & { title: string; count: number; children: J
)
}
function FileAccordionGroup(props: { children: JSX.Element }) {
return (
<div
data-component="accordion"
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
onKeyDown={(event) => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return
if (!(event.target instanceof HTMLButtonElement) || event.target.dataset.slot !== "accordion-trigger") return
if (event.target.closest('[data-component="accordion"]') !== event.currentTarget) return
const triggers = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>(
':scope > [data-slot="accordion-item"] > [data-slot="accordion-header"] > [data-slot="accordion-trigger"]',
),
]
const index = triggers.indexOf(event.target)
if (index < 0) return
event.preventDefault()
const next =
event.key === "Home"
? 0
: event.key === "End"
? triggers.length - 1
: (index + (event.key === "ArrowDown" ? 1 : -1) + triggers.length) % triggers.length
triggers[next]?.focus()
}}
>
{props.children}
</div>
)
}
function FileAccordionItem(props: {
open: boolean
onOpenChange: (open: boolean) => void
type?: string
header: JSX.Element
children: JSX.Element
}) {
const id = createUniqueId()
return (
<div
data-slot="accordion-item"
data-type={props.type}
data-expanded={props.open ? "" : undefined}
data-closed={!props.open ? "" : undefined}
>
<h3
data-slot="accordion-header"
data-component="sticky-accordion-header"
data-expanded={props.open ? "" : undefined}
data-closed={!props.open ? "" : undefined}
>
<button
type="button"
id={`${id}-trigger`}
data-slot="accordion-trigger"
data-expanded={props.open ? "" : undefined}
data-closed={!props.open ? "" : undefined}
aria-expanded={props.open}
aria-controls={props.open ? `${id}-content` : undefined}
onClick={() => batch(() => props.onOpenChange(!props.open))}
>
{props.header}
</button>
</h3>
<Show when={props.open}>
<div
id={`${id}-content`}
role="region"
aria-labelledby={`${id}-trigger`}
data-slot="accordion-content"
data-expanded=""
>
{props.children}
</div>
</Show>
</div>
)
}
function ToolFileAccordion(props: {
path: string
actions?: JSX.Element
@@ -1010,7 +1093,6 @@ function ToolFileAccordion(props: {
forceOpen?: boolean
defer?: boolean
}) {
const value = createMemo(() => props.path || "tool-file")
const [expanded, setExpanded] = createSignal(props.defaultOpen ?? false)
const [visible, setVisible] = createSignal(false)
const open = () => props.open ?? expanded()
@@ -1033,38 +1115,31 @@ function ToolFileAccordion(props: {
})
return (
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
value={open() ? [value()] : []}
onChange={(next) => change(next.includes(value()))}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: props.path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={props.path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(props.path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(props.path)}</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
{props.actions}
<Icon name="chevron-grabber-vertical" size="small" />
<FileAccordionGroup>
<FileAccordionItem
open={open()}
onOpenChange={change}
header={
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: props.path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={props.path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(props.path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(props.path)}</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={!props.defer || visible()}>{props.children}</Show>
</Accordion.Content>
</Accordion.Item>
</Accordion>
<div data-slot="apply-patch-trigger-actions">
{props.actions}
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
}
>
<Show when={!props.defer || visible()}>{props.children}</Show>
</FileAccordionItem>
</FileAccordionGroup>
)
}
@@ -1277,6 +1352,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="bullet-list"
hasContent={!!props.output}
trigger={{
title: i18n.t("ui.tool.list"),
subtitle: displayDirectory(typeof props.input.path === "string" ? props.input.path : "/"),
@@ -1306,6 +1382,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="magnifying-glass-menu"
hasContent={!!props.output}
trigger={{
title: i18n.t("ui.tool.glob"),
subtitle: displayDirectory(typeof props.input.path === "string" ? props.input.path : "/"),
@@ -1339,6 +1416,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="magnifying-glass-menu"
hasContent={!!props.output}
trigger={{
title: i18n.t("ui.tool.grep"),
subtitle: displayDirectory(typeof props.input.path === "string" ? props.input.path : "/"),
@@ -1600,13 +1678,14 @@ ToolRegistry.register({
const i18n = useI18n()
const pending = () => props.status === "streaming" || props.status === "running"
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
const output = createMemo(() => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n"))
const output = () => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
const sawPending = pending()
return (
<BasicTool
{...props}
icon="console"
rail={false}
hasContent
compact
allowOpenWhilePending
trigger={(open) => (
@@ -1616,7 +1695,7 @@ ToolRegistry.register({
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code().split("\n")[0]} animate={sawPending} />
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
</Show>
</div>
</div>
@@ -1642,36 +1721,44 @@ ToolRegistry.register({
props.status === "running" ||
(typeof props.metadata.shellID === "string" && data.shellRunning?.(props.metadata.shellID) === true)
const sawStreaming = streaming()
const [streamed, setStreamed] = createSignal("")
// Direct-user terminal snapshots are authoritative; agent results can describe background shells.
const saved = createMemo(() =>
props.metadata.status === "exited" || props.metadata.status === "timeout" || props.metadata.status === "killed"
? props.output
: undefined,
)
createEffect(() => {
if (saved() !== undefined) return
const id = props.metadata.shellID
const load = data.shellOutput
if (typeof id !== "string" || !load) return
onCleanup(followShellOutput({ id, directory: data.directory, running: pending(), load, onOutput: setStreamed }))
})
const command = () => {
if (typeof props.input.command === "string") return props.input.command
if (typeof props.metadata.command === "string") return props.metadata.command
return ""
}
const output = createMemo(() =>
stripAnsi(saved() ?? ((typeof props.metadata.shellID === "string" && streamed()) || props.output || "")).replace(
/\r\n?/g,
"\n",
),
)
function Output() {
const [streamed, setStreamed] = createSignal("")
// Direct-user terminal snapshots are authoritative; agent results can describe background shells.
const saved = createMemo(() =>
props.metadata.status === "exited" || props.metadata.status === "timeout" || props.metadata.status === "killed"
? props.output
: undefined,
)
createEffect(() => {
if (saved() !== undefined) return
const id = props.metadata.shellID
const load = data.shellOutput
if (typeof id !== "string" || !load) return
onCleanup(followShellOutput({ id, directory: data.directory, running: pending(), load, onOutput: setStreamed }))
})
const output = createMemo(() =>
stripAnsi(
saved() ?? ((typeof props.metadata.shellID === "string" && streamed()) || props.output || ""),
).replace(/\r\n?/g, "\n"),
)
return (
<ConsoleOutput copy={command()} variant="shell">
<span data-slot="bash-command">{command()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
)
}
return (
<BasicTool
{...props}
icon="console"
rail={false}
hasContent
compact
allowOpenWhilePending
trigger={(open) => (
@@ -1696,10 +1783,7 @@ ToolRegistry.register({
</div>
)}
>
<ConsoleOutput copy={command()} variant="shell">
<span data-slot="bash-command">{command()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
<Output />
</BasicTool>
)
},
@@ -1902,13 +1986,7 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<FileTool {...props} title={title()} count={files().length}>
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
value={open()}
onChange={change}
>
<FileAccordionGroup>
<Index each={files()}>
{(file) => {
const value = () => file().path
@@ -1928,66 +2006,68 @@ ToolRegistry.register({
})
return (
<Accordion.Item value={value()} data-type={file().type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file().path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file().path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file().type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file().type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: file().additions, deletions: file().deletions }}
/>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
<FileAccordionItem
open={active()}
onOpenChange={(expanded) =>
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
}
type={file().type}
header={
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file().path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file().path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={props.deferContent === false || visible()}>
<For each={file().views}>
{(view) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={view.fileDiff}
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
onRendered={props.onContentRendered}
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file().type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file().type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: file().additions, deletions: file().deletions }}
/>
</div>
)}
</For>
</Show>
</Accordion.Content>
</Accordion.Item>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
}
>
<Show when={props.deferContent === false || visible()}>
<For each={file().views}>
{(view) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={view.fileDiff}
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
onRendered={props.onContentRendered}
/>
</div>
)}
</For>
</Show>
</FileAccordionItem>
)
}}
</Index>
</Accordion>
</FileAccordionGroup>
</Show>
</FileTool>
</div>
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test"
import { createMarkdownBase, parseSmallMarkdown } from "./marked-base"
test.each([
"Plain response.",
"The grade cost $0.5664, and the original run is preserved.",
"The issue is fixed: all 13 queries succeeded.",
"Completion is 100%…",
"https://example.com",
"Plain response. ",
"Plain response.\t",
"\nPlain response.\n",
"\u00a0Plain response.\u00a0",
"A paragraph\r---",
"A paragraph \rwith a hard break.",
"We can—Code Mode would be useful.\n\nI’ll enable the read-only tools.",
"A paragraph\nwith a soft break.",
"A paragraph\r\n\r\nAnother paragraph.",
"A paragraph\n\n\nAnother paragraph.\n",
"Quotes: \"yes\" and 'no'.",
"She said \"yes\" and 'no'.",
"日本語の文章。",
"Простой текст.",
"١. العربية",
"Hello & goodbye <world>.",
"A paragraph \nwith a hard break.",
"Title\n---",
"- A list\n- Another item",
"1. A list\n2. Another item",
"www.example.com",
"Bold **text** and `code`.",
"**Done.** The judge made **13 evidence queries**.",
"The tool is `candidate_evidence`.\n\nIt can query messages.",
"***Strong emphasis*** and _emphasis_.",
"A [link](https://example.com) and ![image](./image.png).",
"A reference [link][id].\n\n[id]: https://example.com",
"A paragraph\n# Heading",
"### Heading",
"####### Paragraph",
"***",
"_ _ _",
"**",
"*",
"+ Item",
"> Quote",
"A paragraph\n---",
"A paragraph\n===",
"a | b\n--- | ---\nc | d",
"<div>HTML</div>",
"A paragraph\n with indentation.",
"~~Struck~~ text and www.example.com.",
"A **bold\nsoft break**.",
"An escaped \\*asterisk\\*.",
" indented code",
"\n indented code",
" ",
])("preserves the Markdown parser's meaning for %j", (text) => {
const expected = createMarkdownBase().parse(text, { async: false })
if (text.includes("indented code")) {
expect(parseSmallMarkdown(text)).toBeUndefined()
return
}
expect(parseSmallMarkdown(text)).toBe(expected)
})
test("keeps code blocks, math, and large messages on the worker path", () => {
expect(parseSmallMarkdown("```ts\nconst value = 1\n```")).toBeUndefined()
expect(parseSmallMarkdown("~~~ts\nconst value = 1\n~~~")).toBeUndefined()
expect(parseSmallMarkdown("\\(x + y\\)")).toBeUndefined()
expect(parseSmallMarkdown("$$x + y$$")).toBeUndefined()
expect(parseSmallMarkdown("a".repeat(1025))).toBeUndefined()
})
+28
View File
@@ -16,7 +16,35 @@ let smallParser: Marked | undefined
export function parseSmallMarkdown(text: string) {
// Any possible KaTeX delimiter stays on the worker, including escaped ones.
if (text.length > 1024 || text.includes("\\(") || text.includes("$$")) return
// Ordinary prose does not need the block/inline lexer's cold regular expressions.
// Keep every possible Markdown construct, autolink, and hard break on that lexer.
if (
/^[\p{L}\p{N} \t\r\n,.;:!?'"’“”()\-–—…$%]+$/u.test(text) &&
!/(?:^|[\r\n])(?:[ \t]|\d|[-])| {2}(?:\r\n?|\n)|www\./.test(text)
) {
return text
.replace(/\r\n?/g, "\n")
.replace(/^\n+|\n+$/g, "")
.split(/\n{2,}/)
.filter(Boolean)
.map((paragraph) => `<p>${paragraph.replace(/["']/g, (value) => (value === '"' ? "&quot;" : "&#39;"))}</p>\n`)
.join("")
}
const parser = (smallParser ??= createMarkdownBase())
const paragraphs = text.replace(/\r\n?/g, "\n")
// Inline formatting in ordinary paragraphs does not need the block lexer.
// Any possible block opener, HTML, table, or reference definition stays on it.
if (
!/[<|]/.test(paragraphs) &&
!/^(?:[ \t>\d\[\-=]|#{1,6}(?:[ \t]|$)|[`~]{3}|[+*](?:[ \t]|$)|(?:[*_][ \t]*){3,}$)/m.test(paragraphs)
) {
return paragraphs
.replace(/^\n+|\n+$/g, "")
.split(/\n{2,}/)
.filter(Boolean)
.map((paragraph) => `<p>${parser.parseInline(paragraph, { async: false })}</p>\n`)
.join("")
}
const tokens = parser.lexer(text)
let code = false
parser.walkTokens(tokens, (token) => {