Skip to content

Commit 5ed03ad

Browse files
authored
feat(session-ui): quiet automatic compaction divider (#58996)
1 parent 70c69f0 commit 5ed03ad

12 files changed

Lines changed: 792 additions & 41 deletions

File tree

‎packages/app/e2e/regression/timeline-stream.spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,35 @@ test.describe("compaction", () => {
14621462
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
14631463
})
14641464

1465+
test("shows an automatic compaction as one quiet divider with background compaction on", async ({ page }) => {
1466+
const config = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/config")
1467+
1468+
const timeline = await setupTimeline(page, {
1469+
sessionMessages: [user, completed],
1470+
sessionStatus: { [sessionID]: { type: "busy" } },
1471+
configEntries: [{ type: "document", info: { experimental: { background_compaction: true } } }],
1472+
})
1473+
1474+
await config
1475+
1476+
await timeline.send(compactionStarted({ sessionID, reason: "auto" }))
1477+
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
1478+
const compaction = page.locator('[data-component="session-compaction-message"]')
1479+
const divider = compaction.locator('[data-slot="session-compaction-divider"]')
1480+
await expect(divider).toHaveAttribute("data-state", "running")
1481+
await expect(compaction).not.toContainText("Partial automatic summary.")
1482+
await expect(compaction).not.toContainText("Session compaction started")
1483+
1484+
await timeline.send(
1485+
compactionEnded({ sessionID, reason: "auto", text: "## Checkpoint\n\nAutomatic details." }),
1486+
)
1487+
await expect(divider).toHaveAttribute("data-state", "done")
1488+
await expect(divider).toHaveAccessibleName("Compacted")
1489+
await expect(compaction).not.toContainText("Automatic details.")
1490+
await divider.click()
1491+
await expect(compaction).toContainText("Automatic details.")
1492+
})
1493+
14651494
const outcomes = {
14661495
failed: {
14671496
reason: "auto",

‎packages/app/e2e/utils/timeline.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ type TimelineServerInput = Partial<
109109
Pick<
110110
MockServerConfig,
111111
| "inbox"
112+
| "configEntries"
112113
| "forms"
113114
| "permissions"
114115
| "sessionPermissions"

‎packages/app/src/shell/routes/session-ui-provider.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { DataProvider } from "@opencode/session-ui/context"
22
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode/session-ui/context/markdown"
33
import { useNavigate, useParams } from "@solidjs/router"
4-
import { createMemo, type ParentProps } from "solid-js"
4+
import { useQuery } from "@tanstack/solid-query"
5+
import { createMemo, onCleanup, type ParentProps } from "solid-js"
56
import { useProviders } from "@/providers/catalog/providers"
67
import { LocalProvider } from "@/providers/models/selection"
78
import type { ServerConnection } from "@/runtime/server/registry"
@@ -66,6 +67,25 @@ export function SessionUIProvider(
6667

6768
const providers = useProviders(directory)
6869

70+
const config = useQuery(() => ({
71+
queryKey: [serverSDK.scope, "session-ui-config", directory()],
72+
enabled: serverSDK.connection.status() === "connected",
73+
queryFn: () => serverSDK.api.config.get({ location: { directory: directory() } }),
74+
}))
75+
76+
onCleanup(
77+
serverSDK.event.on("config.updated", (event) => {
78+
if (event.location && event.location.directory !== directory()) return
79+
void config.refetch()
80+
}),
81+
)
82+
83+
// Matches the server: the highest-priority document that sets `experimental` decides.
84+
const backgroundCompaction = () =>
85+
config.data
86+
?.flatMap((entry) => (entry.type === "document" && entry.info.experimental ? [entry.info.experimental] : []))
87+
.at(-1)?.background_compaction === true
88+
6989
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
7090
const turnDiffs = () => {
7191
const id = params.id
@@ -112,6 +132,7 @@ export function SessionUIProvider(
112132
}}
113133
onNavigateToSession={navigateToSession}
114134
onSessionHref={href}
135+
backgroundCompaction={backgroundCompaction()}
115136
>
116137
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
117138
<LocalProvider>{props.children}</LocalProvider>
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { expect, story } from "../../storybook/playwright/story"
2+
3+
const rows = "current-session-timeline-rows"
4+
5+
story("shows a running automatic compaction as one Compacting divider", async ({ mount }) => {
6+
const timeline = await mount(`${rows}--compaction-in-progress`)
7+
const message = timeline.locator('[data-component="session-compaction-message"]')
8+
const divider = message.locator('[data-slot="session-compaction-divider"]')
9+
10+
await expect(message).toHaveAttribute("data-quiet", "")
11+
// Already running when the history loads, so it shows at once rather than waiting out the grace.
12+
await expect(message).not.toHaveAttribute("data-enter")
13+
await expect(divider).toHaveAttribute("data-state", "running")
14+
await expect(divider).toBeDisabled()
15+
await expect(divider).toHaveAttribute("aria-busy", "true")
16+
await expect(divider).toHaveAccessibleName("Compacting")
17+
await expect(divider).not.toHaveAttribute("aria-expanded")
18+
await expect(message.locator('[data-slot="session-compaction-running"]')).toHaveCSS("opacity", "1")
19+
await expect(message).not.toContainText("Session compaction started")
20+
await expect(message).not.toContainText("Restore compaction output")
21+
await expect(message.locator('[data-component="text-part"]')).toHaveCount(0)
22+
})
23+
24+
story("opens a finished automatic compaction's summary from its divider", async ({ mount }) => {
25+
const timeline = await mount(`${rows}--compaction-and-continuation`)
26+
const message = timeline.locator('[data-component="session-compaction-message"]')
27+
const divider = message.locator('[data-slot="session-compaction-divider"]')
28+
const summary = message.locator('[data-slot="session-compaction-summary"]')
29+
30+
// Loaded with the history, so it is already there rather than fading in.
31+
await expect(message).not.toHaveAttribute("data-enter")
32+
await expect(divider).toHaveAttribute("data-state", "done")
33+
await expect(message.locator('[data-slot="session-compaction-done"]')).toHaveCSS("opacity", "1")
34+
await expect(divider).toHaveAccessibleName("Compacted")
35+
await expect(divider).toHaveAttribute("aria-expanded", "false")
36+
await expect(divider).not.toHaveAttribute("aria-controls")
37+
await expect(summary).toHaveCount(0)
38+
39+
await divider.click()
40+
await expect(divider).toHaveAttribute("aria-expanded", "true")
41+
await expect(summary).toContainText("The Session timeline now consumes current nested assistant content.")
42+
await expect(summary).toContainText("11.9K in · 508 out")
43+
await expect(divider).toHaveAttribute("aria-controls", (await summary.getAttribute("id")) ?? "")
44+
45+
await divider.click()
46+
await expect(divider).toHaveAttribute("aria-expanded", "false")
47+
await expect(divider).not.toHaveAttribute("aria-controls")
48+
await expect(summary).toHaveCount(0)
49+
})
50+
51+
story("fades in an instant automatic compaction as Compacted", async ({ mount }) => {
52+
const timeline = await mount(`${rows}--compaction-replay`, { args: { run: "instant" } })
53+
const message = timeline.locator('[data-component="session-compaction-message"]')
54+
const divider = message.locator('[data-slot="session-compaction-divider"]')
55+
56+
await expect(message).toHaveAttribute("data-enter", "now")
57+
await expect(divider).toHaveAttribute("data-state", "done")
58+
await expect(divider).toHaveCSS("animation-name", "session-compaction-enter")
59+
await expect(divider).toHaveAccessibleName("Compacted")
60+
await expect(message.locator('[data-slot="session-compaction-light"]').first()).toBeHidden()
61+
})
62+
63+
story("fades in a compaction that ends inside the grace as Compacted only", async ({ mount, page }) => {
64+
await mount(`${rows}--compaction-replay`, { args: { run: "quick" } })
65+
66+
// Sample the frame after it finishes: Compacting must already be gone rather than crossfading under Compacted.
67+
const ended = await page.evaluate(
68+
() =>
69+
new Promise<{ enter: string | null; animation: string; running: string; done: string }>((resolve) => {
70+
const sample = () => {
71+
const message = document.querySelector('[data-component="session-compaction-message"]')
72+
const divider = message?.querySelector('[data-slot="session-compaction-divider"]')
73+
74+
if (!message || !divider || divider.getAttribute("data-state") !== "done")
75+
return requestAnimationFrame(sample)
76+
77+
requestAnimationFrame(() =>
78+
resolve({
79+
enter: message.getAttribute("data-enter"),
80+
animation: getComputedStyle(divider).animationName,
81+
running: getComputedStyle(message.querySelector('[data-slot="session-compaction-running"]')!).opacity,
82+
done: getComputedStyle(message.querySelector('[data-slot="session-compaction-done"]')!).opacity,
83+
}),
84+
)
85+
}
86+
87+
sample()
88+
}),
89+
)
90+
91+
expect(ended).toEqual({ enter: "now", animation: "session-compaction-enter", running: "0", done: "1" })
92+
})
93+
94+
story("warms the label only once the gathering light reaches it", async ({ mount }) => {
95+
const timeline = await mount(`${rows}--compaction-in-progress`)
96+
const message = timeline.locator('[data-component="session-compaction-message"]')
97+
98+
// Hold the divider's animations at a point in their 3.2 s cycle and read the light and the label's glint there.
99+
const at = (progress: number) =>
100+
message.evaluate((root, progress) => {
101+
root.ownerDocument
102+
.getAnimations()
103+
.filter(
104+
(animation) => animation instanceof CSSAnimation && animation.animationName.startsWith("session-compaction-"),
105+
)
106+
.forEach((animation) => {
107+
animation.pause()
108+
animation.currentTime = progress * 3_200
109+
})
110+
111+
const light = root.querySelector('[data-slot="session-compaction-light"]')
112+
const glint = root.querySelector('[data-slot="session-compaction-glint"]')
113+
114+
if (!light || !glint) throw new Error("Expected the light and the glint")
115+
116+
return [getComputedStyle(light, "::after").opacity, getComputedStyle(glint).opacity].map(Number)
117+
}, progress)
118+
119+
const [travelling, arriving, landed, cooled] = await Promise.all(
120+
[0.3, 0.48, 0.57, 0.9].map((progress) => at(progress)),
121+
)
122+
123+
expect(travelling).toEqual([1, 0])
124+
expect(arriving).toEqual([1, 0])
125+
expect(landed[0]).toBeLessThan(1)
126+
expect(landed[1]).toBeCloseTo(0.75, 2)
127+
expect(cooled).toEqual([0, 0])
128+
})
129+
130+
story("keeps the full record for a running automatic compaction with background compaction off", async ({ mount }) => {
131+
const timeline = await mount(`${rows}--compaction-in-progress`, { args: { backgroundCompaction: false } })
132+
const message = timeline.locator('[data-component="session-compaction-message"]')
133+
134+
await expect(message).not.toHaveAttribute("data-quiet")
135+
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveCount(0)
136+
await expect(message).toContainText("Session compaction started")
137+
await expect(message.getByRole("status")).toBeVisible()
138+
})
139+
140+
story("keeps the full record for a finished automatic compaction with background compaction off", async ({ mount }) => {
141+
const timeline = await mount(`${rows}--compaction-and-continuation`, { args: { backgroundCompaction: false } })
142+
const message = timeline.locator('[data-component="session-compaction-message"]')
143+
144+
await expect(message).not.toHaveAttribute("data-quiet")
145+
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveCount(0)
146+
await expect(message).toContainText("Session compaction started")
147+
await expect(message).toContainText("The Session timeline now consumes current nested assistant content.")
148+
await expect(message).toContainText("Session compacted · 11.9K in · 508 out")
149+
})
150+
151+
story("keeps the full record for a manual compaction", async ({ mount }) => {
152+
const timeline = await mount(`${rows}--compaction-manual`)
153+
const message = timeline.locator('[data-component="session-compaction-message"]')
154+
155+
await expect(message).not.toHaveAttribute("data-quiet")
156+
await expect(message).toContainText("Session compaction started")
157+
await expect(message).toContainText("The Session timeline now consumes current nested assistant content.")
158+
await expect(message).toContainText("Session compacted · 11.9K in · 508 out")
159+
})
160+
161+
story("keeps the full record for a failed automatic compaction", async ({ mount }) => {
162+
const timeline = await mount(`${rows}--compaction-failed`)
163+
const message = timeline.locator('[data-component="session-compaction-message"]')
164+
165+
await expect(message).not.toHaveAttribute("data-quiet")
166+
await expect(message).toContainText("Session compaction started")
167+
await expect(message).toContainText("Session compaction failed")
168+
await expect(message.locator('[data-component="card"]')).toContainText(
169+
"The provider rejected the compaction request.",
170+
)
171+
})
172+
173+
story("keeps the divider's box while an automatic compaction runs and finishes", async ({ mount, page }) => {
174+
const timeline = await mount(`${rows}--conversation`, { args: { scenario: "compaction" } })
175+
const message = timeline.locator('[data-component="session-compaction-message"]')
176+
const label = message.locator('[data-slot="session-compaction-label"]')
177+
const stem = label.locator("span").first()
178+
179+
// The story's turn above gains its footer when the Session goes idle, so measure the divider against itself.
180+
const box = async () => {
181+
const [outer, text, start] = await Promise.all([message.boundingBox(), label.boundingBox(), stem.boundingBox()])
182+
183+
if (!outer || !text || !start) throw new Error("Expected the compaction divider to be laid out")
184+
185+
return {
186+
width: outer.width,
187+
height: outer.height,
188+
label: [text.x - outer.x, text.y - outer.y, text.width],
189+
stem: [start.x - outer.x, start.width],
190+
}
191+
}
192+
193+
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveAttribute("data-state", "running")
194+
const running = await box()
195+
196+
await page.getByRole("button", { name: "Stream summary" }).click()
197+
await expect(message).not.toContainText("Streamed implementation details.")
198+
expect(await box()).toEqual(running)
199+
200+
await page.getByRole("button", { name: "Complete summary" }).click()
201+
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveAttribute("data-state", "done")
202+
expect(await box()).toEqual(running)
203+
})
204+
205+
story("holds the lines still under reduced motion", async ({ mount, page }) => {
206+
await page.emulateMedia({ reducedMotion: "reduce" })
207+
const timeline = await mount(`${rows}--compaction-in-progress`)
208+
const message = timeline.locator('[data-component="session-compaction-message"]')
209+
const line = message.locator('[data-slot="session-compaction-line"]').first()
210+
211+
await expect(message.locator('[data-slot="session-compaction-light"]').first()).toBeHidden()
212+
await expect(message.locator('[data-slot="session-compaction-glint"]')).toBeHidden()
213+
await expect.poll(() => line.evaluate((element) => getComputedStyle(element, "::before").opacity)).toBe("1")
214+
await expect(message.locator('[data-slot="session-compaction-running"]')).toHaveCSS("opacity", "1")
215+
})

0 commit comments

Comments
 (0)