diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 238abbf7954..ca2e0caa313 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -259,6 +259,22 @@ export function event( return makeEvent(type, data) } +export function compactionStarted(data: Extract["data"]) { + return makeEvent("session.compaction.started", data) +} + +export function compactionDelta(data: Extract["data"]) { + return makeEvent("session.compaction.delta", data) +} + +export function compactionEnded(data: Extract["data"]) { + return makeEvent("session.compaction.ended", data) +} + +export function compactionFailed(data: Extract["data"]) { + return makeEvent("session.compaction.failed", data) +} + export function toolInputStarted(data: Extract["data"]) { return makeEvent("session.tool.input.started", data) } diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index e1589870b2f..3749c4e64bf 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -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") }) diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index bc420ff26c0..45f1ca218be 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -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() diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 24e04b4147e..eb03cc9f078 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -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 }) => { diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index fee19a8173e..03c001b25ac 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -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 ( +
+ + +
+
+ +
+
+
+ + + {error()} + + +
+ ) +} + export function AssistantTextContent(props: { id: string text: string diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index fa45f38e6a0..f4c42b3ea8c 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -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), diff --git a/packages/session-ui/src/storybook/index.ts b/packages/session-ui/src/storybook/index.ts index 93ea26fa947..40679a94213 100644 --- a/packages/session-ui/src/storybook/index.ts +++ b/packages/session-ui/src/storybook/index.ts @@ -6,7 +6,10 @@ export { activeQuestionRequest, attachmentsAndCommentsDocument, attachmentsAndCommentsPresentation, + compactionCancelledDocument, compactionDocument, + compactionFailedDocument, + compactionRunningDocument, editThenTestDocument, emptySessionDocument, fileChangeLoadingDocument, diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 1574572efb3..a7c6684889a 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -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) => { diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index fa844999150..6078902a1d7 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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 @@ -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 ( + + {(message) => ( +
+
+ +
+
+ )} +
( + + ), +} + export const CompactionAndContinuation = { render: () => ( ( + + ), +} + +export const CompactionCancelled = { + render: () => ( + + ), +} + export const AnsweredQuestionAndDeclinedCommand = { render: () => (