Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-working-status.md
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 94 additions & 4 deletions packages/kilo-vscode/tests/session-dock-stability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions packages/kilo-vscode/webview-ui/src/components/shared/StatusText.tsx
Original file line number Diff line number Diff line change
@@ -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<string>()
const [width, setWidth] = createSignal<string>()

let box: HTMLSpanElement | undefined
let line: HTMLSpanElement | undefined
let frame: number | undefined
let timer: ReturnType<typeof setTimeout> | 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 (
<span class="working-status" ref={box} data-swap={old() === undefined ? undefined : ""} style={{ width: width() }}>
{/* Keyed so each label is a fresh node: the entry animation replays on every
swap, which an in-place text update would not do. */}
<Show when={label()} keyed>
{(text) => (
<span class="working-status-line" ref={line}>
<TextShimmer text={text} />
</span>
)}
</Show>
<Show when={old()} keyed>
{(text) => (
<span class="working-status-line" data-old="" aria-hidden="true">
<TextShimmer text={text} active={false} />
</span>
)}
</Show>
</span>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -94,9 +96,18 @@ export const WorkingIndicator: Component = () => {
return (
<div class="working-indicator">
<Spinner />
<span class="working-text">{statusText()}</span>
<Show when={elapsed() > 0}>
<span class="working-elapsed">{formatElapsed()}</span>
<StatusText text={statusText()} />
{/* 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. */}
<Show when={isRetrying() && retryCountdown() > 0}>
<span class="working-count">({retryCountdown()}s)</span>
</Show>
{/* Laid out for the whole turn and only faded until the first tick: mounting
the counter a second in shifted the whole cluster sideways. */}
<Show when={timing()}>
<span class="working-elapsed" data-empty={elapsed() > 0 ? undefined : ""}>
{formatElapsed()}
</span>
</Show>
<Show when={isRetrying()}>
<Button
Expand Down
9 changes: 8 additions & 1 deletion packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,16 @@ export const ChatViewSessionDockStability: Story = {
name: "ChatView — session dock keeps its height",
render: () => {
const [busy, setBusy] = createSignal(false)
// Statuses of deliberately different widths: the label swap is what used to
// shove the centered spinner sideways.
const labels = ["Thinking…", "Searching the codebase", "Making edits"]
const [step, setStep] = createSignal(0)
const status = () => (busy() ? "busy" : "idle")
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle", closeReason: "completed" }),
status,
statusInfo: () => ({ type: status() }),
statusText: () => (busy() ? "Thinking…" : undefined),
statusText: () => (busy() ? labels[step() % labels.length] : undefined),
busySince: () => (busy() ? Date.now() - 2000 : undefined),
submitting: () => busy(),
isSubmitting: () => busy(),
Expand All @@ -219,6 +223,9 @@ export const ChatViewSessionDockStability: Story = {
<button data-testid="toggle-busy" onClick={() => setBusy(!busy())}>
toggle busy
</button>
<button data-testid="next-status" onClick={() => setStep(step() + 1)}>
next status
</button>
<ChatView onForkSession={() => undefined} continueInWorktree />
</div>
</WorktreeModeProvider>
Expand Down
Loading
Loading