diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index 040e376d9d6..ee055fd965d 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -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 }) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index ac6da664afa..b61e2b5b5aa 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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"] { diff --git a/packages/session-ui/src/timeline/error-spacing.stories.tsx b/packages/session-ui/src/timeline/error-spacing.stories.tsx new file mode 100644 index 00000000000..43e322856b4 --- /dev/null +++ b/packages/session-ui/src/timeline/error-spacing.stories.tsx @@ -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: () => ( +
+ + preset.id === "detailed")!.value} + /> + +
+ ), +} diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index e1abec240f5..2cfdecc9915 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -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}`, diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 5b472a59fca..4484f0a4c41 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -213,43 +213,45 @@ export function createSessionTimelineRowRenderer(input: { const key = () => `context:${row().group.key}` return ( - { - const parts = tools.map((tool) => patchPartKeys.get(tool)!) +
part.type === "notice") ? "" : undefined}> + { + 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} + /> +
) } @@ -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"), }} >