feat(session-ui): quiet automatic compaction divider (#58996)

This commit is contained in:
Kit Langton
2026-10-25 08:32:58 -07:00
committed by GitHub
Unverified
parent 70c69f06f9
commit 5ed03ad137
12 changed files with 792 additions and 41 deletions
@@ -1462,6 +1462,35 @@ test.describe("compaction", () => {
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
})
test("shows an automatic compaction as one quiet divider with background compaction on", async ({ page }) => {
const config = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/config")
const timeline = await setupTimeline(page, {
sessionMessages: [user, completed],
sessionStatus: { [sessionID]: { type: "busy" } },
configEntries: [{ type: "document", info: { experimental: { background_compaction: true } } }],
})
await config
await timeline.send(compactionStarted({ sessionID, reason: "auto" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
const compaction = page.locator('[data-component="session-compaction-message"]')
const divider = compaction.locator('[data-slot="session-compaction-divider"]')
await expect(divider).toHaveAttribute("data-state", "running")
await expect(compaction).not.toContainText("Partial automatic summary.")
await expect(compaction).not.toContainText("Session compaction started")
await timeline.send(
compactionEnded({ sessionID, reason: "auto", text: "## Checkpoint\n\nAutomatic details." }),
)
await expect(divider).toHaveAttribute("data-state", "done")
await expect(divider).toHaveAccessibleName("Compacted")
await expect(compaction).not.toContainText("Automatic details.")
await divider.click()
await expect(compaction).toContainText("Automatic details.")
})
const outcomes = {
failed: {
reason: "auto",
+1
View File
@@ -109,6 +109,7 @@ type TimelineServerInput = Partial<
Pick<
MockServerConfig,
| "inbox"
| "configEntries"
| "forms"
| "permissions"
| "sessionPermissions"
@@ -1,7 +1,8 @@
import { DataProvider } from "@opencode/session-ui/context"
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode/session-ui/context/markdown"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useQuery } from "@tanstack/solid-query"
import { createMemo, onCleanup, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -66,6 +67,25 @@ export function SessionUIProvider(
const providers = useProviders(directory)
const config = useQuery(() => ({
queryKey: [serverSDK.scope, "session-ui-config", directory()],
enabled: serverSDK.connection.status() === "connected",
queryFn: () => serverSDK.api.config.get({ location: { directory: directory() } }),
}))
onCleanup(
serverSDK.event.on("config.updated", (event) => {
if (event.location && event.location.directory !== directory()) return
void config.refetch()
}),
)
// Matches the server: the highest-priority document that sets `experimental` decides.
const backgroundCompaction = () =>
config.data
?.flatMap((entry) => (entry.type === "document" && entry.info.experimental ? [entry.info.experimental] : []))
.at(-1)?.background_compaction === true
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
const turnDiffs = () => {
const id = params.id
@@ -112,6 +132,7 @@ export function SessionUIProvider(
}}
onNavigateToSession={navigateToSession}
onSessionHref={href}
backgroundCompaction={backgroundCompaction()}
>
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
<LocalProvider>{props.children}</LocalProvider>
@@ -0,0 +1,215 @@
import { expect, story } from "../../storybook/playwright/story"
const rows = "current-session-timeline-rows"
story("shows a running automatic compaction as one Compacting divider", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-in-progress`)
const message = timeline.locator('[data-component="session-compaction-message"]')
const divider = message.locator('[data-slot="session-compaction-divider"]')
await expect(message).toHaveAttribute("data-quiet", "")
// Already running when the history loads, so it shows at once rather than waiting out the grace.
await expect(message).not.toHaveAttribute("data-enter")
await expect(divider).toHaveAttribute("data-state", "running")
await expect(divider).toBeDisabled()
await expect(divider).toHaveAttribute("aria-busy", "true")
await expect(divider).toHaveAccessibleName("Compacting")
await expect(divider).not.toHaveAttribute("aria-expanded")
await expect(message.locator('[data-slot="session-compaction-running"]')).toHaveCSS("opacity", "1")
await expect(message).not.toContainText("Session compaction started")
await expect(message).not.toContainText("Restore compaction output")
await expect(message.locator('[data-component="text-part"]')).toHaveCount(0)
})
story("opens a finished automatic compaction's summary from its divider", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-and-continuation`)
const message = timeline.locator('[data-component="session-compaction-message"]')
const divider = message.locator('[data-slot="session-compaction-divider"]')
const summary = message.locator('[data-slot="session-compaction-summary"]')
// Loaded with the history, so it is already there rather than fading in.
await expect(message).not.toHaveAttribute("data-enter")
await expect(divider).toHaveAttribute("data-state", "done")
await expect(message.locator('[data-slot="session-compaction-done"]')).toHaveCSS("opacity", "1")
await expect(divider).toHaveAccessibleName("Compacted")
await expect(divider).toHaveAttribute("aria-expanded", "false")
await expect(divider).not.toHaveAttribute("aria-controls")
await expect(summary).toHaveCount(0)
await divider.click()
await expect(divider).toHaveAttribute("aria-expanded", "true")
await expect(summary).toContainText("The Session timeline now consumes current nested assistant content.")
await expect(summary).toContainText("11.9K in · 508 out")
await expect(divider).toHaveAttribute("aria-controls", (await summary.getAttribute("id")) ?? "")
await divider.click()
await expect(divider).toHaveAttribute("aria-expanded", "false")
await expect(divider).not.toHaveAttribute("aria-controls")
await expect(summary).toHaveCount(0)
})
story("fades in an instant automatic compaction as Compacted", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-replay`, { args: { run: "instant" } })
const message = timeline.locator('[data-component="session-compaction-message"]')
const divider = message.locator('[data-slot="session-compaction-divider"]')
await expect(message).toHaveAttribute("data-enter", "now")
await expect(divider).toHaveAttribute("data-state", "done")
await expect(divider).toHaveCSS("animation-name", "session-compaction-enter")
await expect(divider).toHaveAccessibleName("Compacted")
await expect(message.locator('[data-slot="session-compaction-light"]').first()).toBeHidden()
})
story("fades in a compaction that ends inside the grace as Compacted only", async ({ mount, page }) => {
await mount(`${rows}--compaction-replay`, { args: { run: "quick" } })
// Sample the frame after it finishes: Compacting must already be gone rather than crossfading under Compacted.
const ended = await page.evaluate(
() =>
new Promise<{ enter: string | null; animation: string; running: string; done: string }>((resolve) => {
const sample = () => {
const message = document.querySelector('[data-component="session-compaction-message"]')
const divider = message?.querySelector('[data-slot="session-compaction-divider"]')
if (!message || !divider || divider.getAttribute("data-state") !== "done")
return requestAnimationFrame(sample)
requestAnimationFrame(() =>
resolve({
enter: message.getAttribute("data-enter"),
animation: getComputedStyle(divider).animationName,
running: getComputedStyle(message.querySelector('[data-slot="session-compaction-running"]')!).opacity,
done: getComputedStyle(message.querySelector('[data-slot="session-compaction-done"]')!).opacity,
}),
)
}
sample()
}),
)
expect(ended).toEqual({ enter: "now", animation: "session-compaction-enter", running: "0", done: "1" })
})
story("warms the label only once the gathering light reaches it", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-in-progress`)
const message = timeline.locator('[data-component="session-compaction-message"]')
// Hold the divider's animations at a point in their 3.2 s cycle and read the light and the label's glint there.
const at = (progress: number) =>
message.evaluate((root, progress) => {
root.ownerDocument
.getAnimations()
.filter(
(animation) => animation instanceof CSSAnimation && animation.animationName.startsWith("session-compaction-"),
)
.forEach((animation) => {
animation.pause()
animation.currentTime = progress * 3_200
})
const light = root.querySelector('[data-slot="session-compaction-light"]')
const glint = root.querySelector('[data-slot="session-compaction-glint"]')
if (!light || !glint) throw new Error("Expected the light and the glint")
return [getComputedStyle(light, "::after").opacity, getComputedStyle(glint).opacity].map(Number)
}, progress)
const [travelling, arriving, landed, cooled] = await Promise.all(
[0.3, 0.48, 0.57, 0.9].map((progress) => at(progress)),
)
expect(travelling).toEqual([1, 0])
expect(arriving).toEqual([1, 0])
expect(landed[0]).toBeLessThan(1)
expect(landed[1]).toBeCloseTo(0.75, 2)
expect(cooled).toEqual([0, 0])
})
story("keeps the full record for a running automatic compaction with background compaction off", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-in-progress`, { args: { backgroundCompaction: false } })
const message = timeline.locator('[data-component="session-compaction-message"]')
await expect(message).not.toHaveAttribute("data-quiet")
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveCount(0)
await expect(message).toContainText("Session compaction started")
await expect(message.getByRole("status")).toBeVisible()
})
story("keeps the full record for a finished automatic compaction with background compaction off", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-and-continuation`, { args: { backgroundCompaction: false } })
const message = timeline.locator('[data-component="session-compaction-message"]')
await expect(message).not.toHaveAttribute("data-quiet")
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveCount(0)
await expect(message).toContainText("Session compaction started")
await expect(message).toContainText("The Session timeline now consumes current nested assistant content.")
await expect(message).toContainText("Session compacted · 11.9K in · 508 out")
})
story("keeps the full record for a manual compaction", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-manual`)
const message = timeline.locator('[data-component="session-compaction-message"]')
await expect(message).not.toHaveAttribute("data-quiet")
await expect(message).toContainText("Session compaction started")
await expect(message).toContainText("The Session timeline now consumes current nested assistant content.")
await expect(message).toContainText("Session compacted · 11.9K in · 508 out")
})
story("keeps the full record for a failed automatic compaction", async ({ mount }) => {
const timeline = await mount(`${rows}--compaction-failed`)
const message = timeline.locator('[data-component="session-compaction-message"]')
await expect(message).not.toHaveAttribute("data-quiet")
await expect(message).toContainText("Session compaction started")
await expect(message).toContainText("Session compaction failed")
await expect(message.locator('[data-component="card"]')).toContainText(
"The provider rejected the compaction request.",
)
})
story("keeps the divider's box while an automatic compaction runs and finishes", async ({ mount, page }) => {
const timeline = await mount(`${rows}--conversation`, { args: { scenario: "compaction" } })
const message = timeline.locator('[data-component="session-compaction-message"]')
const label = message.locator('[data-slot="session-compaction-label"]')
const stem = label.locator("span").first()
// The story's turn above gains its footer when the Session goes idle, so measure the divider against itself.
const box = async () => {
const [outer, text, start] = await Promise.all([message.boundingBox(), label.boundingBox(), stem.boundingBox()])
if (!outer || !text || !start) throw new Error("Expected the compaction divider to be laid out")
return {
width: outer.width,
height: outer.height,
label: [text.x - outer.x, text.y - outer.y, text.width],
stem: [start.x - outer.x, start.width],
}
}
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveAttribute("data-state", "running")
const running = await box()
await page.getByRole("button", { name: "Stream summary" }).click()
await expect(message).not.toContainText("Streamed implementation details.")
expect(await box()).toEqual(running)
await page.getByRole("button", { name: "Complete summary" }).click()
await expect(message.locator('[data-slot="session-compaction-divider"]')).toHaveAttribute("data-state", "done")
expect(await box()).toEqual(running)
})
story("holds the lines still under reduced motion", async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
const timeline = await mount(`${rows}--compaction-in-progress`)
const message = timeline.locator('[data-component="session-compaction-message"]')
const line = message.locator('[data-slot="session-compaction-line"]').first()
await expect(message.locator('[data-slot="session-compaction-light"]').first()).toBeHidden()
await expect(message.locator('[data-slot="session-compaction-glint"]')).toBeHidden()
await expect.poll(() => line.evaluate((element) => getComputedStyle(element, "::before").opacity)).toBe("1")
await expect(message.locator('[data-slot="session-compaction-running"]')).toHaveCSS("opacity", "1")
})
@@ -0,0 +1,262 @@
/* The quiet automatic compaction divider. Every state keeps the same box: only opacity, transforms, a light blur and
the light's position move, so the timeline never shifts. */
[data-component="session-compaction-message"][data-quiet] {
--session-compaction-delay: 0ms;
--session-compaction-cycle: 3.2s;
&[data-enter="grace"] {
--session-compaction-delay: 400ms;
}
&[data-enter] [data-slot="session-compaction-divider"] {
animation: session-compaction-fade 320ms ease-out var(--session-compaction-delay) both;
}
/* Its own name restarts the fade when a compaction ends inside the grace, and nothing inside transitions, so only
Compacted appears. */
&[data-enter="now"] {
[data-slot="session-compaction-divider"] {
animation-name: session-compaction-enter;
}
[data-slot="session-compaction-line"]::before,
[data-slot="session-compaction-light"],
[data-slot="session-compaction-running"],
[data-slot="session-compaction-glint-stem"],
[data-slot="session-compaction-glint-ending"] {
transition: none;
}
[data-slot="session-compaction-done"] {
transition: color 150ms ease-out;
}
}
[data-slot="session-compaction-divider"] {
padding: 0;
border: 0;
border-radius: 6px;
background: none;
font: inherit;
cursor: pointer;
&:disabled {
cursor: default;
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
}
}
[data-slot="session-compaction-line"] {
position: relative;
overflow: hidden;
&[data-side="end"] {
transform: scaleX(-1);
}
&::before {
content: "";
position: absolute;
inset: 0;
background: var(--v2-border-border-strong);
opacity: 0.6;
transition: opacity 360ms ease-out;
}
}
/* Each light starts at the outer end and travels to the label. Its brightest point reaches the line's end at 49.5% of
the cycle, where it starts to cool as it slides off. */
[data-slot="session-compaction-light"] {
position: absolute;
inset: 0;
transition:
opacity 360ms ease-out,
display 360ms allow-discrete;
&::after {
content: "";
position: absolute;
inset-block: 0;
left: 0;
width: 40%;
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--v2-text-text-muted) 55%, transparent) 65%,
transparent
);
animation: session-compaction-gather var(--session-compaction-cycle) linear var(--session-compaction-delay)
infinite backwards;
}
}
[data-slot="session-compaction-ending"] > span {
grid-area: 1 / 1;
}
/* Only the ending changes when the compaction ends: ing rolls up and out, then ed rolls up into its place. The
ending's box fits the wider of the two, so the stem and lines hold still. */
[data-slot="session-compaction-running"],
[data-slot="session-compaction-glint-ending"] {
transition:
opacity 200ms cubic-bezier(0.4, 0, 1, 1),
transform 200ms cubic-bezier(0.4, 0, 1, 1),
filter 200ms cubic-bezier(0.4, 0, 1, 1);
}
[data-slot="session-compaction-done"] {
opacity: 0;
transform: translateY(3px);
filter: blur(2px);
transition:
opacity 280ms cubic-bezier(0.2, 0.8, 0.2, 1) 80ms,
transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1) 80ms,
filter 280ms cubic-bezier(0.2, 0.8, 0.2, 1) 80ms,
color 150ms ease-out;
}
/* The label warms only once each light lands on it, then cools before the next one arrives. */
[data-slot="session-compaction-glint"] {
position: absolute;
inset: 0;
display: flex;
pointer-events: none;
color: var(--v2-text-text-muted);
opacity: 0;
animation: session-compaction-glint var(--session-compaction-cycle) linear var(--session-compaction-delay) infinite
backwards;
}
[data-slot="session-compaction-glint-stem"] {
transition: opacity 200ms ease-out;
}
[data-state="done"] {
[data-slot="session-compaction-line"]::before {
opacity: 1;
}
[data-slot="session-compaction-light"] {
display: none;
opacity: 0;
}
[data-slot="session-compaction-running"],
[data-slot="session-compaction-glint-ending"] {
opacity: 0;
transform: translateY(-3px);
filter: blur(2px);
}
[data-slot="session-compaction-glint"] {
animation-play-state: paused;
}
[data-slot="session-compaction-glint-stem"] {
opacity: 0;
}
[data-slot="session-compaction-done"] {
opacity: 1;
transform: none;
filter: none;
}
&:not(:disabled):hover [data-slot="session-compaction-done"],
&[aria-expanded="true"] [data-slot="session-compaction-done"] {
color: var(--v2-text-text-muted);
}
}
/* The summary is reference material, so its body sits a step quieter than the conversation around it. */
[data-slot="session-compaction-summary"] {
padding-bottom: 8px;
animation: session-compaction-fade 200ms ease-out both;
[data-component="markdown"] {
color: var(--v2-text-text-muted);
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="session-compaction-line"]::before {
opacity: 1;
transition: none;
}
[data-slot="session-compaction-light"],
[data-slot="session-compaction-glint"] {
display: none;
}
&[data-enter] [data-slot="session-compaction-divider"] {
animation-duration: 200ms;
}
[data-slot="session-compaction-running"],
[data-state="done"] [data-slot="session-compaction-running"],
[data-slot="session-compaction-done"] {
transform: none;
filter: none;
}
}
}
:dir(rtl) [data-component="session-compaction-message"][data-quiet] [data-slot="session-compaction-line"] {
transform: scaleX(-1);
&[data-side="end"] {
transform: none;
}
}
@keyframes session-compaction-fade {
from {
opacity: 0;
}
}
@keyframes session-compaction-enter {
from {
opacity: 0;
}
}
/* Travel runs 0–70% on one ease-in-out, so the light never stops or jumps. Opacity has its own keyframes: it fades up
early, holds, and cools from the moment the light reaches the label. */
@keyframes session-compaction-gather {
0% {
transform: translateX(-100%);
opacity: 0;
animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
}
14%,
49.5% {
opacity: 1;
}
70%,
100% {
transform: translateX(250%);
opacity: 0;
}
}
@keyframes session-compaction-glint {
0%,
49% {
opacity: 0;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
57% {
opacity: 0.75;
animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
}
82%,
100% {
opacity: 0;
}
}
+5
View File
@@ -62,6 +62,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
turnDiff?: TurnDiffSource
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
/** Whether `experimental.background_compaction` is on for this location; automatic compactions then show quietly. */
backgroundCompaction?: boolean
}) => {
return {
get store() {
@@ -73,6 +75,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
get sessionID() {
return props.sessionID
},
get backgroundCompaction() {
return props.backgroundCompaction === true
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
shellRunning: props.shellRunning,
@@ -585,6 +585,7 @@ function CompactionDivider(props: { label: string }) {
export function SessionCompactionMessage(props: { message: SessionMessageCompaction; error: string }) {
const i18n = useI18n()
const data = useData()
const summary = () => (props.message.status === "failed" ? "" : props.message.summary)
const error = () => {
@@ -630,44 +631,147 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
const label = createMemo(() => [i18n.t(outcome()), usage()].filter(Boolean).join(" · "))
return (
<div data-component="session-compaction-message">
<CompactionDivider label={i18n.t("ui.messagePart.compaction.started")} />
<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"}
<Show
when={!data.backgroundCompaction || props.message.reason === "manual" || props.message.status === "failed"}
fallback={
<QuietCompaction
id={props.message.id}
created={props.message.time.created}
running={props.message.status === "running"}
summary={summary()}
usage={usage()}
/>
}
>
<div data-component="session-compaction-message">
<CompactionDivider label={i18n.t("ui.messagePart.compaction.started")} />
<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={props.message.status === "running"}>
<div role="status" class="py-2">
<BasicTool
icon="archive"
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
status="running"
locked
hideDetails
/>
</div>
</Show>
<Show when={props.message.status !== "running"}>
<div class="py-2">
<TimelineSeparator label={label()} />
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()}
</Card>
</Show>
</div>
</Show>
)
}
/**
* An automatic compaction, drawn as one divider that reads Compacting, then Compacted. Its height never changes, and
* clicking it opens the summary.
*/
function QuietCompaction(props: { id: string; created: number; running: boolean; summary: string; usage: string }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
// A new one waits out the grace (400 ms, matched in session-compaction.css) before showing Compacting; one that
// ends inside it, as a precomputed summary does, only fades in as Compacted. One already running or finished when
// the history loads shows without an entrance.
const mounted = performance.now()
const age = Date.now() - props.created
const [enter, setEnter] = createSignal(
props.running ? (age < 400 ? "grace" : undefined) : age < 1_000 ? "now" : undefined,
)
const expandable = () => !props.running && !!props.summary.trim()
const label = createMemo(() =>
splitEnding(i18n.t("ui.messagePart.compaction.running"), i18n.t("ui.messagePart.compaction.done")),
)
createEffect(() => {
if (!props.running && enter() === "grace" && performance.now() - mounted < 400) setEnter("now")
})
return (
<div data-component="session-compaction-message" data-quiet="" data-enter={enter()}>
<div class="py-2">
<button
type="button"
data-slot="session-compaction-divider"
data-state={props.running ? "running" : "done"}
class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint"
disabled={!expandable()}
aria-busy={props.running}
aria-expanded={expandable() ? open() : undefined}
aria-controls={expandable() && open() ? `${props.id}-summary` : undefined}
aria-label={props.running ? label().running : label().done}
onClick={() => setOpen((value) => !value)}
>
<span data-slot="session-compaction-line" data-side="start" class="h-px min-w-0 flex-1">
<span data-slot="session-compaction-light" />
</span>
<span
data-slot="session-compaction-label"
class="relative flex shrink-0 whitespace-nowrap text-[13px] font-[440] leading-text-compact tracking-[-0.04px]"
>
<span>{label().stem}</span>
<span data-slot="session-compaction-ending" class="grid justify-items-start">
<span data-slot="session-compaction-running">{label().ending.running}</span>
<span data-slot="session-compaction-done">{label().ending.done}</span>
</span>
<span data-slot="session-compaction-glint" aria-hidden="true">
<span data-slot="session-compaction-glint-stem">{label().stem}</span>
<span data-slot="session-compaction-glint-ending">{label().ending.running}</span>
</span>
</span>
<span data-slot="session-compaction-line" data-side="end" class="h-px min-w-0 flex-1">
<span data-slot="session-compaction-light" />
</span>
</button>
</div>
<Show when={expandable() && open()}>
<div id={`${props.id}-summary`} data-slot="session-compaction-summary">
<div data-component="text-part" data-timeline-part-id={props.id}>
<div data-slot="text-part-body">
<PacedMarkdown text={props.summary} cacheKey={props.id} streaming={false} />
</div>
</div>
<Show when={props.usage}>
<div class="mt-2 text-[13px] leading-text-compact text-v2-text-text-faint tabular-nums">{props.usage}</div>
</Show>
</div>
</Show>
<Show when={props.message.status === "running"}>
<div role="status" class="py-2">
<BasicTool
icon="archive"
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
status="running"
locked
hideDetails
/>
</div>
</Show>
<Show when={props.message.status !== "running"}>
<div class="py-2">
<TimelineSeparator label={label()} />
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()}
</Card>
</Show>
</div>
)
}
/** Splits Compacting and Compacted at their shared stem, so only the ending changes when the compaction ends. */
function splitEnding(running: string, done: string) {
const chars = [...running]
const others = [...done]
const at = chars.findIndex((char, index) => char !== others[index])
const stem = chars.slice(0, at === -1 ? chars.length : at).join("")
return { running, done, stem, ending: { running: running.slice(stem.length), done: done.slice(stem.length) } }
}
export function AssistantTextContent(props: {
id: string
text: string
@@ -1010,6 +1010,20 @@ export const compactionDocument = document([
}),
] satisfies SessionMessageInfo[])
export const compactionManualDocument = document([
...compactionPrelude,
{
id: "msg_compaction_manual",
type: "compaction",
status: "completed",
reason: "manual",
summary: "The Session timeline now consumes current nested assistant content.",
cost: 0.0142,
tokens: { input: 3_180, output: 412, reasoning: 96, cache: { read: 8_704, write: 0 } },
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[])
export const compactionFailedDocument = document([
...compactionPrelude,
{
@@ -9,11 +9,16 @@ import { Button } from "@opencode/ui/button"
import { Show, createMemo, createSignal, type JSX } from "solid-js"
import { CURRENT_SESSION_ID, STORY_TIME } from "./current-session-fixtures"
export function CurrentSessionProviders(props: { document: SessionDocument; children: JSX.Element }) {
export function CurrentSessionProviders(props: {
document: SessionDocument
backgroundCompaction?: boolean
children: JSX.Element
}) {
return (
<DataProvider
directory="C:/workspaces/opencode"
sessionID={props.document.sessionID}
backgroundCompaction={props.backgroundCompaction}
data={{
agent: [
{ name: "build", color: "blue" },
@@ -78,6 +83,7 @@ export function CurrentSessionTimelineStory(props: {
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
turnDiffs?: boolean
backgroundCompaction?: boolean
}) {
// Inline story documents rebuild their messages on every prop read, and the timeline projection keys text by identity.
const document = createMemo(() => props.document)
@@ -113,7 +119,7 @@ export function CurrentSessionTimelineStory(props: {
<Show when={revision()} keyed>
{(revision) => (
<div data-story-revision={revision}>
<CurrentSessionProviders document={document()}>
<CurrentSessionProviders document={document()} backgroundCompaction={props.backgroundCompaction}>
<SessionTimeline
document={document()}
presentation={props.presentation}
+1
View File
@@ -7,6 +7,7 @@
@import "../components/message-nav.css" layer(components);
@import "../components/question-prompt.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-compaction.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/shell-submessage.css" layer(components);
@import "../components/tool-count-label.css" layer(components);
@@ -1,5 +1,5 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
import { createMemo } from "solid-js"
import { createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { SessionTimeline } from "./session-timeline"
@@ -14,6 +14,7 @@ import {
compactionCancelledDocument,
compactionDocument,
compactionFailedDocument,
compactionManualDocument,
compactionRunningDocument,
instructionsUpdatedMultipleDocument,
instructionsUpdatedSingleDocument,
@@ -207,7 +208,10 @@ function RetryAndRecoverStory() {
? {
attempt: 2,
at: 1_900_000_000_000,
error: { type: "ProviderRateLimitError" as const, message: "Rate limit reached. Retrying with backoff." },
error: {
type: "ProviderRateLimitError" as const,
message: "Rate limit reached. Retrying with backoff.",
},
}
: undefined,
time: { created: STORY_TIME, completed: finished ? STORY_TIME + 300 : undefined },
@@ -231,7 +235,7 @@ function RetryAndRecoverStory() {
Finish response
</button>
</div>
<CurrentSessionProviders document={document()}>
<CurrentSessionProviders document={document()} backgroundCompaction>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
@@ -364,7 +368,7 @@ function CompactSessionStory() {
Cancel next compaction
</button>
</div>
<CurrentSessionProviders document={document()}>
<CurrentSessionProviders document={document()} backgroundCompaction>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
@@ -373,28 +377,114 @@ function CompactSessionStory() {
const CompactSession = { render: () => <CompactSessionStory /> }
// Automatic compactions only show quietly with experimental.background_compaction on; turn it off to see today's record.
const backgroundCompaction = {
args: { backgroundCompaction: true },
argTypes: { backgroundCompaction: { control: "boolean" } },
}
export const CompactionInProgress = {
render: () => (
...backgroundCompaction,
render: (args: { backgroundCompaction: boolean }) => (
<CurrentSessionTimelineStory
title="Compaction in progress"
description="The production divider remains stable while the context summary streams below it."
description="With background compaction on, an automatic compaction is one quiet divider whose lines carry light toward Compacting."
document={compactionRunningDocument}
width="600px"
backgroundCompaction={args.backgroundCompaction}
/>
),
}
export const CompactionAndContinuation = {
render: () => (
...backgroundCompaction,
render: (args: { backgroundCompaction: boolean }) => (
<CurrentSessionTimelineStory
title="Compaction and continuation"
description="The context summary separates earlier output from the continued response."
description="With background compaction on, a finished automatic compaction reads Compacted; clicking it opens the summary."
document={compactionDocument}
width="600px"
backgroundCompaction={args.backgroundCompaction}
/>
),
}
export const CompactionManual = {
render: () => (
<CurrentSessionTimelineStory
title="Manual compaction"
description="A /compact keeps the full record: started divider, summary and usage."
document={compactionManualDocument}
width="600px"
backgroundCompaction
/>
),
}
/** Replays an automatic compaction live: Working, then the divider for `duration` ms, then the continued reply. */
function CompactionReplayStory(props: { duration: number }) {
// The fixture's earlier reply was cut off for the compaction; here it finished, so no Interrupted divider shows first.
const prelude = compactionDocument.messages
.slice(0, 2)
.map((message) => (message.type === "assistant" ? { ...message, error: undefined } : message))
const [compaction, continuation] = [compactionDocument.messages[2], compactionDocument.messages[3]]
const [state, setState] = createStore({ phase: "working", created: 0, run: 0 })
const start = () => {
const run = state.run + 1
setState({ phase: "working", created: 0, run })
const at = (ms: number, apply: () => void) => setTimeout(() => state.run === run && apply(), ms)
at(1_500, () => setState({ phase: props.duration > 0 ? "running" : "completed", created: Date.now() }))
at(1_500 + props.duration, () => setState("phase", "completed"))
at(2_900 + props.duration, () => setState("phase", "continued"))
}
onMount(start)
onCleanup(() => setState("run", -1))
const document = createMemo(() => {
if (state.phase === "working" || compaction?.type !== "compaction" || compaction.status !== "completed")
return { ...compactionDocument, messages: prelude, status: { type: "busy" } } satisfies SessionDocument
const message =
state.phase === "running"
? { ...compaction, status: "running" as const, time: { created: state.created } }
: { ...compaction, time: { created: state.created } }
return {
...compactionDocument,
messages: [...prelude, message, ...(state.phase === "continued" && continuation ? [continuation] : [])],
status: { type: state.phase === "continued" ? "idle" : "busy" },
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[640px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={start}>
Replay
</button>
</div>
<CurrentSessionProviders document={document()} backgroundCompaction>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
// `instant` arrives finished, as when started and ended land in one flush; `quick` ends inside the 400 ms grace.
const replayDurations = { instant: 0, quick: 150, short: 1_200, long: 20_000 }
export const CompactionReplay = {
args: { run: "long" },
argTypes: { run: { control: "select", options: Object.keys(replayDurations) } },
render: (args: { run: string }) => (
// SAFETY: the run control only offers replayDurations keys.
<CompactionReplayStory duration={replayDurations[args.run as keyof typeof replayDurations]} />
),
}
export const CompactionFailed = {
render: () => (
<CurrentSessionTimelineStory
@@ -402,6 +492,7 @@ export const CompactionFailed = {
description="A failed compaction keeps the production divider and shows the provider error."
document={compactionFailedDocument}
width="600px"
backgroundCompaction
/>
),
}
@@ -413,6 +504,7 @@ export const CompactionCancelled = {
description="An interrupted compaction keeps the production divider without an empty error block."
document={compactionCancelledDocument}
width="600px"
backgroundCompaction
/>
),
}
+1
View File
@@ -113,6 +113,7 @@ const source = {
"ui.messagePart.compaction.started": "Session compaction started",
"ui.messagePart.compaction.queued": "Session compaction queued",
"ui.messagePart.compaction.running": "Compacting",
"ui.messagePart.compaction.done": "Compacted",
"ui.messagePart.compaction.failed": "Session compaction failed",
"ui.messagePart.compaction.cancelled": "Session compaction cancelled",
"ui.messagePart.compaction.interrupted": "Session compaction interrupted",