Skip to content

feat(vscode): add a motion budget for the chat animations - #15361

Open
gouravkargwal wants to merge 9 commits into
Kilo-Org:mainfrom
gouravkargwal:feat/vscode/animation-controls
Open

gouravkargwal wants to merge 9 commits into
Kilo-Org:mainfrom
gouravkargwal:feat/vscode/animation-controls

Conversation

@gouravkargwal

@gouravkargwal gouravkargwal commented Oct 22, 2026 •

Copy link
Copy Markdown

Why

The chat animates while a session works: the status shimmer, the spinner, the tool icon breathe, the agent avatar pulse. Each of those ticks on the main thread, so the webview renderer keeps working at the display refresh rate for as long as the session runs. On a laptop that is the difference between a quiet machine and a hot one, and it gets worse the more chat windows are open.

#14792 measures this on Windows and on a 144 Hz display. The same cost shows up on macOS: with one window running a single subagent, the renderer for that window sat at 0.6-0.7 cores and kilo serve at roughly 15% of a core, while the rest of the machine was idle.

The shimmer is a background-position animation on a background-clip: text element, so it cannot be composited and it repaints on every frame. The other busy indicators animate opacity and transform, which are cheap on their own, but there are always a few of them running and each one keeps the animation tick alive.

What changed

Motion is now a budget instead of a fixed behaviour.

  • packages/kilo-ui/src/styles/motion.css defines the contract. A client writes data-animations on the root element and the stylesheet reacts:
    • full - the default, no rule matches
    • reduced - looping animations play once and hold their end state
    • paused - whatever is running stops where it is and resumes later
    • off - nothing animates and nothing transitions
  • The VS Code extension reports the budget to the webview. The new kilo-code.new.uiAnimations setting picks full, reduced, or off, and the window state is reported alongside it: a chat in a window that is not focused is paused, because the user is not looking at it.
  • The webview also pauses the chat while the view is hidden, which the extension cannot see.

The values are picked so that components which clean up in an animationend or transitionend handler still get their event: a shortened animation still ends, and a paused animation resumes and fires its event once the motion is allowed again. Nothing is left half revealed.

The budget is one source of truth

The stylesheet cannot reach the Web Animations API, so the budget does not stop there:

  • useReducedMotion in packages/kilo-ui/src/hooks/use-reduced-motion.ts now honours the budget next to the OS setting, so the script-driven motion in tool-motion.ts, grow-box.tsx, and rolling-results.tsx stops with it. The hook reads the same root attribute the stylesheet reads, so the two can never disagree.
  • The webview already carried a reduce-motion escape hatch that the stylesheet and the SessionDock and TodoChip motion read. Kilo sets its own kilo-reduce-motion marker for the budget and the existing checks read both markers, because vscode-reduce-motion belongs to VS Code: the host puts that class on the webview body when the user asked for reduced motion and removes it again on every style change, so writing on it would take the user's setting away.

Scope

This covers the chat webview (the sidebar and the editor panels), which is where the session status is animated. The Agent Manager has its own webview and its own message contract, so it is not included here.

The server side is not touched: kilo serve used roughly 15% of a core during a session, which is a separate cost from the renderer.

Verification

  • bun run typecheck in packages/kilo-vscode (host and webview)
  • bun typecheck --filter='!@kilocode/kilo-jetbrains' from the repo root, which is what the pre-push gate runs
  • bun run lint, bun run knip in packages/kilo-vscode
  • bun test tests/unit/chat-animations.test.ts
  • bun esbuild.js builds the webview, and the new rules are present in the emitted stylesheet

The JetBrains half of the pre-push gate was not run locally: it needs a Java 21 toolchain and pulls about 14 GB of Gradle caches, and this change touches no Kotlin.

@github-actions github-actions Bot added area:ui Shared UI components area:vscode VS Code extension community Opened by someone outside the Kilo team needs-triage Waiting for a maintainer to look priority:p2 Medium: features and other work. Set from the title. Change by hand. labels Oct 22, 2026
"full" the default, no rule matches
"reduced" looping animations play once and hold their end state
"paused" whatever is running stops where it is and resumes later
"off" nothing animates and nothing transitions

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.

SUGGESTION: off/paused only stop CSS animations, not the JS-driven motion

