mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
feat(session-ui): quiet automatic compaction divider (#58996)
This commit is contained in:
committed by
GitHub
Unverified
parent
70c69f06f9
commit
5ed03ad137
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user