From 5ed03ad1377b549df83aa52dc11bdb07593a5327 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 25 Oct 2026 08:32:58 -0700 Subject: [PATCH] feat(session-ui): quiet automatic compaction divider (#58996) --- .../e2e/regression/timeline-stream.spec.ts | 29 ++ packages/app/e2e/utils/timeline.ts | 1 + .../src/shell/routes/session-ui-provider.tsx | 23 +- .../session-compaction.spec.ts | 215 ++++++++++++++ .../src/components/session-compaction.css | 262 ++++++++++++++++++ packages/session-ui/src/context/data.tsx | 5 + .../src/message/message-content.tsx | 164 +++++++++-- .../src/storybook/current-session-fixtures.ts | 14 + .../src/storybook/current-session-story.tsx | 10 +- packages/session-ui/src/styles/index.css | 1 + .../src/timeline/timeline-row.stories.tsx | 108 +++++++- packages/ui/src/i18n/en.ts | 1 + 12 files changed, 792 insertions(+), 41 deletions(-) create mode 100644 packages/session-ui/component-tests/session-compaction.spec.ts create mode 100644 packages/session-ui/src/components/session-compaction.css diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index a927f8dfc3f..39f474bb912 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -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", diff --git a/packages/app/e2e/utils/timeline.ts b/packages/app/e2e/utils/timeline.ts index 4dae3612585..b774c3bac0a 100644 --- a/packages/app/e2e/utils/timeline.ts +++ b/packages/app/e2e/utils/timeline.ts @@ -109,6 +109,7 @@ type TimelineServerInput = Partial< Pick< MockServerConfig, | "inbox" + | "configEntries" | "forms" | "permissions" | "sessionPermissions" diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index e17c307e261..f2e8420282c 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -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()} > {props.children} diff --git a/packages/session-ui/component-tests/session-compaction.spec.ts b/packages/session-ui/component-tests/session-compaction.spec.ts new file mode 100644 index 00000000000..03ad1fd8cae --- /dev/null +++ b/packages/session-ui/component-tests/session-compaction.spec.ts @@ -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") +}) diff --git a/packages/session-ui/src/components/session-compaction.css b/packages/session-ui/src/components/session-compaction.css new file mode 100644 index 00000000000..d93b531b335 --- /dev/null +++ b/packages/session-ui/src/components/session-compaction.css @@ -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; + } +} diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index ffd6a9041d2..7faad71501a 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -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, diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 8f0d18c32b2..2098826500d 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -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 ( -
- - -
-
- + } + > +
+ + +
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+ + + {error()} + + +
+ + ) +} + +/** + * 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 ( +
+
+ +
+ +
+
+
+ +
+
+ +
{props.usage}
+
- -
- -
-
- -
- -
-
- - - {error()} - -
) } +/** 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 diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index be939cf1c96..f6fe22fa9ae 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -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, { diff --git a/packages/session-ui/src/storybook/current-session-story.tsx b/packages/session-ui/src/storybook/current-session-story.tsx index 17357ef3c00..00cc74525a5 100644 --- a/packages/session-ui/src/storybook/current-session-story.tsx +++ b/packages/session-ui/src/storybook/current-session-story.tsx @@ -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 ( props.document) @@ -113,7 +119,7 @@ export function CurrentSessionTimelineStory(props: { {(revision) => (
- +
- + @@ -364,7 +368,7 @@ function CompactSessionStory() { Cancel next compaction
- + @@ -373,28 +377,114 @@ function CompactSessionStory() { const CompactSession = { render: () => } +// 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 }) => ( ), } export const CompactionAndContinuation = { - render: () => ( + ...backgroundCompaction, + render: (args: { backgroundCompaction: boolean }) => ( ), } +export const CompactionManual = { + render: () => ( + + ), +} + +/** 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 ( +
+
+ +
+ + + +
+ ) +} + +// `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. + + ), +} + export const CompactionFailed = { render: () => ( ), } @@ -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 /> ), } diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index f4c50837089..1b0d52876d6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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",