diff --git a/packages/app/e2e/performance/timeline/laggy-session-benchmark.md b/packages/app/e2e/performance/timeline/laggy-session-benchmark.md new file mode 100644 index 00000000000..68d3ab27fd3 --- /dev/null +++ b/packages/app/e2e/performance/timeline/laggy-session-benchmark.md @@ -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. diff --git a/packages/app/e2e/performance/timeline/laggy-session-benchmark.spec.ts b/packages/app/e2e/performance/timeline/laggy-session-benchmark.spec.ts new file mode 100644 index 00000000000..a897772f4cc --- /dev/null +++ b/packages/app/e2e/performance/timeline/laggy-session-benchmark.spec.ts @@ -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", + }, + ) + }) +} diff --git a/packages/app/e2e/performance/timeline/laggy-session-report.ts b/packages/app/e2e/performance/timeline/laggy-session-report.ts new file mode 100644 index 00000000000..a214d587796 --- /dev/null +++ b/packages/app/e2e/performance/timeline/laggy-session-report.ts @@ -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)!, + } +} diff --git a/packages/app/e2e/regression/mobile-status-drawer.spec.ts b/packages/app/e2e/regression/mobile-status-drawer.spec.ts index efd6e48a1f3..d37b7e963f7 100644 --- a/packages/app/e2e/regression/mobile-status-drawer.spec.ts +++ b/packages/app/e2e/regression/mobile-status-drawer.spec.ts @@ -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 } }) diff --git a/packages/app/e2e/regression/session-timeline-cache.spec.ts b/packages/app/e2e/regression/session-timeline-cache.spec.ts new file mode 100644 index 00000000000..6643138ce28 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-cache.spec.ts @@ -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() + const release = Promise.withResolvers() + 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() + const release = Promise.withResolvers() + 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 = 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") + }) +} diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 6fbcf1a6499..75baef12ec1 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -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([]) diff --git a/packages/app/e2e/regression/session-timeline-hydration.spec.ts b/packages/app/e2e/regression/session-timeline-hydration.spec.ts index f1504a05d9d..c31045ac80a 100644 --- a/packages/app/e2e/regression/session-timeline-hydration.spec.ts +++ b/packages/app/e2e/regression/session-timeline-hydration.spec.ts @@ -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() diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 4b42aefb8b4..71ca5540e0a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -80,6 +80,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const id = state.connections let ended = false let own: ReadableStreamDefaultController | undefined + let keepalive: ReturnType | undefined const stream = new ReadableStream({ 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 }, }) diff --git a/packages/app/src/runtime/server/request-queue.test.ts b/packages/app/src/runtime/server/request-queue.test.ts index 984b1789989..8354a61f03e 100644 --- a/packages/app/src/runtime/server/request-queue.test.ts +++ b/packages/app/src/runtime/server/request-queue.test.ts @@ -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) diff --git a/packages/app/src/runtime/server/request-queue.ts b/packages/app/src/runtime/server/request-queue.ts index 304c30ad610..eaf1267465c 100644 --- a/packages/app/src/runtime/server/request-queue.ts +++ b/packages/app/src/runtime/server/request-queue.ts @@ -84,17 +84,23 @@ export function createRequestQueue(input: { if (index === -1) return waiting.splice(index, 1)[0]?.start() } - const acquire = (entry: Entry) => - new Promise((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((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") diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 50015e1a93a..00774730e5a 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -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, ) { 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 })), diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 76d9a5ba7a2..68336d9f32d 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -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 }) { ) + const timelineView = createTimelineCache( + session, + (source, active) => ( + + + + } + /> + ), + () => conversationVisible() && messagesReady(), + ) + const sessionPanelContent = () => ( <> - - {(_id) => ( - } - /> - )} - + {timelineView()} diff --git a/packages/app/src/session/timeline/cache.ts b/packages/app/src/session/timeline/cache.ts new file mode 100644 index 00000000000..6496fb01ac5 --- /dev/null +++ b/packages/app/src/session/timeline/cache.ts @@ -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 } }, + render: (source: TimelineSessionSource, active: Accessor) => JSX.Element, + visible: Accessor, +) { + 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 = (read: Accessor) => + createMemo((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 +} diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 135894d2a1c..af55900822b 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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, diff --git a/packages/app/src/session/timeline/model.ts b/packages/app/src/session/timeline/model.ts index 6629dabb01c..8b3724c246c 100644 --- a/packages/app/src/session/timeline/model.ts +++ b/packages/app/src/session/timeline/model.ts @@ -11,17 +11,17 @@ export { selectVisibleSessionUserMessages as selectVisibleUserMessages, } from "../session-domain" -export function createTimelineModel(input: { session: Pick }) { +export function createTimelineModel(input: { session: Pick }) { 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 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() diff --git a/packages/app/src/session/timeline/observe-element-offset.test.ts b/packages/app/src/session/timeline/observe-element-offset.test.ts index 267769b9cb0..7968565e300 100644 --- a/packages/app/src/session/timeline/observe-element-offset.test.ts +++ b/packages/app/src/session/timeline/observe-element-offset.test.ts @@ -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 + 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) diff --git a/packages/app/src/session/timeline/observe-element-offset.ts b/packages/app/src/session/timeline/observe-element-offset.ts index 941e190d3ad..bbf6455b7b0 100644 --- a/packages/app/src/session/timeline/observe-element-offset.ts +++ b/packages/app/src/session/timeline/observe-element-offset.ts @@ -20,7 +20,8 @@ export function observeElementOffsetReconnectAware { if (frame === undefined) return diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index f72c4d8d9b5..e924d346892 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -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 sessionKey: Accessor presentationKey?: Accessor 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({ @@ -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("[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) }) })} @@ -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 { diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index 5e627edfa7e..0a6263ab93a 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -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 { diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index 0a469539ddb..b7a46df96ee 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -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), + }), + ) + }), ) }) diff --git a/packages/app/test-browser/message-page-publication.test.ts b/packages/app/test-browser/message-page-publication.test.ts new file mode 100644 index 00000000000..eddecf3b9e9 --- /dev/null +++ b/packages/app/test-browser/message-page-publication.test.ts @@ -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() + } +}) diff --git a/packages/app/test-browser/timeline-cache.test.ts b/packages/app/test-browser/timeline-cache.test.ts new file mode 100644 index 00000000000..3d3842eba21 --- /dev/null +++ b/packages/app/test-browser/timeline-cache.test.ts @@ -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, + }) + 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) +}) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 5071b0368f6..a3617b86607 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -57,6 +57,8 @@ type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract 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: 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) { diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index c30d78e5587..4260aa18ac6 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -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((resolve) => (release = resolve)) diff --git a/packages/session-ui/component-tests/patch-group.spec.ts b/packages/session-ui/component-tests/patch-group.spec.ts index 23d192948d2..03ea0d720e2 100644 --- a/packages/session-ui/component-tests/patch-group.spec.ts +++ b/packages/session-ui/component-tests/patch-group.spec.ts @@ -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"]') diff --git a/packages/session-ui/component-tests/session-shell.spec.ts b/packages/session-ui/component-tests/session-shell.spec.ts index d63b88b939b..9587d9447cb 100644 --- a/packages/session-ui/component-tests/session-shell.spec.ts +++ b/packages/session-ui/component-tests/session-shell.spec.ts @@ -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"]') diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index db6e641465e..6dd34ecc596 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -334,9 +334,18 @@ export function BasicTool(props: BasicToolProps) { - - {props.children} - + + {props.children} + + } + > +
+ {props.children} +
+
) diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 1a15d2b9913..948fa029b82 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -69,7 +69,10 @@ export function createSessionTimelineRowRenderer(input: { const patchPartKeys = new WeakMap() const patchOwners = createMemo(() => { const owners = new Map() - 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[]) => { diff --git a/packages/session-ui/src/timeline/terminal-work.stories.tsx b/packages/session-ui/src/timeline/terminal-work.stories.tsx index e080752b41a..47937ec22ab 100644 --- a/packages/session-ui/src/timeline/terminal-work.stories.tsx +++ b/packages/session-ui/src/timeline/terminal-work.stories.tsx @@ -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({ id: "msg_shell_live", type: "shell", @@ -83,6 +84,7 @@ export const LiveUserCommand = { let output = args.output ? "ready\n" : "" return (
+ {stats.reads} @@ -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 = { } }} > - +
) diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index fdf8061f2c1..9e3193c459d 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -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)[]>( - (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)[] + >((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() @@ -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 ( +
{ + 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( + ':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} +
+ ) +} + +function FileAccordionItem(props: { + open: boolean + onOpenChange: (open: boolean) => void + type?: string + header: JSX.Element + children: JSX.Element +}) { + const id = createUniqueId() + return ( +
+

+ +

+ +
+ {props.children} +
+
+
+ ) +} + 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 ( - change(next.includes(value()))} - > - - - -
-
- -
- - {`\u202A${displayDirectory(props.path)}\u202C`} - - {getFilename(props.path)} -
-
-
- {props.actions} - + + +
+ +
+ + {`\u202A${displayDirectory(props.path)}\u202C`} + + {getFilename(props.path)}
- - - - {props.children} - - - +
+ {props.actions} + +
+
+ } + > + {props.children} + + ) } @@ -1277,6 +1352,7 @@ ToolRegistry.register({ 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 ( ( @@ -1616,7 +1695,7 @@ ToolRegistry.register({ - +
@@ -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 ( + + {command()} + {(value) => {value()}} + + ) + } return ( ( @@ -1696,10 +1783,7 @@ ToolRegistry.register({ )} > - - {command()} - {(value) => {value()}} - + ) }, @@ -1902,13 +1986,7 @@ ToolRegistry.register({
0}> - + {(file) => { const value = () => file().path @@ -1928,66 +2006,68 @@ ToolRegistry.register({ }) return ( - - - -
-
- -
- - {`\u202A${displayDirectory(file().path)}\u202C`} - - {getFilename(file().path)} -
-
-
- - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - - - + + change(expanded ? [...open(), value()] : open().filter((path) => path !== value())) + } + type={file().type} + header={ +
+
+ +
+ + {`\u202A${displayDirectory(file().path)}\u202C`} + + {getFilename(file().path)}
- - - - - - {(view) => ( -
- + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + -
- )} -
-
-
- + + + +
+
+ } + > + + + {(view) => ( +
+ +
+ )} +
+
+ ) }} - +
diff --git a/packages/ui/src/context/marked-base.test.ts b/packages/ui/src/context/marked-base.test.ts new file mode 100644 index 00000000000..e56efeed5cf --- /dev/null +++ b/packages/ui/src/context/marked-base.test.ts @@ -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 .", + "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", + "
HTML
", + "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() +}) diff --git a/packages/ui/src/context/marked-base.tsx b/packages/ui/src/context/marked-base.tsx index dba44f51586..dbb857dc1fe 100644 --- a/packages/ui/src/context/marked-base.tsx +++ b/packages/ui/src/context/marked-base.tsx @@ -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) => `

${paragraph.replace(/["']/g, (value) => (value === '"' ? """ : "'"))}

\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) => `

${parser.parseInline(paragraph, { async: false })}

\n`) + .join("") + } const tokens = parser.lexer(text) let code = false parser.walkTokens(tokens, (token) => {