fix(app): preserve timeline tool state while streaming

This commit is contained in:
LukeParkerDev 2026-05-20 12:43:20 +10:00
parent 66d409d679
commit e66084ff5b
5 changed files with 359 additions and 52 deletions

View file

@ -0,0 +1,249 @@
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readToolState(page)).toEqual({
expanded: false,
row: "AssistantPart",
streamedTextVisible: true,
})
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function expectExpanded(locator: Locator, expected: boolean) {
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
}
async function readToolState(page: Page) {
return page.locator(`[data-timeline-part-id="${editPartID}"]`).first().evaluate((element, textPartID) => ({
expanded: (() => {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
})(),
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}), textPartID)
}
function readExpanded(element: Element) {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event") return sse(route, events.splice(0))
if (path === "/global/config" || path === "/config" || path === "/provider/auth" || path === "/mcp" || path === "/session/status") return json(route, {})
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
return json(route, {})
})
}
function project() {
return { id: projectID, worktree: directory, vcs: "git", name: "timeline-state-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }
}
function session() {
return { id: sessionID, slug: "timeline-state-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events: EventPayload[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
headers: { "access-control-allow-origin": "*" },
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
})
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}

View file

@ -1,4 +1,16 @@
import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
import {
createEffect,
createMemo,
createSignal,
For,
Index,
on,
onCleanup,
Show,
mapArray,
type Accessor,
type JSX,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
@ -540,6 +552,7 @@ export function MessageTimeline(props: {
const [bar, setBar] = createStore({
ms: pace(640),
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
@ -980,29 +993,34 @@ export function MessageTimeline(props: {
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
if (row.group.type === "context") {
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
if (row().group.type === "context") {
const parts = createMemo(() => {
const group = row.group
const group = row().group
if (group.type !== "context") return emptyTools
return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
})
return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} />
}
const message = createMemo(() => {
const group = row.group
const group = row().group
if (group.type !== "part") return
return messageByID().get(group.ref.messageID)
})
const part = createMemo(() => {
const group = row.group
const group = row().group
if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID)
})
const defaultOpen = createMemo(() => {
const item = part()
if (!item) return
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
})
return (
<Show when={message()}>
@ -1012,13 +1030,11 @@ export function MessageTimeline(props: {
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(
part(),
settings.general.shellToolPartsExpanded(),
settings.general.editToolPartsExpanded(),
)}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false}
/>
)}
@ -1028,25 +1044,25 @@ export function MessageTimeline(props: {
)
}
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
const anchor = () => {
const row = input.row
const row = input.row()
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
}
const previousUserMessage = () => {
const row = input.row
const row = input.row()
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
}
const previousAssistantPart = () => {
const row = input.row
const row = input.row()
return row._tag === "AssistantPart" && row.previousAssistantPart
}
return (
<div
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row.userMessageID}
data-timeline-row={input.row._tag}
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
data-message-id={input.row().userMessageID}
data-timeline-row={input.row()._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
@ -1062,14 +1078,15 @@ export function MessageTimeline(props: {
)
}
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
switch (row._tag) {
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
switch (row()._tag) {
case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() =>
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
)
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={commentStripRow}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
@ -1102,17 +1119,18 @@ export function MessageTimeline(props: {
)
}
case "UserMessage": {
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(row.userMessageID)
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
<Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
</div>
</div>
)}
@ -1121,13 +1139,14 @@ export function MessageTimeline(props: {
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={turnDividerRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
</div>
@ -1136,22 +1155,24 @@ export function MessageTimeline(props: {
)
}
case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={assistantPartRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
{renderAssistantPartGroup(row)}
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
{renderAssistantPartGroup(assistantPartRow)}
</div>
</div>
</TimelineRowFrame>
)
}
case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={thinkingRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={row.reasoningHeading}
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
@ -1159,29 +1180,32 @@ export function MessageTimeline(props: {
)
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={retryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={diffSummaryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={row.diffs} />
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
</div>
</TimelineRowFrame>
)
}
case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={errorRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{row.text}
{errorRow().text}
</Card>
</div>
</TimelineRowFrame>
@ -1193,11 +1217,9 @@ export function MessageTimeline(props: {
}
function TimelineRowView(props: { rowKey: string }) {
return (
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
{(item) => renderTimelineRow(item)}
</Show>
)
const row = () => timelineRowByKey().get(props.rowKey)!
return renderTimelineRow(row)
}
return (

View file

@ -29,6 +29,8 @@ export interface BasicToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
forceOpen?: boolean
defer?: boolean
locked?: boolean
@ -83,7 +85,7 @@ export function BasicTool(props: BasicToolProps) {
open: props.defaultOpen ?? false,
ready: !props.defer && (props.defaultOpen ?? false),
})
const open = () => state.open
const open = () => props.open ?? state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
@ -110,8 +112,15 @@ export function BasicTool(props: BasicToolProps) {
if (props.defer && open()) scheduleReady(true)
})
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
props.onOpenChange?.(value)
}
createEffect(() => {
if (props.forceOpen) setState("open", true)
if (!props.forceOpen) return
if (open()) return
setOpen(true)
})
createEffect(
@ -166,7 +175,7 @@ export function BasicTool(props: BasicToolProps) {
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setState("open", value)
setOpen(value)
}
const trigger = () => (

View file

@ -174,6 +174,8 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
deferToolContent?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
@ -1260,6 +1262,8 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
@ -1277,6 +1281,8 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
deferContent?: boolean
forceOpen?: boolean
locked?: boolean
@ -1375,6 +1381,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
})
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open)
return (
<Show when={!hideQuestion()}>
@ -1398,6 +1406,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
error={error()}
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
subtitle={taskSubtitle()}
href={taskHref()}
/>
@ -1416,6 +1426,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
open={controlledOpen()}
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
deferContent={props.deferToolContent}
/>
</Match>

View file

@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
error: string
title?: string
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
subtitle?: string
href?: string
}
@ -22,9 +24,22 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
open: props.defaultOpen ?? false,
copied: false,
})
const open = () => state.open
const open = () => props.open ?? state.open
const copied = () => state.copied
const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"])
const [split, rest] = splitProps(props, [
"tool",
"error",
"title",
"defaultOpen",
"open",
"onOpenChange",
"subtitle",
"href",
])
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
props.onOpenChange?.(value)
}
const name = createMemo(() => {
if (split.title) return split.title
const map: Record<string, string> = {
@ -81,7 +96,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
class="tool-collapsible"
data-open={open() ? "true" : "false"}
open={open()}
onOpenChange={(value) => setState("open", value)}
onOpenChange={setOpen}
>
<Collapsible.Trigger>
<div data-component="tool-trigger">