fix(session-ui): space errors and grouped updates in timeline (#52876)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-06 00:38:15 +00:00
committed by GitHub
Unverified
parent 48b295aef4
commit 3ca0ac718b
5 changed files with 136 additions and 36 deletions
@@ -1,5 +1,25 @@
import { expect, story } from "../../storybook/playwright/story"
story("spaces an error between a shell result and expanded updates", async ({ mount }) => {
const timeline = await mount("current-session-error-spacing--error-and-updates")
const shell = timeline.locator('[data-component="bash-output"]')
const error = timeline.locator('[data-kind="session-error-card"]')
const updates = timeline.locator('[data-component="collapsed-tool-group"] [data-slot="collapsible-trigger"]')
const shellBox = (await shell.boundingBox())!
const errorBox = (await error.boundingBox())!
const updatesBox = (await updates.boundingBox())!
expect(errorBox.y - (shellBox.y + shellBox.height)).toBe(24)
expect(updatesBox.y - (errorBox.y + errorBox.height)).toBe(8)
expect(updatesBox.x).toBe(errorBox.x)
await updates.click()
const notice = timeline.locator('[data-slot="session-timeline-notice"]')
await expect(notice).toContainText("Instructions updated")
const expandedBox = (await updates.boundingBox())!
expect((await notice.boundingBox())!.y - (expandedBox.y + expandedBox.height)).toBe(0)
expect((await notice.boundingBox())!.x - expandedBox.x).toBe(12)
expect(expandedBox.height).toBe(28)
})
for (const streaming of [false, true]) {
story(`renders Mermaid in the ${streaming ? "streaming" : "completed"} timeline`, async ({ mount, page }) => {
await page.setViewportSize({ width: 390, height: 900 })
@@ -688,6 +688,10 @@
}
}
[data-notices-only] > [data-component="collapsed-tool-group"] > [data-component="collapsible"].tool-collapsible {
--tool-content-gap: 0px;
}
[data-component="context-tool-group-list"] {
padding: 0;
display: flex;
@@ -1585,7 +1589,8 @@
background: var(--v2-background-bg-layer-02);
}
:root body
:root
body
[data-workspace-session]
[data-component="user-message"]:not([data-pending])
[data-slot="user-message-text"] {
@@ -0,0 +1,69 @@
import type { SessionDocument } from "../document"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME } from "../storybook/current-session-fixtures"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { timelinePresets } from "./detail"
import { SessionTimeline } from "./session-timeline"
export default {
title: "OpenCode/Conversation/Error spacing",
id: "current-session-error-spacing",
component: SessionTimeline,
parameters: { layout: "fullscreen" },
}
const document = {
sessionID: CURRENT_SESSION_ID,
status: { type: "idle" },
diffs: [],
messages: [
{
id: "msg_user_shell_error",
type: "user",
text: "Check the shell command and its result.",
metadata: { agent: "build", model: STORY_MODEL },
time: { created: STORY_TIME },
},
{
id: "msg_shell_error",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [
{
type: "tool",
id: "tool_shell_error",
name: "shell",
state: {
status: "completed",
input: { command: "cd ~/Documents/Local/opencode && ls packages/tui" },
content: [{ type: "text", text: "AGENTS.md\nbunfig.toml\nnode_modules\npackage.json\nsrc" }],
metadata: {},
},
time: { created: STORY_TIME + 100, ran: STORY_TIME + 150, completed: STORY_TIME + 300 },
},
],
error: { type: "ProviderError", message: "getaddrinfo ENOTFOUND opencode.ai" },
time: { created: STORY_TIME + 50, completed: STORY_TIME + 400 },
},
{
id: "msg_after_error_updates",
type: "system",
description: "Instructions updated: core/codemode, core/mcp-guidance",
text: "Instructions updated: core/codemode, core/mcp-guidance",
time: { created: STORY_TIME + 500 },
},
],
} satisfies SessionDocument
export const ErrorAndUpdates = {
render: () => (
<section class="mx-auto min-h-screen w-full max-w-[720px] bg-v2-background-bg-base p-6">
<CurrentSessionProviders document={document}>
<SessionTimeline
document={document}
timelineDetail={timelinePresets.find((preset) => preset.id === "detailed")!.value}
/>
</CurrentSessionProviders>
</section>
),
}
@@ -550,7 +550,10 @@ function groupMessages(rows: TimelineRow.TimelineRow[], detail: TimelineDetail,
? new TimelineRow.AssistantPart({
userMessageID: row.userMessageID,
previousAssistantPart: Predicate.isTagged(previous, "AssistantPart"),
spacing: Predicate.isTagged(previous, "AssistantPart") ? "tool" : undefined,
spacing:
Predicate.isTagged(previous, "AssistantPart") || Predicate.isTagged(previous, "Error")
? "tool"
: undefined,
group: {
type: "context",
key: `message:${row.messageID}`,
@@ -213,43 +213,45 @@ export function createSessionTimelineRowRenderer(input: {
const key = () => `context:${row().group.key}`
return (
<SessionContextToolGroup
parts={parts()}
patchGroupKey={(tools) => {
const parts = tools.map((tool) => patchPartKeys.get(tool)!)
<div class="contents" data-notices-only={parts().every((part) => part.type === "notice") ? "" : undefined}>
<SessionContextToolGroup
parts={parts()}
patchGroupKey={(tools) => {
const parts = tools.map((tool) => patchPartKeys.get(tool)!)
// After a split, only the subgroup with the earliest surviving member keeps the old anchor.
const key =
parts
.map((part) => patchGroupKeys.get(part))
.find((key) => key !== undefined && parts.includes(patchOwners().get(key)!)) ?? parts[0]!
// After a split, only the subgroup with the earliest surviving member keeps the old anchor.
const key =
parts
.map((part) => patchGroupKeys.get(part))
.find((key) => key !== undefined && parts.includes(patchOwners().get(key)!)) ?? parts[0]!
parts.forEach((part) => patchGroupKeys.set(part, key))
parts.forEach((part) => patchGroupKeys.set(part, key))
return key
}}
reasoningDefaultOpen={
input.timelineDetail
? input.timelineDetail().thinking.details === "expanded"
: input.reasoningMode() === "full"
}
reasoningOpen={(id) => input.disclosure.value(id)}
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
toolDefaultOpen={(tool) => (input.timelineDetail ? contentDefaultOpen(tool) : false)}
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
fileOpen={(path) =>
input.disclosure.value(`patch:${path}`) ?? input.timelineDetail?.().edit.details === "expanded"
}
onFileOpenChange={(path, open) => input.disclosure.set(`patch:${path}`, open)}
open={input.disclosure.value(key()) === true}
busy={
workingTurn(row().userMessageID) &&
input.projection.lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
onOpenChange={(open) => input.disclosure.set(key(), open)}
onSizeChange={onSizeChange}
/>
return key
}}
reasoningDefaultOpen={
input.timelineDetail
? input.timelineDetail().thinking.details === "expanded"
: input.reasoningMode() === "full"
}
reasoningOpen={(id) => input.disclosure.value(id)}
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
toolDefaultOpen={(tool) => (input.timelineDetail ? contentDefaultOpen(tool) : false)}
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
fileOpen={(path) =>
input.disclosure.value(`patch:${path}`) ?? input.timelineDetail?.().edit.details === "expanded"
}
onFileOpenChange={(path, open) => input.disclosure.set(`patch:${path}`, open)}
open={input.disclosure.value(key()) === true}
busy={
workingTurn(row().userMessageID) &&
input.projection.lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
onOpenChange={(open) => input.disclosure.set(key(), open)}
onSizeChange={onSizeChange}
/>
</div>
)
}
@@ -471,6 +473,7 @@ export function createSessionTimelineRowRenderer(input: {
"md:max-w-[1000px] md:mx-auto": input.centered?.(),
"pt-2": Predicate.isTagged(props.row, "AssistantPart") && props.row.spacing === "tool",
"pt-4": Predicate.isTagged(props.row, "AssistantPart") && props.row.spacing === "content",
"pt-6": Predicate.isTagged(props.row, "Error"),
}}
>
<div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>