Skip to content

Commit 571c3c4

Browse files
authored
feat(desktop): show compaction progress and outcomes (#48152)
1 parent 50ed7c4 commit 571c3c4

3 files changed

Lines changed: 101 additions & 19 deletions

File tree

packages/app/e2e/regression/session-timeline-notices.spec.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
compactionFailed,
88
compactionStarted,
99
directory,
10-
event,
1110
session,
1211
sessionID,
1312
setupTimeline,
@@ -87,12 +86,13 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
8786
expect(ownerWarnings).toEqual([])
8887
})
8988

90-
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
89+
test("renders compaction progress, summary, and outcome in order", async ({ page }) => {
9190
const timeline = await setupTimeline(page, {
9291
settings: {
9392
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
9493
},
9594
sessionMessages: [user, assistant(true)],
95+
sessionStatus: { [sessionID]: { type: "busy" } },
9696
})
9797

9898
await timeline.send(
@@ -104,7 +104,15 @@ test("renders a compaction summary while it streams and after completion", async
104104
)
105105

106106
const compaction = page.locator('[data-component="session-compaction-message"]')
107-
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
107+
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
108+
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
109+
await expect(compaction.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
110+
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
111+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
112+
await expect(page.locator('[data-component="session-working"]')).toHaveCount(0)
113+
114+
await page.setViewportSize({ width: 480, height: 900 })
115+
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeInViewport()
108116

109117
await timeline.send(
110118
compactionDelta({
@@ -114,6 +122,8 @@ test("renders a compaction summary while it streams and after completion", async
114122
)
115123
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
116124
await expect(compaction).toContainText("Streamed implementation details.")
125+
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
126+
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
117127

118128
await timeline.send(
119129
compactionEnded({
@@ -125,6 +135,18 @@ test("renders a compaction summary while it streams and after completion", async
125135
)
126136
await expect(compaction).toContainText("Final implementation details.")
127137
await expect(compaction).not.toContainText("Streamed implementation details.")
138+
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
139+
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
140+
await expect
141+
.poll(async () => {
142+
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
143+
const completed = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
144+
return !!summary && !!completed && completed.y >= summary.y + summary.height
145+
})
146+
.toBe(true)
147+
await expect(compaction.getByRole("status")).toHaveCount(0)
148+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
149+
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
128150
})
129151

130152
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
@@ -146,7 +168,10 @@ test("updates running compactions to failed and cancelled boundaries", async ({
146168

147169
const compactions = page.locator('[data-component="session-compaction-message"]')
148170
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
149-
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
171+
await expect(failed.getByText("Session compaction started", { exact: true })).toBeVisible()
172+
await expect(failed.getByText("Session compaction failed", { exact: true })).toBeVisible()
173+
await expect(failed.getByText("Session compacted", { exact: true })).toHaveCount(0)
174+
await expect(failed.getByRole("status")).toHaveCount(0)
150175
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
151176
await expect(failed).not.toContainText("Partial summary that should be discarded.")
152177

@@ -164,11 +189,48 @@ test("updates running compactions to failed and cancelled boundaries", async ({
164189

165190
await expect(compactions).toHaveCount(2)
166191
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
167-
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
192+
await expect(cancelled.getByText("Session compaction started", { exact: true })).toBeVisible()
193+
await expect(cancelled.getByText("Session compaction cancelled", { exact: true })).toBeVisible()
194+
await expect(cancelled.getByText("Session compacted", { exact: true })).toHaveCount(0)
195+
await expect(cancelled.getByRole("status")).toHaveCount(0)
168196
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
169197
await expect(cancelled).not.toContainText("Summary before cancellation.")
170198
})
171199

200+
test("shows an interrupted outcome when stopping automatic compaction", async ({ page }) => {
201+
const timeline = await setupTimeline(page, {
202+
sessionMessages: [user, assistant(true)],
203+
sessionStatus: { [sessionID]: { type: "busy" } },
204+
})
205+
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
206+
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
207+
const compaction = page.locator('[data-component="session-compaction-message"]')
208+
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
209+
await expect(compaction).toContainText("Partial automatic summary.")
210+
211+
const request = page.waitForRequest(
212+
(request) =>
213+
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
214+
)
215+
await page.getByRole("button", { name: "Stop", exact: true }).click()
216+
await request
217+
await timeline.send(
218+
compactionFailed({
219+
sessionID,
220+
reason: "auto",
221+
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
222+
}),
223+
)
224+
225+
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
226+
await expect(compaction.getByText("Session compaction interrupted", { exact: true })).toBeVisible()
227+
await expect(compaction.getByText("Session compaction failed", { exact: true })).toHaveCount(0)
228+
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
229+
await expect(compaction.getByRole("status")).toHaveCount(0)
230+
await expect(compaction).not.toContainText("Partial automatic summary.")
231+
await expect(compaction).not.toContainText("Compaction was interrupted")
232+
})
233+
172234
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
173235
await setupTimeline(page, {
174236
settings: {

packages/session-ui/src/message/message-content.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
392392
const i18n = useI18n()
393393
const summary = () => (props.message.status === "failed" ? "" : props.message.summary)
394394
const error = () => {
395-
if (props.message.status !== "failed" || props.message.error.type === "aborted") return ""
395+
if (props.message.status !== "failed") return ""
396+
if (props.message.error.type === "aborted" || props.message.error.type === "compaction.interrupted") return ""
396397
return props.error
397398
}
398399
const compact = createMemo(
@@ -410,24 +411,33 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
410411
output: compact().format(output),
411412
})
412413
}
413-
const label = createMemo(() =>
414-
[
415-
i18n.t(
416-
props.message.status === "completed" && props.message.providerContext
417-
? "ui.messagePart.providerCompaction"
418-
: "ui.messagePart.compaction",
419-
),
420-
usage(),
421-
]
422-
.filter(Boolean)
423-
.join(" · "),
424-
)
414+
const outcome = () => {
415+
if (props.message.status !== "failed")
416+
return props.message.status === "completed" && props.message.providerContext
417+
? "ui.messagePart.providerCompaction"
418+
: "ui.messagePart.compaction"
419+
if (props.message.error.type === "aborted") return "ui.messagePart.compaction.cancelled"
420+
if (props.message.error.type === "compaction.interrupted") return "ui.messagePart.compaction.interrupted"
421+
return "ui.messagePart.compaction.failed"
422+
}
423+
const label = createMemo(() => [i18n.t(outcome()), usage()].filter(Boolean).join(" · "))
425424

426425
return (
427426
<div data-component="session-compaction-message">
428427
<div class="py-2">
429-
<TimelineSeparator label={label()} />
428+
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
430429
</div>
430+
<Show when={props.message.status === "running"}>
431+
<div role="status" class="py-2">
432+
<BasicTool
433+
icon="archive"
434+
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
435+
status="running"
436+
locked
437+
hideDetails
438+
/>
439+
</div>
440+
</Show>
431441
<Show when={summary().trim()}>
432442
<div data-component="text-part" data-timeline-part-id={props.message.id}>
433443
<div data-slot="text-part-body">
@@ -439,6 +449,11 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
439449
</div>
440450
</div>
441451
</Show>
452+
<Show when={props.message.status !== "running"}>
453+
<div class="py-2">
454+
<TimelineSeparator label={label()} />
455+
</div>
456+
</Show>
442457
<Show when={error()}>
443458
<Card variant="error" class="error-card">
444459
{error()}

packages/ui/src/i18n/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ const source = {
104104
"ui.messagePart.review.title": "Review your answers",
105105
"ui.messagePart.questions.dismissed": "Questions dismissed",
106106
"ui.messagePart.compaction": "Session compacted",
107+
"ui.messagePart.compaction.started": "Session compaction started",
108+
"ui.messagePart.compaction.running": "Compacting",
109+
"ui.messagePart.compaction.failed": "Session compaction failed",
110+
"ui.messagePart.compaction.cancelled": "Session compaction cancelled",
111+
"ui.messagePart.compaction.interrupted": "Session compaction interrupted",
107112
"ui.messagePart.providerCompaction": "Session compacted by provider",
108113
"ui.messagePart.compaction.usage": "{{input}} in · {{output}} out",
109114
"ui.messagePart.context.details": "Details",

0 commit comments

Comments
 (0)