perf(session-ui): skip timeline row rebuild on text deltas (#46774)

This commit is contained in:
Luke Parker
2026-09-02 21:08:07 +10:00
committed by GitHub
Unverified
parent 998086d6fb
commit 44a3bf2520
8 changed files with 618 additions and 138 deletions
@@ -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.
+3 -122
View File
@@ -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()
})
})
})
}
+21 -2
View File
@@ -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))