From 44a3bf2520580b817df67de5753a6137781e43a6 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:08:07 +1000 Subject: [PATCH] perf(session-ui): skip timeline row rebuild on text deltas (#46774) --- .../timeline/projection.playwright.config.ts | 15 + .../timeline/projection.vite.config.ts | 43 +++ .../session-timeline-benchmark.fixture.ts | 57 +++- .../timeline-projection-benchmark.spec.ts | 169 ++++++++++ .../timeline/timeline-projection.md | 32 ++ .../app/src/session/timeline/projection.ts | 125 +------- .../test-browser/timeline-projection.test.ts | 292 ++++++++++++++++++ .../session-ui/src/timeline/projection.ts | 23 +- 8 files changed, 618 insertions(+), 138 deletions(-) create mode 100644 packages/app/e2e/performance/timeline/projection.playwright.config.ts create mode 100644 packages/app/e2e/performance/timeline/projection.vite.config.ts create mode 100644 packages/app/e2e/performance/timeline/timeline-projection-benchmark.spec.ts create mode 100644 packages/app/e2e/performance/timeline/timeline-projection.md create mode 100644 packages/app/test-browser/timeline-projection.test.ts diff --git a/packages/app/e2e/performance/timeline/projection.playwright.config.ts b/packages/app/e2e/performance/timeline/projection.playwright.config.ts new file mode 100644 index 00000000000..db52b8c1a53 --- /dev/null +++ b/packages/app/e2e/performance/timeline/projection.playwright.config.ts @@ -0,0 +1,15 @@ +import config from "../playwright.config" + +export default { + ...config, + testDir: ".", + testMatch: "timeline-projection-benchmark.spec.ts", + outputDir: process.env.PROJECTION_OUTPUT, + webServer: { + ...config.webServer, + command: `bun run serve -- --host 127.0.0.1 --port ${process.env.PLAYWRIGHT_PORT ?? 3000} --strictPort --outDir ${process.env.PROJECTION_BUNDLE ?? "dist"}`, + url: `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? 3000}`, + reuseExistingServer: false, + }, + use: { ...config.use, video: "off" as const, trace: "off" as const }, +} diff --git a/packages/app/e2e/performance/timeline/projection.vite.config.ts b/packages/app/e2e/performance/timeline/projection.vite.config.ts new file mode 100644 index 00000000000..7aa8348d136 --- /dev/null +++ b/packages/app/e2e/performance/timeline/projection.vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, mergeConfig } from "vite" +import config from "../../../vite.config" + +// Benchmark-only instrumentation. Normal production builds contain no probes. +export default mergeConfig( + config, + defineConfig({ + plugins: [ + { + name: "timeline-projection-measurement", + enforce: "pre", + transform(source, id) { + if (!id.replaceAll("\\", "/").endsWith("/session-ui/src/timeline/projection.ts")) return + const start = " type Turn = {" + const end = "\n export function constructMessageRows(" + if (!source.includes(start) || !source.includes(end)) throw new Error("Projection probe boundary changed") + return source + .replace( + start, + ` + const probe = globalThis.__timelineProjectionProbe + const started = probe ? performance.now() : 0 + try { +${start}`, + ) + .replace( + end, + ` + finally { + if (probe) { + probe.calls += 1 + probe.entries += messages.length + probe.ms += performance.now() - started + } + } + } +${end}`, + ) + }, + }, + ], + }), +) diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index 64d816222da..c093d1cbf8b 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -39,13 +39,7 @@ const editPart: ToolSeed = { content: [{ type: "text", text: "Edited src/regression.ts" }], metadata: { files: [ - currentFile( - "src/regression.ts", - "export const value = 'before'\n", - "export const value = 'after'\n", - 1, - 1, - ), + currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1), ], }, }, @@ -70,6 +64,8 @@ export async function setupTimelineBenchmark( eventBatch: number vcsDiff?: unknown[] turnDiffs?: unknown[] + busy?: boolean + historyShape?: "mixed" | "tool-heavy" }, ) { const events: EventPayload[] = [] @@ -77,19 +73,47 @@ export async function setupTimelineBenchmark( const currentUserMessage = options.turnDiffs ? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } } : userMessage + const messages = [ + ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)) + .flat() + .map((message) => { + if (options.historyShape !== "tool-heavy" || message.type !== "assistant") return message + return { + ...message, + content: [ + ...Array.from({ length: 6 }, (_, index) => + toolContent({ + id: `${message.id}:read:${index}`, + type: "tool", + name: "read", + state: { + status: "completed", + input: { path: `src/session/module-${index}.ts` }, + content: [{ type: "text", text: historicalSource(index, false) }], + metadata: {}, + }, + time: { + created: message.time.created, + ran: message.time.created, + completed: message.time.created + 100, + }, + }), + ), + ...message.content, + ], + } + }), + currentUserMessage, + assistantMessage, + ] await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], vcsDiff: options.vcsDiff, - pageMessages: () => ({ - items: [ - ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(), - currentUserMessage, - assistantMessage, - ], - }), + sessionStatus: options.busy ? { [sessionID]: { type: "busy" } } : undefined, + pageMessages: () => ({ items: messages }), events: () => events.splice(0, eventBatch), eventRetry: 16, }) @@ -113,6 +137,11 @@ export async function setupTimelineBenchmark( await expectSessionTitle(page, title) await expectAppVisible(scroller) return { + workload: { + messages: messages.length, + parts: messages.reduce((sum, message) => sum + (message.type === "assistant" ? message.content.length : 0), 0), + historyBytes: Buffer.byteLength(JSON.stringify(messages)), + }, scroller, text, transport: { diff --git a/packages/app/e2e/performance/timeline/timeline-projection-benchmark.spec.ts b/packages/app/e2e/performance/timeline/timeline-projection-benchmark.spec.ts new file mode 100644 index 00000000000..e5a3ad41329 --- /dev/null +++ b/packages/app/e2e/performance/timeline/timeline-projection-benchmark.spec.ts @@ -0,0 +1,169 @@ +import { createServer, type ServerResponse } from "node:http" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" +import { + buildInitialStreamEvent, + buildStreamDeltaEvents, + setupTimelineBenchmark, + textPartID, +} from "./session-timeline-benchmark.fixture" + +type Probe = { calls: number; entries: number; ms: number } +type Measurement = { frames: number[]; started: number; ready: number; rowReplacements: number; stop: () => void } +declare global { + interface Window { + __timelineProjectionProbe?: Probe + __projectionMeasurement: Measurement + } +} + +benchmark.use({ traceScope: "interaction" }) + +for (const scenario of [ + { historyTurns: 40, historyShape: "mixed" }, + { historyTurns: 320, historyShape: "mixed" }, + { historyTurns: 320, historyShape: "tool-heavy" }, +] as const) { + benchmark(`text projection ${scenario.historyTurns} ${scenario.historyShape}`, async ({ page, report }) => { + benchmark.setTimeout(120_000) + const responses = new Set() + const source = createServer((request, response) => { + response.writeHead(200, { + "content-type": "text/event-stream", + "access-control-allow-origin": "*", + "cache-control": "no-cache", + }) + response.write( + `data: ${JSON.stringify({ id: "evt_projection_connected", type: "server.connected", data: {} })}\n\n`, + ) + responses.add(response) + request.on("close", () => responses.delete(response)) + }) + await new Promise((resolve) => source.listen(0, "127.0.0.1", resolve)) + const address = source.address() + if (!address || typeof address === "string") throw new Error("Missing fixture SSE address") + let timer: ReturnType | undefined + const send = (events: OpenCodeEvent[]) => + responses.forEach((response) => events.forEach((event) => response.write(`data: ${JSON.stringify(event)}\n\n`))) + try { + await page.addInitScript( + ({ url, counters }) => { + Object.assign(window, { __testSseTransport: true }) + if (counters) window.__timelineProjectionProbe = { calls: 0, entries: 0, ms: 0 } + const fetch = window.fetch.bind(window) + const intercept = (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return fetch( + new URL(request.url).pathname === "/api/event" ? new Request(url, { signal: request.signal }) : request, + ) + } + Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: intercept }) + }, + { url: `http://127.0.0.1:${address.port}`, counters: process.env.PROJECTION_COUNTERS === "1" }, + ) + const initialStarted = performance.now() + const fixture = await setupTimelineBenchmark(page, { ...scenario, busy: true, eventBatch: 1 }) + await expect.poll(() => responses.size).toBe(1) + const deltas = buildStreamDeltaEvents(160) + send(buildInitialStreamEvent(160)) + await expect(fixture.text).toContainText("Implementation plan") + await expect(fixture.text.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "") + const initialReadyMs = performance.now() - initialStarted + await fixture.scrollToBottom() + await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible() + await benchmarkDiagnostics(page).startTrace() + await page.evaluate( + ({ partID, counters }) => { + const part = document.querySelector(`[data-timeline-part-id="${partID}"]`) + const row = part?.closest("[data-timeline-key]") + if (!row) throw new Error("Missing active row") + if (counters) { + if (!window.__timelineProjectionProbe?.calls) + throw new Error("Projection instrumentation did not observe initial construction") + window.__timelineProjectionProbe = { calls: 0, entries: 0, ms: 0 } + } + const measurement: Measurement = { + frames: [], + started: performance.now(), + ready: 0, + rowReplacements: 0, + stop: () => {}, + } + window.__projectionMeasurement = measurement + let previous: number | undefined + let frame = 0 + let current = row + const sample = (now: number) => { + if (previous !== undefined) measurement.frames.push(now - previous) + previous = now + const next = document.querySelector(`[data-timeline-part-id="${partID}"]`)?.closest("[data-timeline-key]") + if (next && next !== current) { + measurement.rowReplacements++ + current = next + } + const markdown = next?.querySelector('[data-component="markdown"][data-markdown-ready]') + if (markdown?.textContent?.includes("benchmark-complete")) { + measurement.ready = performance.now() - measurement.started + return + } + frame = requestAnimationFrame(sample) + } + measurement.stop = () => cancelAnimationFrame(frame) + frame = requestAnimationFrame(sample) + }, + { partID: textPartID, counters: process.env.PROJECTION_COUNTERS === "1" }, + ) + const emitted: number[] = [] + const started = performance.now() + // The source clock runs in Node, never waiting for a renderer acknowledgement. + await new Promise((resolve) => { + timer = setInterval(() => { + const event = deltas[emitted.length] + if (!event) throw new Error("Unexpected source overrun") + send([event]) + emitted.push(performance.now() - started) + if (emitted.length !== deltas.length) return + clearInterval(timer) + resolve() + }, 25) + }) + await expect(fixture.text).toContainText("benchmark-complete") + await expect(fixture.text.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "") + await page.waitForFunction(() => window.__projectionMeasurement.ready > 0) + const metrics = await page.evaluate(() => { + const data = window.__projectionMeasurement + data.stop() + return { + frames: data.frames, + readyMs: data.ready, + rowReplacements: data.rowReplacements, + projection: window.__timelineProjectionProbe ?? null, + } + }) + expect(emitted).toHaveLength(160) + expect(metrics.frames.length).toBeGreaterThan(0) + await benchmarkDiagnostics(page).stop() + report( + { ...metrics, initialReadyMs, emitted }, + { + ...scenario, + ...fixture.workload, + deltaBytes: Buffer.byteLength(JSON.stringify(deltas)), + counters: process.env.PROJECTION_COUNTERS === "1", + intervalMs: 25, + deltas: 160, + viewport: "1366x768", + revision: process.env.PROJECTION_REVISION, + }, + ) + if (process.env.PROJECTION_SCREENSHOT) + await page.screenshot({ + path: `${process.env.PROJECTION_SCREENSHOT}-${scenario.historyTurns}-${scenario.historyShape}.png`, + }) + } finally { + clearInterval(timer) + source.closeAllConnections() + await new Promise((resolve, reject) => source.close((error) => (error ? reject(error) : resolve()))) + } + }) +} diff --git a/packages/app/e2e/performance/timeline/timeline-projection.md b/packages/app/e2e/performance/timeline/timeline-projection.md new file mode 100644 index 00000000000..f3dbb3e9b18 --- /dev/null +++ b/packages/app/e2e/performance/timeline/timeline-projection.md @@ -0,0 +1,32 @@ +# Timeline Text Projection Benchmark + +Run from `packages/app`, against a production bundle. No OpenCode server is needed. +The fixture owns its HTTP event source and mocks all other API responses. + +```sh +bun run build -- --config e2e/performance/timeline/projection.vite.config.ts +PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/timeline/projection.playwright.config.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line +``` + +Set `PLAYWRIGHT_PORT` to an unused local port. Set `PROJECTION_BUNDLE` to preview a +previously frozen bundle, `PROJECTION_OUTPUT` for test artifacts, and +`PROJECTION_REVISION` to label results. Keep all three workloads separate: +40 historical user/assistant pairs, 320 pairs, and 320 pairs with six additional +completed read tools per assistant. The existing mixed fixture includes Markdown, +reasoning, and edit/write/patch output. Results include serialized history bytes, +message/part counts, and event bytes. + +Each case opens the real application timeline with a busy session, waits for the +initial Markdown to be ready, then sends the same 160 text deltas at a 25 ms source +cadence. The Node HTTP source does not wait for renderer acknowledgements. Results +retain every source emission time and animation-frame interval. Completion means +the final marker is in ready Markdown and has reached an animation-frame callback; +it does not claim a compositor presentation timestamp. Row replacements count the +active streaming row, not normal virtualizer mounts while scrolling. + +Use `PROJECTION_COUNTERS=1` for separate diagnostic runs. The benchmark build +instruments the full-history constructor with its call count, input entries visited, +and synchronous elapsed time. The probe is absent from normal builds and disabled +in clean timing runs. `OPENCODE_PERFORMANCE_TRACE_DIR` enables the existing Chrome +trace collector. Set `PROJECTION_SCREENSHOT` to an output path prefix for screenshots +after timing. Do not compare diagnostic timing with clean timing. diff --git a/packages/app/src/session/timeline/projection.ts b/packages/app/src/session/timeline/projection.ts index 76c3535bdbe..28307ae9ae5 100644 --- a/packages/app/src/session/timeline/projection.ts +++ b/packages/app/src/session/timeline/projection.ts @@ -1,126 +1,7 @@ -import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" -import { - reuseTimelineRows, - Timeline, - TimelineRow, - type ReasoningMode, -} from "@opencode-ai/session-ui/timeline/projection" -import { createMemo, type Accessor } from "solid-js" +import { createReactiveTimelineProjection } from "@opencode-ai/session-ui/timeline/projection" export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection" -export function createTimelineProjection(input: { - sessionMessages: Accessor - status: Accessor - reasoningMode: Accessor - shellToolDefaultOpen: Accessor - editToolDefaultOpen: Accessor - pendingUserMessageIDs: Accessor> -}) { - const sessionMessageByID = createMemo( - () => new Map(input.sessionMessages().map((message) => [message.id, message] as const)), - ) - const userContextByID = createMemo(() => { - const result = new Map() - let agent = "" - let model: ModelRef = { id: "", providerID: "" } - let userID: string | undefined - input.sessionMessages().forEach((message) => { - if (message.type === "agent-switched") agent = message.agent - if (message.type === "model-switched") model = message.model - if (message.type === "user") { - userID = message.id - const metadata = message.metadata - const localAgent = typeof metadata?.agent === "string" ? metadata.agent : agent - const localModel = metadata?.model - const localModelID = - localModel && typeof localModel === "object" && !Array.isArray(localModel) - ? typeof localModel.id === "string" - ? localModel.id - : typeof localModel.modelID === "string" - ? localModel.modelID - : undefined - : undefined - result.set(message.id, { - agent: localAgent, - model: - localModel && - typeof localModel === "object" && - !Array.isArray(localModel) && - localModelID && - typeof localModel.providerID === "string" - ? { - id: localModelID, - providerID: localModel.providerID, - variant: typeof localModel.variant === "string" ? localModel.variant : undefined, - } - : model, - }) - } - if (message.type === "shell") userID = undefined - if (message.type !== "assistant") return - agent = message.agent - model = message.model - if (userID) result.set(userID, { agent, model }) - }) - return result - }) - const assistantMessagesByParent = createMemo(() => { - const result = new Map[]>() - let userID: string | undefined - input.sessionMessages().forEach((message) => { - if (message.type === "user") userID = message.id - if (message.type === "shell") userID = undefined - if (message.type !== "assistant") return - if (!userID) userID = message.id - const messages = result.get(userID) - if (messages) { - messages.push(message) - return - } - result.set(userID, [message]) - }) - return result - }) - const projection = createMemo(() => - Timeline.constructSessionMessageRows( - input.sessionMessages(), - input.reasoningMode() !== "hidden", - input.status(), - input.pendingUserMessageIDs(), - input.shellToolDefaultOpen(), - input.editToolDefaultOpen(), - ), - ) - const activeMessageID = createMemo(() => projection().activeMessageID) - const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows(previous, projection().rows), - ) - const indexes = createMemo(() => { - const rowByKey = new Map() - const messageRowIndex = new Map() - const messageLastRowIndex = new Map() - const lastAssistantGroupKey = new Map() - rows().forEach((row, index) => { - rowByKey.set(TimelineRow.key(row), row) - if (!("userMessageID" in row)) return - if (!messageRowIndex.has(row.userMessageID)) messageRowIndex.set(row.userMessageID, index) - messageLastRowIndex.set(row.userMessageID, index) - if (row._tag === "AssistantPart") lastAssistantGroupKey.set(row.userMessageID, row.group.key) - }) - return { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey } - }) - - return { - activeMessageID, - assistantMessagesByParent, - lastAssistantGroupKey: () => indexes().lastAssistantGroupKey, - messageByID: sessionMessageByID, - messageRowIndex: () => indexes().messageRowIndex, - messageLastRowIndex: () => indexes().messageLastRowIndex, - rowByKey: () => indexes().rowByKey, - rows, - sessionMessageByID, - userContextByID, - } +export function createTimelineProjection(input: Parameters[0]) { + return createReactiveTimelineProjection(input) } diff --git a/packages/app/test-browser/timeline-projection.test.ts b/packages/app/test-browser/timeline-projection.test.ts new file mode 100644 index 00000000000..7b1b1a16ee4 --- /dev/null +++ b/packages/app/test-browser/timeline-projection.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageAssistant, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" +import { createRoot } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { + createReactiveTimelineProjection, + reuseTimelineRows, + Timeline, + TimelineRow, + type ReasoningMode, +} from "@opencode-ai/session-ui/timeline/projection" +import { createTimelineProjection } from "../src/session/timeline/projection" + +const assistant = (id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant => ({ + id, + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: 2 }, + content, +}) + +for (const factory of [createTimelineProjection, createReactiveTimelineProjection]) { + describe(factory.name, () => { + test("only crosses the renderable boundary on text deltas, while retaining live content", () => { + createRoot((dispose) => { + let visits = 0 + const [state, setState] = createStore({ + messages: [ + { + id: "old", + type: "user", + text: "history", + get time() { + visits++ + return { created: 0 } + }, + }, + assistant("old-answer", [{ type: "text", text: "History remains visible." }]), + { id: "user", type: "user", text: "question", time: { created: 1 } }, + assistant("answer", [{ type: "text", text: "" }]), + ] as SessionMessageInfo[], + }) + const projection = factory({ + sessionMessages: () => state.messages, + status: () => ({ type: "busy" }), + reasoningMode: () => "compact", + shellToolDefaultOpen: () => false, + editToolDefaultOpen: () => false, + pendingUserMessageIDs: () => new Set(), + }) + const update = (text: string) => + setState( + "messages", + 3, + produce((message) => { + if (message.type === "assistant" && message.content[0].type === "text") message.content[0].text = text + }), + ) + const empty = projection.rows() + visits = 0 + update(" \n\t") + expect(projection.rows()).toBe(empty) + expect(visits).toBe(0) + update("The first visible answer.") + const visible = projection.rows() + expect(visible.length).toBe(empty.length + 1) + expect(visits).toBeGreaterThan(0) + visits = 0 + update("The first visible answer. More streamed words.") + expect(projection.rows()).toBe(visible) + expect(visits).toBe(0) + expect(Timeline.resolveContent(projection.messageByID().get("answer"), "answer:text:0")).toMatchObject({ + text: "The first visible answer. More streamed words.", + }) + update("") + expect(projection.rows()).toEqual(empty) + expect(visits).toBeGreaterThan(0) + dispose() + }) + }) + + test("matches full construction through grouping, notices, history and preference transitions", () => { + createRoot((dispose) => { + const [state, setState] = createStore({ + messages: [ + assistant("answer", [ + { type: "reasoning", text: "Inspect the source", time: { created: 1 } }, + { + type: "tool", + id: "read", + name: "read", + state: { status: "completed", input: {}, content: [{ type: "text", text: "read" }], metadata: {} }, + time: { created: 1 }, + }, + { type: "text", text: "" }, + { + type: "tool", + id: "shell", + name: "shell", + state: { status: "running", input: {}, metadata: {} }, + time: { created: 1 }, + }, + { + type: "tool", + id: "question", + name: "question", + state: { status: "running", input: {}, metadata: {} }, + time: { created: 1 }, + }, + ]), + ] as SessionMessageInfo[], + status: { type: "busy" } as SessionStatus, + reasoning: "compact" as ReasoningMode, + shell: false, + edit: false, + pending: new Set(), + }) + const projection = factory({ + sessionMessages: () => state.messages, + status: () => state.status, + reasoningMode: () => state.reasoning, + shellToolDefaultOpen: () => state.shell, + editToolDefaultOpen: () => state.edit, + pendingUserMessageIDs: () => state.pending, + }) + let previous: TimelineRow.TimelineRow[] | undefined + const verify = () => { + const full = Timeline.constructSessionMessageRows( + state.messages, + state.reasoning !== "hidden", + state.status, + state.pending, + state.shell, + state.edit, + ) + previous = reuseTimelineRows(previous, full.rows) + expect(projection.rows()).toEqual(previous) + expect(projection.activeMessageID()).toBe(full.activeMessageID) + expect([...projection.rowByKey().keys()]).toEqual(previous.map(TimelineRow.key)) + expect([...projection.messageByID().keys()]).toEqual(state.messages.map((message) => message.id)) + previous.forEach((row, index) => { + expect(projection.messageRowIndex().get(row.userMessageID)).toBe( + previous!.findIndex((item) => item.userMessageID === row.userMessageID), + ) + expect(projection.messageLastRowIndex().get(row.userMessageID)).toBe( + previous!.findLastIndex((item) => item.userMessageID === row.userMessageID), + ) + expect(projection.rowByKey().get(TimelineRow.key(row))).toBe(projection.rows()[index]) + }) + } + const change = (update: (message: SessionMessageAssistant) => void) => { + setState( + "messages", + (message) => message.id === "answer", + produce((message) => { + if (message.type === "assistant") update(message) + }), + ) + verify() + } + verify() + change((message) => { + if (message.content[2].type === "text") message.content[2].text = "Split the context group" + }) + change((message) => { + if (message.content[2].type === "text") message.content[2].text = " \n" + }) + change((message) => { + if (message.content[0].type === "reasoning") message.content[0].text += " and its callers" + }) + setState("reasoning", "hidden") + verify() + setState("reasoning", "full") + verify() + change((message) => { + if (message.content[1].type === "tool" && message.content[1].state.status === "completed") + message.content[1].state.metadata = { loaded: ["src/example.ts"] } + }) + change((message) => { + if (message.content[3].type === "tool") + message.content[3].state = { + status: "completed", + input: {}, + metadata: {}, + content: [{ type: "text", text: "ok" }], + } + }) + setState("shell", true) + verify() + change((message) => { + if (message.content[4].type === "tool") + message.content[4].state = { + status: "completed", + input: {}, + metadata: {}, + content: [{ type: "text", text: "answered" }], + } + }) + change((message) => { + message.content.push({ + type: "tool", + id: "edit", + name: "edit", + state: { + status: "completed", + input: {}, + metadata: { files: [{ status: "deleted" }] }, + content: [{ type: "text", text: "edited" }], + }, + time: { created: 1 }, + }) + }) + setState("edit", true) + verify() + change((message) => { + const tool = message.content.at(-1) + if (tool?.type === "tool" && tool.state.status === "completed") + tool.state.metadata = { files: [{ status: "modified" }] } + }) + change((message) => { + message.content.push({ type: "reasoning", text: "Working", time: { created: 3 } }) + }) + expect(projection.rows().at(-1)?._tag).toBe("Thinking") + change((message) => { + const part = message.content.at(-1) + if (part?.type === "reasoning") part.time = { created: 3, completed: 4 } + }) + change((message) => { + message.retry = { attempt: 1, at: 3, error: { type: "Retry", message: "retry" } } + }) + change((message) => { + message.retry = undefined + message.error = { type: "Interrupted", message: "stopped" } + }) + change((message) => { + message.error = { type: "Error", message: "failed" } + }) + change((message) => { + message.error = undefined + message.time.completed = 5 + }) + setState("status", { type: "idle" }) + verify() + setState( + "messages", + produce((messages) => + messages.unshift({ id: "user", type: "user", text: "Earlier page", time: { created: 0 } }), + ), + ) + verify() + expect(projection.assistantMessagesByParent().get("user")?.[0].id).toBe("answer") + expect(projection.userContextByID().get("user")?.agent).toBe("build") + setState( + "messages", + produce((messages) => + messages.push( + { id: "notice", type: "synthetic", text: "hidden", time: { created: 6 } }, + assistant("later", [{ type: "text", text: "Later work" }]), + ), + ), + ) + verify() + setState("messages", (message) => message.id === "notice", { description: "Visible notice" }) + verify() + setState( + "messages", + produce((messages) => messages.push({ id: "pending", type: "user", text: "Steer", time: { created: 8 } })), + ) + setState("pending", new Set(["pending"])) + verify() + expect(projection.activeMessageID()).toBe("user") + setState("pending", new Set()) + verify() + expect(projection.activeMessageID()).toBe("pending") + const all = [...state.messages] + setState("messages", (messages) => messages.slice(0, 2)) + verify() + setState("messages", all) + verify() + change((message) => { + message.content = [{ type: "text", text: "Replacement" }] + }) + setState("messages", []) + verify() + setState("messages", [assistant("new-session", [{ type: "text", text: "New session" }])]) + verify() + dispose() + }) + }) + }) +} diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index e3ea09e385f..73ff609a96a 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -7,7 +7,7 @@ import type { SessionStatus, } from "@opencode-ai/client/promise" import { Option, Schema } from "effect" -import { createMemo, type Accessor } from "solid-js" +import { createMemo, mapArray, type Accessor } from "solid-js" import { currentContentDefaultOpen } from "../message/current-tool-state" import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row" @@ -82,6 +82,18 @@ export function createReactiveTimelineProjection(input: { ) const userContextByID = createMemo(() => indexUserContext(input.sessionMessages())) const assistantMessagesByParent = createMemo(() => indexAssistantMessages(input.sessionMessages())) + // Row structure depends on the empty/non-empty boundary, not each text delta. + // Keep the original content objects so row renderers still read live text. + const textParts = mapArray( + () => + input + .sessionMessages() + .flatMap((message) => + message.type === "assistant" ? message.content.filter((content) => content.type !== "tool") : [], + ), + (content) => [content, createMemo(() => !!content.text.trim())] as const, + ) + const textVisible = createMemo(() => new Map>(textParts())) const projection = createMemo(() => Timeline.constructSessionMessageRows( input.sessionMessages(), @@ -90,6 +102,10 @@ export function createReactiveTimelineProjection(input: { input.pendingUserMessageIDs?.(), input.shellToolDefaultOpen?.() ?? false, input.editToolDefaultOpen?.() ?? false, + (content, showReasoning) => + content.type === "tool" + ? renderable(content, showReasoning) + : (content.type === "text" || showReasoning) && textVisible().get(content)!(), ), ) const activeMessageID = createMemo(() => projection().activeMessageID) @@ -140,6 +156,7 @@ export namespace Timeline { pendingUserMessageIDs?: ReadonlySet, shellToolDefaultOpen = false, editToolDefaultOpen = false, + isRenderable = renderable, ) { type Turn = { id: string @@ -218,6 +235,7 @@ export namespace Timeline { turn.id === activeMessageID, shellToolDefaultOpen, editToolDefaultOpen, + isRenderable, ) }), ], @@ -234,6 +252,7 @@ export namespace Timeline { isActive: boolean, shellToolDefaultOpen = false, editToolDefaultOpen = false, + isRenderable = renderable, ) { const rows: TimelineRow.TimelineRow[] = [] const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : [])) @@ -261,7 +280,7 @@ export namespace Timeline { const appendAssistantSegment = (messages: SessionMessageAssistant[]) => { const refs = messages.flatMap((message, messageIndex) => contentEntries(message) - .filter((entry) => renderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent)) + .filter((entry) => isRenderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent)) .map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })), ) const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))