From 10b67018693b75443dcc3b15912c91c085d8ae3b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 20 Aug 2026 12:12:11 +0200 Subject: [PATCH 1/2] fix(vscode): stabilize working indicator transitions --- .changeset/steady-working-status.md | 5 + .../tests/session-dock-stability.spec.ts | 98 ++++++++++++++- .../src/components/shared/StatusText.tsx | 107 ++++++++++++++++ .../components/shared/WorkingIndicator.tsx | 39 +++--- .../webview-ui/src/stories/chat.stories.tsx | 9 +- .../webview-ui/src/styles/chat-layout.css | 114 +++++++++++++++++- 6 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 .changeset/steady-working-status.md create mode 100644 packages/kilo-vscode/webview-ui/src/components/shared/StatusText.tsx diff --git a/.changeset/steady-working-status.md b/.changeset/steady-working-status.md new file mode 100644 index 00000000000..3a60661fe32 --- /dev/null +++ b/.changeset/steady-working-status.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the working indicator steady while the agent works: the status label now shimmers and glides into its new width instead of jumping the spinner sideways on every status change. diff --git a/packages/kilo-vscode/tests/session-dock-stability.spec.ts b/packages/kilo-vscode/tests/session-dock-stability.spec.ts index 2517eb20f51..6b97634c6d1 100644 --- a/packages/kilo-vscode/tests/session-dock-stability.spec.ts +++ b/packages/kilo-vscode/tests/session-dock-stability.spec.ts @@ -14,13 +14,20 @@ import { expect, test, type Page } from "@playwright/test" const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" const STORY_ID = "chat--chat-view-session-dock-stability" -async function openStory(page: Page) { +async function openStory(page: Page, motion = false) { await page.setViewportSize({ width: 720, height: 640 }) await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) - await page.addStyleTag({ - content: `*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }`, - }) + // Geometry assertions need settled layout, so motion is off unless the test is + // about the motion itself. + if (!motion) { + await page.addStyleTag({ + content: `*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }`, + }) + } await page.waitForSelector('[data-component="session-dock"]') + // Every assertion here is a width or a position, so nothing may be measured + // while the bundled font is still swapping in. + await page.evaluate(() => document.fonts.ready) } async function geometry(page: Page) { @@ -126,6 +133,89 @@ test("the counter keeps its width as it ticks", async ({ page }) => { expect(wide).toBe(before) }) +// Both motion preferences are emulated explicitly: the swap has two different +// behaviours and neither should depend on the ambient default. +test.describe("status swap", () => { + test("a status change glides the cluster instead of jumping", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }) + await openStory(page, true) + await page.getByTestId("toggle-busy").click() + const label = page.locator(".working-status") + await expect(label).toBeVisible() + await page.waitForFunction(() => !document.querySelector(".working-status[data-swap]")) + + // "Thinking…" to "Searching the codebase" is wide enough that a bare label + // swap moved the centered spinner by tens of pixels in a single frame. + const swap = await page.evaluate(async () => { + const spinner = document.querySelector('.working-indicator [data-component="spinner"]') + const box = document.querySelector(".working-status") + const next = document.querySelector('[data-testid="next-status"]') + if (!(spinner instanceof Element) || !(box instanceof HTMLElement) || !(next instanceof HTMLElement)) + throw new Error("indicator missing") + + const left = () => spinner.getBoundingClientRect().left + const start = left() + const duration = getComputedStyle(box).transitionDuration + next.click() + + const frames: { left: number; width: string; lines: number }[] = [] + for (let i = 0; i < 6; i++) { + await new Promise((resolve) => requestAnimationFrame(resolve)) + frames.push({ + left: left(), + width: box.style.width, + lines: box.querySelectorAll(".working-status-line").length, + }) + } + return { start, duration, frames } + }) + + // The width is animated rather than reassigned. + expect(swap.duration).not.toBe("0s") + // In the frame of the swap the box still holds the outgoing width, so the + // spinner starts from exactly where it was instead of teleporting. + expect(Math.abs(swap.frames[0]!.left - swap.start)).toBeLessThanOrEqual(1) + expect(swap.frames[0]!.width).not.toBe("") + // Both labels are mounted for the crossfade. + expect(swap.frames[0]!.lines).toBe(2) + // and the cluster only ever travels toward its new position. + for (const [i, frame] of swap.frames.entries()) { + if (i === 0) continue + expect(frame.left).toBeLessThanOrEqual(swap.frames[i - 1]!.left) + } + + // Once the glide lands, the lock is released and only the new label is left. + await page.waitForFunction(() => !document.querySelector(".working-status[data-swap]")) + expect(await label.evaluate((el) => el.style.width)).toBe("") + expect(await label.locator(".working-status-line").count()).toBe(1) + const settled = await page + .locator('.working-indicator [data-component="spinner"]') + .evaluate((el) => el.getBoundingClientRect().left) + expect(settled).toBeLessThan(swap.start) + }) + + test("reduced motion cuts to the new status instead of animating it", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }) + await openStory(page, true) + await page.getByTestId("toggle-busy").click() + await expect(page.locator(".working-status")).toBeVisible() + await page.getByTestId("next-status").click() + + const swap = await page.locator(".working-status").evaluate((el) => { + const old = el.querySelector(".working-status-line[data-old]") + return { + glide: getComputedStyle(el).transitionDuration, + // The outgoing copy has no fade to carry it away, so it must not paint on + // top of the new label. + old: old ? getComputedStyle(old).display : "absent", + } + }) + + expect(swap.glide).toBe("0s") + expect(["absent", "none"]).toContain(swap.old) + }) +}) + test("a wrapped narrow-sidebar actions row is not clipped", async ({ page }) => { await openStory(page) // Narrow enough for the container query to wrap the actions row onto a diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/StatusText.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/StatusText.tsx new file mode 100644 index 00000000000..adeda13d8e1 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/shared/StatusText.tsx @@ -0,0 +1,107 @@ +/** + * StatusText + * + * The working indicator is a centered cluster, so replacing the status label + * outright moved the spinner by half the width delta — a visible jump every time + * the agent switched from reading to editing to writing a response. + * + * The label box is locked to the outgoing width, then released to the incoming + * width, so the cluster glides to its new position instead of teleporting. The + * outgoing label stays mounted for that one crossfade, and the incoming label + * shimmers with the same treatment the edit and write tools use for a pending + * title, so the row reads as one live element that is being rewritten. + */ + +import { type Component, Show, createEffect, createSignal, on, onCleanup, onMount } from "solid-js" +import { TextShimmer } from "@kilocode/kilo-ui/text-shimmer" + +/** Outlasts the width spring in chat-layout.css so the lock is released last. */ +const SWAP = 520 + +const measure = (el: HTMLElement | undefined) => (el ? `${Math.ceil(el.getBoundingClientRect().width)}px` : undefined) + +export const StatusText: Component<{ text: string }> = (props) => { + const [label, setLabel] = createSignal(props.text) + const [old, setOld] = createSignal() + const [width, setWidth] = createSignal() + + let box: HTMLSpanElement | undefined + let line: HTMLSpanElement | undefined + let frame: number | undefined + let timer: ReturnType | undefined + + const settle = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + if (timer !== undefined) clearTimeout(timer) + frame = undefined + timer = undefined + setOld(undefined) + setWidth(undefined) + } + + createEffect( + on( + () => props.text, + (next) => { + if (next === label()) return + // Read the box before the swap: mid-glide this is the animated width, so a + // status change during a glide continues from where the box actually is. + const from = measure(box) + settle() + setOld(label()) + setLabel(next) + setWidth(from) + // The line is `justify-self: start` and never wraps, so it keeps its + // natural width inside the locked box and can be measured directly. The + // frame also guarantees the swapped DOM is laid out before it is read. + frame = requestAnimationFrame(() => { + frame = undefined + setWidth(measure(line)) + timer = setTimeout(settle, SWAP) + }) + }, + { defer: true }, + ), + ) + + onCleanup(settle) + + // A label that outgrows the row is clipped rather than ellipsized: it is measured + // at its natural width for the glide, so it cannot also be clamped to the box. A + // fade marks the cut instead, and because it is only a mask it never feeds back + // into layout or into the measurement. Mid-glide that same fade covers the part + // of the incoming label the box has not opened up for yet. + // + // Observing the box is enough: the clip state can only change when its used width + // does, whether that is the surface resizing or a swap resizing the label. + onMount(() => { + const el = box + if (!el || typeof ResizeObserver === "undefined") return + const check = () => el.toggleAttribute("data-clip", el.scrollWidth > el.clientWidth + 1) + const observer = new ResizeObserver(check) + observer.observe(el) + onCleanup(() => observer.disconnect()) + check() + }) + + return ( + + {/* Keyed so each label is a fresh node: the entry animation replays on every + swap, which an in-place text update would not do. */} + + {(text) => ( + + + + )} + + + {(text) => ( + + )} + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx index 75a4271d92a..f06351a291e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx @@ -8,12 +8,13 @@ * a turn starts or ends. */ -import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js" +import { type Component, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Button } from "@kilocode/kilo-ui/button" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" +import { StatusText } from "./StatusText" import { tracksElapsed } from "./working-indicator-utils" export const WorkingIndicator: Component = () => { @@ -61,18 +62,15 @@ export const WorkingIndicator: Component = () => { onCleanup(() => clearInterval(id)) }) - const statusText = () => { + // Memoized so an unchanged label never reaches `StatusText`: the status is + // recomputed on every streamed part, and each pass through would otherwise + // replay the swap animation. + const statusText = createMemo(() => { const info = session.statusInfo() - if (info.type === "retry") { - const countdown = retryCountdown() - const retryMsg = info.message || language.t("session.status.retry") - return countdown > 0 ? `${retryMsg} (${countdown}s)` : retryMsg - } - if (info.type === "offline") { - return info.message || language.t("session.status.offline") - } + if (info.type === "retry") return info.message || language.t("session.status.retry") + if (info.type === "offline") return info.message || language.t("session.status.offline") return session.statusText() ?? language.t("ui.sessionTurn.status.thinking") - } + }) const formatElapsed = () => { const s = elapsed() @@ -84,6 +82,10 @@ export const WorkingIndicator: Component = () => { const isRetrying = () => session.statusInfo().type === "retry" + // The counter's slot is reserved for exactly as long as the turn is timed, so a + // state that never counts (a retry with no start time) keeps the row compact. + const timing = () => tracksElapsed(session.status(), session.submitting(), session.busySince()) + const handleCancelRetry = () => { const sid = session.currentSessionID() if (sid) { @@ -94,9 +96,18 @@ export const WorkingIndicator: Component = () => { return (
- {statusText()} - 0}> - {formatElapsed()} + + {/* Kept out of the label: a countdown inside the morphing text would swap it + once a second, and every tick would read as a new status. */} + 0}> + ({retryCountdown()}s) + + {/* Laid out for the whole turn and only faded until the first tick: mounting + the counter a second in shifted the whole cluster sideways. */} + + 0 ? undefined : ""}> + {formatElapsed()} + + undefined} continueInWorktree />
diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index 6b98082c641..09e6324228d 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -234,6 +234,12 @@ ============================================ */ .working-indicator { + /* Kept under the SWAP budget in StatusText.tsx, which releases the width lock + only after the glide has landed. */ + --working-status-glide: 420ms; + --working-status-fade: 240ms; + --working-status-ease: cubic-bezier(0.22, 1, 0.36, 1); + display: flex; align-items: center; justify-content: center; @@ -253,15 +259,59 @@ margin-inline: auto; } +/* The hidden state stays laid out to reserve the row height, so the spinner and + the label shimmer would otherwise animate for the whole idle session. */ +.session-dock-state:not([data-active]) .working-indicator * { + animation-play-state: paused; +} + /* Sized to its text rather than stretched, so the spinner, the label, and the counter stay one cluster. Stretching it pushed the counter onto the far edge of - whatever surface the dock spanned. */ -.working-text { + whatever surface the dock spanned. + + The cluster is centered, so every label change moved the spinner by half the + width delta. `StatusText` locks this box to the outgoing width and releases it + to the incoming one, which turns that jump into a single short glide. */ +.working-status { + display: inline-grid; flex: 0 1 auto; min-width: 0; + /* The clip is only meant to bite horizontally. The padding gives the labels room + to travel vertically, and the matching negative margin keeps the row the exact + height it was. `overflow-y: visible` is not an option: paired with a hidden + axis it computes to auto and turns the label into a scroll container. */ + padding-block: 0.75em; + margin-block: -0.75em; overflow: hidden; - text-overflow: ellipsis; + transition: width var(--working-status-glide) var(--working-status-ease); +} + +/* Set by StatusText while the label does not fit, so the cut reads as truncation + rather than a label that lost its last few characters. */ +.working-status[data-clip] { + mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent); +} + +/* Both labels share the one cell, so the outgoing copy never widens the box. */ +.working-status-line { + grid-area: 1 / 1; + justify-self: start; white-space: nowrap; + animation: working-status-in var(--working-status-fade) var(--working-status-ease) both; +} + +/* Rises out of the way as the incoming label rises in, so the swap reads as one + line being rewritten rather than two labels crossfading in place. */ +.working-status-line[data-old] { + animation-name: working-status-out; + animation-duration: calc(var(--working-status-fade) * 0.75); +} + +/* Sweep between the two greys the indicator already uses, instead of the shared + text tokens the transcript tools shimmer with. */ +.working-status [data-component="text-shimmer"] { + --text-shimmer-base-color: var(--vscode-descriptionForeground); + --text-shimmer-peak-color: var(--vscode-foreground); } /* Reserving the width keeps the cluster still while the counter ticks: tabular @@ -272,6 +322,64 @@ text-align: right; font-variant-numeric: tabular-nums; opacity: 0.7; + transition: opacity var(--working-status-fade) var(--working-status-ease); +} + +/* Before the first tick the counter holds its slot without showing a stale 0s. */ +.working-elapsed[data-empty] { + opacity: 0; +} + +/* Same treatment for the retry countdown: it ticks every second, so it is a + fixed-width sibling of the label instead of part of the morphing text. */ +.working-count { + flex-shrink: 0; + min-width: 5ch; + font-variant-numeric: tabular-nums; + opacity: 0.7; +} + +@keyframes working-status-in { + from { + opacity: 0; + filter: blur(1.5px); + transform: translateY(0.45em); + } + + to { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + +@keyframes working-status-out { + from { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + + to { + opacity: 0; + filter: blur(1.5px); + transform: translateY(-0.45em); + } +} + +@media (prefers-reduced-motion: reduce) { + .working-status { + transition-duration: 0ms; + } + + .working-status-line { + animation: none; + } + + /* Without its fade the outgoing copy would sit on top of the new label. */ + .working-status-line[data-old] { + display: none; + } } /* ============================================ From e4f4bc9748a42948c98c64f82a26cd76ecb3bb24 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 20 Aug 2026 12:41:46 +0200 Subject: [PATCH 2/2] fix(vscode): restore session tab contrast --- packages/kilo-vscode/webview-ui/src/styles/session-tabs.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css index b99cbfb38ec..4306826c51b 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css +++ b/packages/kilo-vscode/webview-ui/src/styles/session-tabs.css @@ -267,11 +267,11 @@ } .session-tab-switcher-meta { - color: var(--text-weak); + color: var(--text-base); } .session-tab-switcher-status { - color: var(--text-weak); + color: var(--text-base); font-weight: 500; line-height: 1; text-transform: uppercase;