Skip to content

feat(session-ui): quiet divider for automatic compaction with background compaction on - #58996

Merged
kitlangton merged 5 commits into
devfrom
quiet-compaction
Oct 25, 2026
Merged

kitlangton merged 5 commits into
devfrom
quiet-compaction

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What it does

Automatic compaction used to take over the timeline: a "Session compaction started" divider, the summary streaming in as a full reply, a Compacting tool row, then a second divider. Most of that is housekeeping the reader never asked for. With experimental.background_compaction on (the flag that makes compaction run in the background), an automatic compaction is now one faint divider that reads Compacting while it runs and Compacted when it's done, and clicking it opens the summary. With the flag off, automatic compaction looks exactly as it does today. Manual /compact and failed compactions always look the same as before.

How it works

  • The app reads the session directory's config (api.config.get, refetched on config.updated) and passes backgroundCompaction to session-ui's DataProvider. Like the server, the highest-priority document that sets experimental decides.
  • SessionCompactionMessage sends automatic, non-failed compactions to a new QuietCompaction only when that flag is on. Everything else keeps the existing markup.
  • While it runs, a soft light travels along both hairlines toward the label (3.2 s cycle, opacity and transform only, no bounce). The label stays dim until the light reaches it, then brightens as the light lands and cools before the next pass. We loop rather than fill because compaction gives no real progress: it's one model call that spends most of its time reading the context.
  • When it finishes, "Compact" holds still and only the ending changes: "ing" rolls up and out and "ed" rolls in (opacity, 3 px, 2 px blur). The ending's box fits the wider of the two, so the label and lines never move. The stem is the two strings' shared prefix, so other languages fall back to changing the whole word. The lines settle to the normal separator colour, and the divider's box never changes.
  • A new compaction waits 400 ms before showing Compacting. If it finishes inside that window (a precomputed summary takes a few ms, but started and ended can still land in separate flushes), the fade restarts and only "Compacted" fades in, with no crossfade. One that was already running (over 400 ms old) or already finished (over 1 s old) when the timeline loads shows up without any animation.
  • Clicking a finished divider toggles the summary (rendered a step quieter than the conversation) and its token usage.
  • Reduced motion keeps the lines static and only crossfades the label. RTL flips the direction of the light.
  • New string: ui.messagePart.compaction.done ("Compacted"), English only.

Risk

