fix(session-ui): restore compaction summaries (#44311)

This commit is contained in:
Luke Parker
2026-08-23 18:57:06 +10:00
committed by GitHub
Unverified
parent 3fd93b659b
commit 0a5349c51c
10 changed files with 261 additions and 27 deletions
@@ -259,6 +259,22 @@ export function event(
return makeEvent(type, data)
}
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
return makeEvent("session.compaction.started", data)
}
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
return makeEvent("session.compaction.delta", data)
}
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
return makeEvent("session.compaction.ended", data)
}
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
return makeEvent("session.compaction.failed", data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
@@ -45,21 +45,17 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
test("shows a comment button when a diff line is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -1,6 +1,15 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
import {
compactionDelta,
compactionEnded,
compactionFailed,
compactionStarted,
event,
session,
sessionID,
setupTimeline,
} from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -73,13 +82,93 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
sessionID,
reason: "manual",
recent: "",
}),
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
sessionID,
text: "## Checkpoint\n\nStreamed implementation details.",
}),
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.send(
compactionEnded({
sessionID,
reason: "manual",
text: "## Checkpoint\n\nFinal implementation details.",
recent: "",
}),
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}),
)
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await timeline.send(
compactionFailed({
sessionID,
reason: "manual",
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
}),
)
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [toolPart("call_subagent", "subagent", "streaming", {})],
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
@@ -87,10 +176,7 @@ test("shows a delegating row while subagent input streams", async ({ page }) =>
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute(
"aria-label",
"Delegating agent...",
)
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
@@ -99,8 +99,8 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
@@ -112,8 +112,10 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group.locator('[data-component="context-tool-group-list"] [data-slot="basic-tool-tool-subtitle"]'),
).toHaveText("a.ts")
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
@@ -12,10 +12,12 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card"
import type {
PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageCompaction,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { SessionUserActions, SessionUserComment } from "../actions"
@@ -383,6 +385,37 @@ export function MessageDivider(props: { label: string }) {
)
}
export function SessionCompactionMessage(props: { message: SessionMessageCompaction; error: string }) {
const i18n = useI18n()
const summary = () => (props.message.status === "failed" ? "" : props.message.summary)
const error = () => {
if (props.message.status !== "failed" || props.message.error.type === "aborted") return ""
return props.error
}
return (
<div data-component="session-compaction-message">
<MessageDivider label={i18n.t("ui.messagePart.compaction")} />
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
<PacedMarkdown
text={summary()}
cacheKey={props.message.id}
streaming={props.message.status === "running"}
/>
</div>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()}
</Card>
</Show>
</div>
)
}
export function AssistantTextContent(props: {
id: string
text: string
@@ -917,7 +917,7 @@ export const retryDocument = document(
{ type: "busy" },
)
export const compactionDocument = document([
const compactionPrelude = [
user("msg_user_compact", "Continue the implementation after compacting context.", 60_000),
assistant({
id: "msg_assistant_before_compact",
@@ -926,6 +926,26 @@ export const compactionDocument = document([
content: [{ type: "text", text: "I inspected the timeline and identified the current message boundary." }],
error: { type: "ExecutionInterrupted", message: "Context compaction started" },
}),
] satisfies SessionMessageInfo[]
export const compactionRunningDocument = document(
[
...compactionPrelude,
{
id: "msg_compaction_running",
type: "compaction",
status: "running",
reason: "auto",
summary: "## Goal\n\nRestore compaction output while preserving timeline notices.",
recent: "",
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[],
{ type: "busy" },
)
export const compactionDocument = document([
...compactionPrelude,
{
id: "msg_compaction_complete",
type: "compaction",
@@ -943,6 +963,30 @@ export const compactionDocument = document([
}),
] satisfies SessionMessageInfo[])
export const compactionFailedDocument = document([
...compactionPrelude,
{
id: "msg_compaction_failed",
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "compaction.failed", message: "The provider rejected the compaction request." },
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[])
export const compactionCancelledDocument = document([
...compactionPrelude,
{
id: "msg_compaction_cancelled",
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[])
export const subagentDocument = document(
[
user("msg_user_subagent", "Delegate the fixture review and report the result.", 70_000),
@@ -6,7 +6,10 @@ export {
activeQuestionRequest,
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
compactionDocument,
compactionFailedDocument,
compactionRunningDocument,
editThenTestDocument,
emptySessionDocument,
fileChangeLoadingDocument,
@@ -533,7 +533,7 @@ function cleanHeading(value: string) {
.trim()
}
function unwrapErrorMessage(message: string) {
export function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => Option.getOrUndefined(decodeJson(value))
const read = (value: string) => {
@@ -21,8 +21,9 @@ import {
SessionUserMessage,
currentContentDefaultOpen,
} from "../message/current-message"
import { SessionCompactionMessage } from "../message/message-content"
import { SessionRetry } from "../components/session-retry"
import { createReactiveTimelineProjection, Timeline, TimelineRow } from "./projection"
import { createReactiveTimelineProjection, Timeline, TimelineRow, unwrapErrorMessage } from "./projection"
const emptyAssistantMessages: SessionMessageAssistant[] = []
type Projection = ReturnType<typeof createReactiveTimelineProjection>
@@ -203,7 +204,6 @@ export function createSessionTimelineRowRenderer(input: {
}
return { label: message.description ?? message.text }
}
if (message.type === "compaction") return { label: i18n.t("ui.messagePart.compaction"), data: message.status }
if (message.type !== "synthetic") return undefined
if (message.description === "Continuing after restart") return { label: message.description }
const source = typeof message.metadata?.source === "string" ? message.metadata.source : undefined
@@ -315,6 +315,15 @@ export function createSessionTimelineRowRenderer(input: {
return value
}
const message = createMemo(() => input.projection.messageByID().get(current().messageID))
const compaction = createMemo(() => {
const value = message()
return value?.type === "compaction" ? value : undefined
})
const compactionError = createMemo(() => {
const value = compaction()
if (value?.status !== "failed") return ""
return unwrapErrorMessage(value.error.message)
})
const moved = createMemo(() => {
const value = message()
return value?.type === "location-switched" ? value : undefined
@@ -325,6 +334,15 @@ export function createSessionTimelineRowRenderer(input: {
})
return (
<Frame row={current()}>
<Show when={compaction()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-compaction">
<SessionCompactionMessage message={message()} error={compactionError()} />
</div>
</div>
)}
</Show>
<Show
when={moved()}
fallback={
@@ -3,7 +3,10 @@ import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
compactionDocument,
compactionFailedDocument,
compactionRunningDocument,
instructionsUpdatedMultipleDocument,
instructionsUpdatedSingleDocument,
requestHistoryDocument,
@@ -61,6 +64,17 @@ export const ProviderRetry = {
),
}
export const CompactionInProgress = {
render: () => (
<CurrentSessionTimelineStory
title="Compaction in progress"
description="The production divider remains stable while the context summary streams below it."
document={compactionRunningDocument}
width="600px"
/>
),
}
export const CompactionAndContinuation = {
render: () => (
<CurrentSessionTimelineStory
@@ -72,6 +86,28 @@ export const CompactionAndContinuation = {
),
}
export const CompactionFailed = {
render: () => (
<CurrentSessionTimelineStory
title="Compaction failed"
description="A failed compaction keeps the production divider and shows the provider error."
document={compactionFailedDocument}
width="600px"
/>
),
}
export const CompactionCancelled = {
render: () => (
<CurrentSessionTimelineStory
title="Compaction cancelled"
description="An interrupted compaction keeps the production divider without an empty error block."
document={compactionCancelledDocument}
width="600px"
/>
),
}
export const AnsweredQuestionAndDeclinedCommand = {
render: () => (
<CurrentSessionTimelineStory