diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts new file mode 100644 index 0000000000..271c2e2cab --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -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 +} + +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('[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('[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) { + 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, "") +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 1df861cfc3..9935a62073 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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>({}) 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) => { + 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 + return } 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 ( @@ -1012,13 +1030,11 @@ export function MessageTimeline(props: { 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; 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 (
{ - switch (row._tag) { + const renderTimelineRow = (row: Accessor) => { + switch (row()._tag) { case "CommentStrip": { + const commentStripRow = row as Accessor> const comments = createMemo(() => - getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), + getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), ) return ( - +
@@ -1102,17 +1119,18 @@ export function MessageTimeline(props: { ) } case "UserMessage": { + const userMessageRow = row as Accessor> const message = createMemo(() => { - const m = messageByID().get(row.userMessageID) + const m = messageByID().get(userMessageRow().userMessageID) if (m?.role === "user") return m }) return ( - + {(message) => (
- +
)} @@ -1121,13 +1139,14 @@ export function MessageTimeline(props: { ) } case "TurnDivider": { + const turnDividerRow = row as Accessor> return ( - +
@@ -1136,22 +1155,24 @@ export function MessageTimeline(props: { ) } case "AssistantPart": { + const assistantPartRow = row as Accessor> return ( - +
-
- {renderAssistantPartGroup(row)} +
+ {renderAssistantPartGroup(assistantPartRow)}
) } case "Thinking": { + const thinkingRow = row as Accessor> return ( - +
@@ -1159,29 +1180,32 @@ export function MessageTimeline(props: { ) } case "Retry": { + const retryRow = row as Accessor> return ( - +
- +
) } case "DiffSummary": { + const diffSummaryRow = row as Accessor> return ( - +
- +
) } case "Error": { + const errorRow = row as Accessor> return ( - +
- {row.text} + {errorRow().text}
@@ -1193,11 +1217,9 @@ export function MessageTimeline(props: { } function TimelineRowView(props: { rowKey: string }) { - return ( - - {(item) => renderTimelineRow(item)} - - ) + const row = () => timelineRowByKey().get(props.rowKey)! + + return renderTimelineRow(row) } return ( diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index cb9f9b17fe..213a48e11f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -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 = () => ( diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index eeaf895e29..d06d9a2fb7 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -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 ( @@ -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} /> diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index c85e7d55cd..b75671bae5 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit, "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 = { @@ -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} >