These selectors only match CSS animations and transitions, but the chat also plays Web Animations API animations that this stylesheet cannot reach: the tool-row height glide (packages/kilo-ui/src/components/tool-motion.ts:147), the dock pill flight (packages/kilo-vscode/webview-ui/src/components/chat/SessionDock.tsx:420), and the todo chip animations (packages/kilo-vscode/webview-ui/src/components/chat/todo/TodoChip.tsx). They keep running with data-animations="off" or "paused", so the contract above ("nothing animates", "stops where it is") only holds for CSS-driven motion. useReducedMotion also still reads prefers-reduced-motion alone, so the new budget never reaches those code paths.

If the budget is meant to be authoritative, consider having applyAnimations expose the resolved mode to useReducedMotion (or check it at those call sites); otherwise the comment and the "Turn off chat animations entirely" description could be qualified. Non-blocking.

@kilo-code-bot

kilo-code-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • packages/kilo-vscode/tests/unit/happy-dom.fixture.ts - new shared helper
  • packages/kilo-vscode/tests/unit/chat-animations.test.ts - imports the fixture
  • packages/kilo-vscode/tests/unit/diff-keyboard-focus.test.ts - imports the fixture

The incremental commit resolves the previous useDom duplication suggestion: the helper now lives once in happy-dom.fixture.ts with the same body and restore semantics, and both test files import it without leaving an unused Window import. The .fixture.ts naming matches the existing markdown-worker-recovery.fixture.tsx and roo-migration-ui.fixture.tsx precedent and does not match Bun's test-file globs, so it will not be collected as an empty suite. No memory, lifecycle, or cleanup regressions in the changed lines.

Previous Review Summaries (4 snapshots, latest commit 63d79a4)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 63d79a4)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-vscode/tests/unit/chat-animations.test.ts 16 useDom is a verbatim copy of the existing helper in tests/unit/diff-keyboard-focus.test.ts:21; extract to a shared test util.
Files Reviewed (1 file)
  • packages/kilo-vscode/tests/unit/chat-animations.test.ts - 1 suggestion

The incremental commit replaces the source-text grep with real happy-dom assertions: applyAnimations("off"/"full") now verifies kilo-reduce-motion is toggled while a pre-existing vscode-reduce-motion class survives, unfocused windows pause via the same class, and data-animations is written/cleared on the root element. The previous test-quality suggestion is resolved, and the earlier vscode-reduce-motion write warning no longer applies at HEAD (animations.ts:42-58 only reads the host class and writes the Kilo-owned marker). No memory leaks or lifecycle issues in the new code.

Fix these issues in Kilo Cloud

Previous review (commit 6a6ec96)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-vscode/tests/unit/chat-animations.test.ts 58 Source-text grep guard does not test behaviour; use the existing happy-dom harness to assert kilo-reduce-motion is toggled while vscode-reduce-motion survives.
Files Reviewed (7 files)
  • packages/kilo-vscode/tests/unit/chat-animations.test.ts - 1 suggestion
  • packages/kilo-vscode/webview-ui/src/animations.ts - no new issues (previous vscode-reduce-motion write now uses a Kilo-owned class; resolved)
  • packages/kilo-vscode/webview-ui/src/components/chat/SessionDock.tsx - no issues
  • packages/kilo-vscode/webview-ui/src/components/chat/todo/TodoChip.tsx - no issues
  • packages/kilo-vscode/webview-ui/src/styles/goal.css - no issues
  • packages/kilo-vscode/webview-ui/src/styles/prompt-input.css - no issues
  • packages/kilo-vscode/webview-ui/src/styles/todo-dock.css - no issues

The incremental commit resolves the earlier warning: applyAnimations now toggles the Kilo-owned kilo-reduce-motion class, the script-driven motion in SessionDock/TodoChip reads it through reduceMotionEnabled(), and the three remaining stylesheets match both markers via body:is(...) with unchanged specificity. No memory leaks, listener, or lifecycle issues found in the new code.

Fix these issues in Kilo Cloud

Previous review (commit 6ede287)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
packages/kilo-vscode/webview-ui/src/animations.ts 41 classList.toggle("vscode-reduce-motion", …) removes the host-provided reduce-motion marker when the Kilo budget is full or reduced, dropping reduced motion for users who enabled it in VS Code. Use a Kilo-owned class instead.
Files Reviewed (5 files)
  • packages/kilo-ui/src/hooks/use-motion.ts - no issues
  • packages/kilo-ui/src/hooks/use-reduced-motion.ts - no issues
  • packages/kilo-ui/src/styles/motion.css - no new issues (prior WAAPI finding now addressed)
  • packages/kilo-vscode/tests/unit/chat-animations.test.ts - no issues
  • packages/kilo-vscode/webview-ui/src/animations.ts - 1 warning