UI only (session-ui, plus one config query in the app's session provider). With the flag off, nothing changes. The summary no longer streams while an automatic compaction runs. It's still there to open once the compaction finishes. If the routing were wrong, a manual or failed compaction, or any compaction with the flag off, would lose its full record; component tests cover all three, and an e2e test covers the flag on end to end.

Proof

Before → after, a running automatic compaction:

Before: a running automatic compaction After: a running automatic compaction

Finished and opened:

After: a finished compaction with its summary open

A 20 s compaction at real speed. Just before it ends the page drops to 0.2× speed (CDP Animation.setPlaybackRate, marked on screen) so the ending's roll is visible:

compaction-20s-then-slow.mp4

One that ends inside the 400 ms grace, and an instant one: both just fade in as Compacted.

compaction-inside-grace.mp4
compaction-instant.mp4
  • bun run test:components -- component-tests/session-compaction.spec.ts component-tests/session-timeline.spec.ts --repeat-each=5 (packages/session-ui): 75 passed. The new spec covers running, click-to-open, instant fade, manual, failed, a stable box from running to done, and reduced motion.
  • Same command with the new grace and glow-timing cases: 85 passed.
  • After the flag gate, with new flag-off cases (running and finished keep today's record), --repeat-each=3: 57 passed.
  • bun run test:e2e -- e2e/regression/timeline-stream.spec.ts -g compaction --repeat-each=2 (packages/app): 8 passed. The existing flag-off tests are back to their original assertions, and a new one turns the flag on through the mock config and checks the quiet divider runs, finishes as Compacted and opens the summary.
  • Full session-ui component suite: all passed except the box-stability test, which measured the whole row. Fixed it to measure the divider itself; it passed 5/5 repeats afterwards.
  • App e2e timeline-stream.spec.ts compaction group: the failed and interrupted cases now expect the quiet divider (no partial summary) while an automatic compaction runs; 3/3 repeats pass.
  • motion-lint static scan: 0 violations. Live traces of the long, instant and inside-grace runs: 0 hitches. The flags are the continued reply popping in (not this PR) and a ±0.04 opacity "wobble" on the glint that comes from uneven sample timing; the recorded values decrease steadily.
  • A test holds the animations at 30%, 48%, 57% and 90% of the cycle: the label stays dark until the light arrives, glows at 57% and is dark again at 90%.
  • bun typecheck (packages/session-ui and packages/app), bun script/lint-changed.ts origin/dev: clean.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. I took screenshots of the CompactionInProgress story on the base and on this branch (attached), and ran session-compaction.spec.ts and session-timeline.spec.ts with --repeat-each=3: 45/45 passed. tsc -b in packages/session-ui is clean. Manual and failed compactions keep the full record, and cancelled or interrupted ones count as failed, so they keep it too.

Public API: unchanged. The only addition is the UI string ui.messagePart.compaction.done.

One small accessibility point is in an inline comment: the running state no longer reaches screen readers.

Before

After

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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old running row was wrapped in role="status", so screen readers announced "Compacting". This divider is a disabled button with only aria-busy and no live region, so assistive tech no longer hears that a compaction started or finished. Wrapping the divider (or the label) in role="status" would bring that back without changing the layout.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit updates the e2e compaction expectations to match the quiet divider, and the change looks right. The component code is unchanged, so my earlier check (story screenshots on base vs PR, component specs 45/45 over 3 repeats) still covers it. I did not run the app e2e suite.

The earlier point about the missing role="status" for the running divider is still open. This commit drops the e2e assertion on that status role rather than restoring it.

Public API: unchanged.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits change the entrance timing and aria-controls, and both look right. The inside-grace path restarts the fade and shows only "Compacted". I ran session-compaction.spec.ts with --repeat-each=5 (40/40, including the new inside-grace case), and tsc -b in packages/session-ui is clean.

The earlier point about the missing live region for the running divider is still open.

Public API: unchanged.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits work as described. Only the ending changes, and the stem and the divider's box hold still (the box-stability test now checks the stem too). I ran session-compaction.spec.ts with --repeat-each=3 (27/27), and tsc -b in packages/session-ui is clean.

The earlier point about the running divider having no live region is still open. The new aria-label gives it a name but doesn't announce changes.

The new glow-timing test pins animation tuning; see the inline comment.

Public API: unchanged.

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 }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test checks exact keyframe opacities at fixed points of the 3.2 s cycle (30/48/57/90%, glint ≈ 0.75). Any easing or timing tweak to the light will break it, even though nothing a user would notice has changed. It also runs four at() calls through Promise.all that all pause and seek the same shared animations, which only works because page evaluations run one at a time. I'd drop it, or cut it down to a single check that the glint is off at the start of the cycle and on at some point after it.

@kitlangton kitlangton changed the title feat(session-ui): quiet automatic compaction divider feat(session-ui): quiet divider for automatic compaction with background compaction on Oct 25, 2026

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only uses the quiet divider when experimental.background_compaction is on, and the gate works. Manual and failed compactions, and any compaction with the flag off, keep the full record. I ran session-compaction.spec.ts and session-timeline.spec.ts (19/19, including the new flag-off cases). tsc -b in packages/session-ui and tsc --noEmit in packages/app are clean. The client's reading of the flag matches the server's Config.latest(..., "experimental").

The two earlier points (no live region for the running divider, and the keyframe-timing test) are still open.

New: the divider flashes on load while the config is still loading; see the inline comment.

Public API: unchanged. backgroundCompaction is a new optional, internal prop on session-ui's DataProvider, and the config key already existed.

)

// Matches the server: the highest-priority document that sets `experimental` decides.
const backgroundCompaction = () =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the config query is still loading, config.data is undefined, so this is false. For a user with the flag on, a timeline that already has compactions first renders the full record ("Session compaction started", the whole summary, the separator) and then collapses to the quiet divider once the config arrives. That's a visible flash and a layout shift on every load. Treating "still loading" separately from "off" would avoid it. For example, keep the timeline's compaction rows from rendering until the query has resolved, or reuse config the app has already loaded.

@kitlangton
kitlangton merged commit 5ed03ad into dev Oct 25, 2026
22 checks passed
@kitlangton
kitlangton deleted the quiet-compaction branch October 25, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant