mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 01:31:22 +03:00
fix(session-ui): restore compaction summaries (#44311)
This commit is contained in:
committed by
GitHub
Unverified
parent
3fd93b659b
commit
0a5349c51c
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user