The new useMotion hook observes data-animations on the root element and routes it into useReducedMotion, so the previous concern about off/paused not reaching the Web Animations API paths is resolved: the tool-row glide, GrowBox, and rolling-results motion now gate on the budget, and the dock pill flight and todo chips gate on the body class. The observer is a single singleton instance disconnected in onCleanup, so no new listener or memory leak was found.

Fix these issues in Kilo Cloud

Previous review (commit 7e42d5b)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-ui/src/styles/motion.css 13 off/paused only govern CSS animations and transitions. The chat's Web Animations API motion (tool-row height glide, dock pill flight, todo chips) keeps running, and useReducedMotion still reads only prefers-reduced-motion, so the motion budget never reaches those paths.
Files Reviewed (11 files)
  • .changeset/chat-animation-budget.md - no issues
  • packages/kilo-ui/src/styles/index.css - no issues
  • packages/kilo-ui/src/styles/motion.css - 1 suggestion
  • packages/kilo-vscode/package.json - no issues
  • packages/kilo-vscode/src/KiloProvider.ts - no issues
  • packages/kilo-vscode/src/kilo-provider/animations.ts - no issues
  • packages/kilo-vscode/src/utils.ts - no issues
  • packages/kilo-vscode/tests/unit/chat-animations.test.ts - no issues
  • packages/kilo-vscode/webview-ui/src/animations.ts - no issues
  • packages/kilo-vscode/webview-ui/src/context/display.tsx - no issues
  • packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts - no issues

No memory or listener leaks found in the new code: the visibilitychange listener is removed in onCleanup, and watchAnimations chains its onDidChangeConfiguration/onDidChangeWindowState subscriptions into the provider's disposable so they are released with the webview handler. The settings wiring, the ready/update message flow, and the CSS import order (all @imports, so the late import is valid) all check out; the emitted selectors correctly use the data-animations attribute set by applyAnimations.

Fix these issues in Kilo Cloud


Reviewed by deepseek-flash-latest · Input: 91.2K · Output: 3.3K · Cached: 259.8K

Review guidance: REVIEW.md from base branch main

@gouravkargwal

Copy link
Copy Markdown
Author

Good catch, and it was the right thing to point at: the contract as written only reached the CSS.

Fixed in 6ede287. The budget is now a single source of truth instead of a stylesheet-only convention:

  • The client writes data-animations on the root element, the stylesheet reads it, and the script-driven motion reads the same attribute. useReducedMotion (packages/kilo-ui/src/hooks/use-reduced-motion.ts) now honours the budget next to the OS setting, so the Web Animations API calls in tool-motion.ts (the tool-row height glide), grow-box.tsx, and rolling-results.tsx stop with it. The hook follows the attribute through a MutationObserver, so the budget still applies when the window regains focus.
  • The webview already carried a vscode-reduce-motion escape hatch that the stylesheet and the SessionDock and TodoChip motion read, so the client sets that class while the budget is off or paused. That covers the dock pill flight and the todo chip animations without touching those call sites.

So the four element.animate(...) sites in the chat are now all inside the budget, not just the CSS ones. The PR description says so explicitly rather than leaving the contract to look stylesheet-only.

One deliberate boundary: "reduced" still allows the one-shot script-driven motion, since the budget only shortens the looping animations. "paused" and "off" stop everything.

const mode = resolveAnimations(user, focused, document.visibilityState === "hidden")
if (mode === "full") delete root.dataset.animations
else root.dataset.animations = mode
document.body?.classList.toggle("vscode-reduce-motion", reduceMotion(mode))

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.

WARNING: Toggling vscode-reduce-motion can remove the host's own reduce-motion marker

Nothing in this extension writes this class; the webview only reads it as a reduce-motion escape hatch (SessionDock.tsx:412, TodoChip.tsx:50, todo-dock.css:444, prompt-input.css:740), which points to it being the class VS Code puts on webview bodies when the user has reduced motion enabled.

