mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
perf(session-ui): skip timeline row rebuild on text deltas (#46774)
This commit is contained in:
committed by
GitHub
Unverified
parent
998086d6fb
commit
44a3bf2520
@@ -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 },
|
||||
}
|
||||
@@ -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}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<ServerResponse>()
|
||||
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<void>((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<typeof setInterval> | 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<void>((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<void>((resolve, reject) => source.close((error) => (error ? reject(error) : resolve())))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
)
|
||||
const userContextByID = createMemo(() => {
|
||||
const result = new Map<string, { agent: string; model: ModelRef }>()
|
||||
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<string, Extract<SessionMessageInfo, { type: "assistant" }>[]>()
|
||||
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<string, TimelineRow.TimelineRow>()
|
||||
const messageRowIndex = new Map<string, number>()
|
||||
const messageLastRowIndex = new Map<string, number>()
|
||||
const lastAssistantGroupKey = new Map<string, string>()
|
||||
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<typeof createReactiveTimelineProjection>[0]) {
|
||||
return createReactiveTimelineProjection(input)
|
||||
}
|
||||
|
||||
@@ -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<string>(),
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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<Content, Accessor<boolean>>(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<string>,
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user