classList.toggle(..., false) removes it on the first applyAnimations call (the default budget is full) and whenever the budget resolves to full/reduced, so a user who enabled reduced motion in VS Code loses it on exactly the JS paths that check the class.

Consider a Kilo-owned class (for example kilo-reduce-motion) added alongside the existing checks, or at least avoid removing a class the host may own. Non-blocking, but it is an accessibility regression for those users.

@gouravkargwal

Copy link
Copy Markdown
Author

Confirmed, and it was a real regression rather than a theoretical one: VS Code's webview bootstrap (out/vs/workbench/contrib/webview/browser/pre/index.html) puts vscode-reduce-motion on the webview body when initData.reduceMotion is set, and it removes that class again on every style change. So classList.toggle("vscode-reduce-motion", false) would have taken the user's reduced-motion setting away on exactly the paths that read the class.

Fixed in 6a6ec96:

  • Kilo now writes its own marker, kilo-reduce-motion, and never touches the host's class.
  • The existing checks read both: the stylesheets use body:is(.vscode-reduce-motion, .kilo-reduce-motion) and SessionDock and TodoChip go through a reduceMotionEnabled() helper that checks both classes.
  • A test asserts the host's class is only ever read, so a future change cannot write on it again.

The budget still stops everything it stopped before; it just stops it with a class Kilo owns.

// Writing on it would take that setting away from the user.
it("is only read, never written", () => {
const source = fs.readFileSync(path.join(ROOT, "webview-ui/src/animations.ts"), "utf-8")
expect(source).not.toContain('toggle("vscode-reduce-motion"')

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.

[SUGGESTION]: Assert the behaviour instead of the source text

This guard greps animations.ts for literal strings, so it still passes if the host class is written differently (single quotes, a variable, or a helper in another file) and fails on unrelated refactors, while never checking the actual outcome. The package already has a happy-dom harness (tests/unit/diff-keyboard-focus.test.ts:20), so you could install document/body, call applyAnimations("off", true) and applyAnimations("paused", true), and assert that kilo-reduce-motion is toggled on while a pre-existing vscode-reduce-motion class is left intact. That covers the regression this commit fixes without pinning the file's text. Non-blocking.

@gouravkargwal

Copy link
Copy Markdown
Author

Right, the source-text guard was the wrong tool. Replaced it with the happy-dom behaviour test in 63d79a4.

The test now installs a DOM, puts the host's vscode-reduce-motion class on the body the way VS Code does, and calls applyAnimations:

  • applyAnimations("off", true) adds kilo-reduce-motion and leaves vscode-reduce-motion in place
  • applyAnimations("full", true) takes Kilo's marker off and still leaves the host's class alone
  • applyAnimations("full", false) marks the chat as paused while the window is in the background
  • the budget lands on the root element for the stylesheet

I checked that it is a real guard: putting the old toggle("vscode-reduce-motion", …) back in makes two of the assertions fail, so it catches the regression whichever way the class is written.

import { applyAnimations, reduceMotion, resolveAnimations } from "../../webview-ui/src/animations"

/** Install the happy-dom globals the webview code relies on, and return a restore function. */
function useDom(window: Window, names: string[]) {

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.

[SUGGESTION]: useDom is a verbatim copy of an existing test helper

This helper, including the doc comment, is identical to useDom in tests/unit/diff-keyboard-focus.test.ts:21. Moving it into a shared test utility (for example tests/unit/happy-dom.ts) keeps the global install/restore logic in one place, so a future change to how happy-dom globals are installed or torn down doesn't need to be made twice. Non-blocking.

@gouravkargwal

Copy link
Copy Markdown
Author

Agreed, the helper belongs in one place. Done in dc5bdc6:

  • new packages/kilo-vscode/tests/unit/happy-dom.fixture.ts holds useDom
  • diff-keyboard-focus.test.ts imports it instead of keeping its own copy
  • chat-animations.test.ts imports it as well

The fixture naming follows the .fixture.tsx files already in tests/unit. Both test files pass (28 tests), and the full bun test tests/unit/ run shows the same 8 failures as the base commit before this PR, so nothing regressed here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ui Shared UI components area:vscode VS Code extension community Opened by someone outside the Kilo team needs-triage Waiting for a maintainer to look priority:p2 Medium: features and other work. Set from the title. Change by hand.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant