Repository navigation
[Bug]: The Thinking and running tool shimmers use 14–25% of a CPU core while visible #17897
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/web
Steps to reproduce
- Save the four files from "Benchmark files" below into one folder.
index.htmlis a standalone page with 12 static work log rows and one active row, with the shimmer CSS copied fromapps/web/src/index.cssat dacd2cb.main.jsis an Electron script that measures it. - On Linux, run
electron . 4 15 1in that folder with Electron 44.5.1, on a 120 Hz display. - Click Start and keep the window in front for about 6 minutes.
- Run
python3 summary.py result.json.
The active row is shown in four variants, in a different order each round:
none: no animationshine:live-tool-shineas it is (running tool rows, active work group header)focusLinear: theActivityShimmerOverlaymarkup withlive-activity-focusas it is (the Thinking row, worktree setup, compacting)focusSteps: the same overlay withanimation-timing-function: steps(30)
Each run reads the CPU time of the browser, renderer, and GPU processes from /proc for 15 s, then counts frames in a 3 s trace.
In the app, the same CSS runs whenever a Thinking row or a running tool row is on screen.
Expected behavior
A highlight moving across one label shouldn't cost a noticeable share of a CPU core. #16372 restored the shine with: "We've profiled that shimmer many times and it isn't a cost: it uses steps(30) rather than continuous repaints and pauses through --visible-animation-state while off-screen."
Actual behavior
Medians of 4 runs per variant, one active row, window visible and focused. CPU is in % of one core (browser + renderer + GPU process).
| Variant | CPU (range) | Browser / renderer / GPU | Frames drawn/s | BeginFrames/s | Paints/s |
|---|---|---|---|---|---|
| none | 0.0% (0.0–0.1) | 0.0 / 0.0 / 0.0 | 0 | 0 | 0 |
| shine | 14.3% (13.4–16.2) | 1.1 / 8.3 / 5.0 | 13.7 | 119.5 | 27.3 |
| focusLinear | 24.7% (23.1–26.0) | 5.6 / 6.1 / 12.9 | 113.3 | 120.5 | 0 |
| focusSteps | 8.3% (7.6–8.5) | 1.6 / 2.8 / 3.8 | 13.3 | 118.3 | 0 |
- Shine:
steps(30)does limit drawn frames to about 13.7/s (30 steps in 2.2 s). But Blink repaints the text on the main thread about 27 times a second, and Chromium runs about 120 BeginFrames a second. - Thinking overlay: it's a transform, so it doesn't repaint, but with
lineartiming a frame is drawn on every refresh, 113 a second here. It's the most expensive of the three. - Overlay with
steps(30): no repaints and about 13 frames a second, for a third of the linear overlay's cost and a bit over half of the shine's. What's left is mostly the 120 BeginFrames a second that Chromium runs while any animation is active.
The rows already pause off-screen through observeVisibleAnimation. These costs only apply while the row is on screen. But that's the whole time an agent is working and T3 Code is in front, so on a laptop it's a steady drain on the battery. T3 Code was the biggest steady CPU user on mine while agents were working.
Possible changes:
- Set
animation-timing-function: steps(30)on.live-activity-focusand.live-activity-focus-counter. The overlay would then step the same way the shine already does (24.7% to 8.3% here). - Render the running tool label with that stepped overlay instead of
live-tool-shine(14.3% to 8.3% here). That brings back the second copy of the label that perf(web): stop continuous chat status animations #9709 removed, so you may not want it.
What I didn't test:
- The real app. The page approximates the colors and fonts.
- macOS, Windows, other GPUs, or a 60 Hz display. At 60 Hz, the linear overlay should draw about half as many frames.
- More than one active row.
- How the stepped overlay looks next to the current one. A PR would need before/after captures.
Related: #9709 removed the overlay; #9799 and #9777 restored the motion; #15033 removed the shine; #16372 restored it. W07 in #9661 ("work-row shimmers keep generating frames") is checked off.
Impact
Major degradation or frequent failure
Version or commit
0.0.45 (desktop). The same CSS is on main @ dacd2cb.
Environment
NixOS, Linux 7.2.9, GNOME Shell 50.5 (Wayland), ThinkPad X1 Carbon Gen 13 (Intel Core Ultra 7 258V), 2880x1800 at 120 Hz with 1.67 scaling, Electron 44.5.1 / Chromium 152.0.7977.130
Logs or stack traces
none total cpu median 0.0 range 0.0- 0.1 b/r/g med 0.0 0.0 0.0 wakeups med 2 draw 0.0 begin 0.0 paint 0.0
shine total cpu median 14.3 range 13.4- 16.2 b/r/g med 1.1 8.3 5.0 wakeups med 1157 draw 13.7 begin 119.5 paint 27.3
focusLinear total cpu median 24.7 range 23.1- 26.0 b/r/g med 5.6 6.1 12.9 wakeups med 1792 draw 113.3 begin 120.5 paint 0.0
focusSteps total cpu median 8.3 range 7.6- 8.5 b/r/g med 1.6 2.8 3.8 wakeups med 675 draw 13.3 begin 118.3 paint 0.0Screenshots, recordings, or supporting files
Benchmark files
package.json
{ "name": "t3-shine-bench", "version": "0.0.0", "main": "main.js" }index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>T3 shine bench</title>
<style>
:root {
--background: #161616;
--foreground: #e8e8e8;
--secondary-label: #8f8f8f;
--contrast-foreground: var(--foreground);
--contrast-secondary-label: var(--secondary-label);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
html, body { margin: 0; height: 100%; background: var(--background); color: var(--secondary-label); font-family: var(--font-sans); }
main { max-width: 48rem; margin: 0 auto; padding: 2rem 1rem; }
p { color: var(--foreground); line-height: 1.6; font-size: 0.875rem; }
.row { display: flex; align-items: center; gap: 0.5rem; height: 1.75rem; padding: 0 0.25rem; font-size: 0.875rem; }
.icon { width: 1rem; height: 1rem; flex-shrink: 0; border-radius: 3px; background: #444; }
.label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.time { flex-shrink: 0; font-size: 0.75rem; color: #666; }
.plain-active { color: var(--foreground); }
/* Copied from apps/web/src/index.css (dacd2cb649) */
@keyframes live-tool-shine {
from { background-position: -4.5rem 0, 0 0; }
to { background-position: calc(100% + 4.5rem) 0, 0 0; }
}
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
.live-tool-shine {
color: transparent;
background-image:
linear-gradient(to right, transparent, var(--contrast-foreground), transparent),
linear-gradient(var(--contrast-secondary-label), var(--contrast-secondary-label));
background-size: 4.5rem 100%, 100% 100%;
background-repeat: no-repeat;
background-clip: text;
animation: live-tool-shine 2.2s steps(30) infinite;
animation-play-state: var(--visible-animation-state, paused);
}
}
@keyframes live-activity-focus { from { transform: translateX(0); } to { transform: translateX(100%); } }
@keyframes live-activity-focus-counter { from { transform: translateX(0); } to { transform: translateX(-100%); } }
.live-activity-focus {
--live-activity-focus-width: 4.5rem;
position: absolute; top: 0; bottom: 0;
pointer-events: none; user-select: none;
right: auto;
left: calc(-1 * var(--live-activity-focus-width));
width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width));
mask-image: linear-gradient(to right, transparent 0, rgb(0 0 0 / 12%) 0.675rem, rgb(0 0 0 / 55%) 1.575rem, black 2.25rem, rgb(0 0 0 / 55%) 2.925rem, rgb(0 0 0 / 12%) 3.825rem, transparent var(--live-activity-focus-width), transparent 100%);
mask-repeat: no-repeat;
animation: live-activity-focus 2.2s linear infinite;
animation-play-state: var(--visible-animation-state, paused);
will-change: var(--visible-animation-will-change, auto);
}
.live-activity-focus-counter {
display: block;
width: 100%;
animation: live-activity-focus-counter 2.2s linear infinite;
animation-play-state: var(--visible-animation-state, paused);
will-change: var(--visible-animation-will-change, auto);
}
.live-activity-focus-aligned {
display: block;
width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width));
margin-left: var(--live-activity-focus-width);
color: var(--foreground);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
@media (prefers-reduced-motion: reduce), (forced-colors: active) {
.live-activity-focus { animation: none; opacity: 0; will-change: auto; }
.live-activity-focus-counter { animation: none; will-change: auto; }
}
.steps .live-activity-focus, .steps .live-activity-focus-counter { animation-timing-function: steps(30); }
</style>
</head>
<body>
<main>
<p id="start" style="color:#c9a227">T3 shine benchmark: move this window to the display you want to measure, then <button onclick="window.started = true; this.parentElement.textContent = 'Running. Keep this window in front until it closes (about 6 minutes).'">Start</button></p>
<p>Here's the plan: read the failing test, find where the timeout comes from, and fix it at the source instead of raising the limit.</p>
<div id="log"></div>
</main>
<script>
const rows = Number(new URLSearchParams(location.search).get("rows") || 1);
const done = ["Read apps/server/src/orchestration/projector.ts", "Searched for \"shellSnapshot\" in apps/server", "Ran bun run test --filter projector", "Edited apps/server/src/orchestration/projector.ts", "Read apps/web/src/components/chat/MessagesTimeline.tsx", "Ran git diff --stat", "Searched for \"visibleAnimation\"", "Read apps/web/src/lib/visibleAnimation.ts", "Ran bun typecheck", "Edited apps/web/src/index.css", "Read packages/contracts/src/orchestration.ts", "Ran bun run lint"];
const active = ["Running bun run test --filter apps/web", "Reading apps/web/src/components/Sidebar.tsx", "Searching for \"live-tool-shine\""];
const log = document.getElementById("log");
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
const staticRows = done.map((t, i) => `<div class="row"><span class="icon"></span><span class="label">${esc(t)}</span><span class="time">10:${String(12 + i).padStart(2, "0")}</span></div>`).join("");
const vars = 'style="--visible-animation-state: running; --visible-animation-will-change: transform"';
function activeRow(variant, t) {
if (variant === "none")
return `<div class="row"><span class="icon"></span><span class="label plain-active">${esc(t)}</span><span class="time">10:30</span></div>`;
if (variant === "shine")
return `<div class="row" ${vars}><span class="icon"></span><span class="label live-tool-shine">${esc(t)}</span><span class="time">10:30</span></div>`;
return `<div class="row ${variant === "focusSteps" ? "steps" : ""}"><span class="icon"></span><span class="label" style="position: relative; --visible-animation-state: running; --visible-animation-will-change: transform">${esc(t)}<span aria-hidden="true" class="live-activity-focus"><span class="live-activity-focus-counter"><span class="live-activity-focus-aligned">${esc(t)}</span></span></span></span><span class="time">10:30</span></div>`;
}
window.setVariant = (variant) => {
log.innerHTML = staticRows + active.slice(0, rows).map((t) => activeRow(variant, t)).join("");
return document.getAnimations().filter((a) => a.playState === "running").map((a) => a.animationName).join(",");
};
setVariant("none");
</script>
</body>
</html>main.js
// Measures the CPU cost of T3 Code's active tool label animations in a
// standalone page. Usage: electron . [rounds] [measureSeconds] [rows]
const { app, BrowserWindow, contentTracing } = require("electron");
const fs = require("fs");
const path = require("path");
const [rounds = 4, measureS = 15, rows = 1] = process.argv.slice(2).filter((a) => /^\d+$/.test(a)).map(Number);
const VARIANTS = ["none", "shine", "focusLinear", "focusSteps"];
const WARM_MS = 3000;
const OUT = path.join(__dirname, "result.json");
app.setPath("userData", path.join(__dirname, "profile"));
const ticks = (pid) => {
const s = fs.readFileSync(`/proc/${pid}/stat`, "utf8").split(") ")[1].split(" ");
return Number(s[11]) + Number(s[12]);
};
const switches = (pid) => {
let n = 0;
for (const t of fs.readdirSync(`/proc/${pid}/task`)) {
try {
const st = fs.readFileSync(`/proc/${pid}/task/${t}/status`, "utf8");
n += Number(/^voluntary_ctxt_switches:\s+(\d+)/m.exec(st)[1]);
n += Number(/^nonvoluntary_ctxt_switches:\s+(\d+)/m.exec(st)[1]);
} catch {}
}
return n;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Frames drawn by viz and paints by Blink per second, from a 3 s trace
async function countFrames(variant) {
await contentTracing.startRecording({
included_categories: ["viz", "cc", "devtools.timeline", "disabled-by-default-devtools.timeline"],
excluded_categories: ["*"],
});
await sleep(3000);
const file = await contentTracing.stopRecording(path.join(__dirname, `trace-${variant}.json`));
const events = JSON.parse(fs.readFileSync(file, "utf8")).traceEvents;
const count = (name) => events.filter((e) => e.name === name && (e.ph === "X" || e.ph === "B")).length / 3;
return {
drawAndSwap: +count("Display::DrawAndSwap").toFixed(1),
beginFrames: +count("Scheduler::BeginFrame").toFixed(1),
paint: +count("Paint").toFixed(1),
};
}
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 1400, height: 900, show: false, backgroundColor: "#161616", title: "T3 shine benchmark" });
await win.loadFile(path.join(__dirname, "index.html"), { query: { rows: String(rows) } });
win.maximize();
win.show();
// Wait for a click on the start button, so that the window is in front and drawn
const deadline = Date.now() + 10 * 60 * 1000;
while (!process.env.SKIP_FOCUS && !(await win.webContents.executeJavaScript("window.started === true"))) {
if (Date.now() > deadline) { console.log("never focused"); app.exit(2); return; }
await sleep(250);
}
// Time to move the window to the right display before measuring
console.log("started");
await sleep(2000);
const wc = win.webContents;
const pids = () => ({
browser: process.pid,
renderer: wc.getOSProcessId(),
gpu: app.getAppMetrics().find((m) => m.type === "GPU").pid,
});
const out = {
electron: process.versions.electron,
chrome: process.versions.chrome,
scaleFactor: win.webContents.zoomFactor * (await wc.executeJavaScript("devicePixelRatio")),
contentSize: win.getContentSize(),
rows,
measureS,
runs: [],
};
for (let round = 0; round < rounds; round++) {
// Rotate the order each round so drift doesn't favor one variant
const order = VARIANTS.map((_, i) => VARIANTS[(i + round) % VARIANTS.length]);
for (const variant of order) {
const running = await wc.executeJavaScript(`setVariant(${JSON.stringify(variant)})`);
await sleep(WARM_MS);
const p = pids();
const a = Object.fromEntries(Object.entries(p).map(([k, pid]) => [k, [ticks(pid), switches(pid)]]));
const t0 = Date.now();
await sleep(measureS * 1000);
const s = (Date.now() - t0) / 1000;
const run = { round, variant, running, visible: await wc.executeJavaScript("document.visibilityState") };
for (const [k, pid] of Object.entries(p)) {
run[`${k}Cpu`] = +((ticks(pid) - a[k][0]) / s).toFixed(2);
run[`${k}Wakeups`] = Math.round((switches(pid) - a[k][1]) / s);
}
run.frames = await countFrames(variant);
run.focused = win.isFocused();
out.runs.push(run);
console.log(JSON.stringify(run));
fs.writeFileSync(OUT, JSON.stringify(out, null, 1));
}
}
app.quit();
});summary.py
import json, sys, statistics as st
d = json.load(open(sys.argv[1]))
print({k: v for k, v in d.items() if k != "runs"})
for r in d["runs"]:
f = r["frames"]
print(r["round"], "%-12s" % r["variant"],
"cpu b/r/g %5.2f %5.2f %5.2f" % (r["browserCpu"], r["rendererCpu"], r["gpuCpu"]),
"wake b/r/g %4d %4d %4d" % (r["browserWakeups"], r["rendererWakeups"], r["gpuWakeups"]),
"draw %5.1f begin %5.1f paint %5.1f" % (f["drawAndSwap"], f["beginFrames"], f["paint"]),
"foc", r["focused"])
print()
for v in ["none", "shine", "focusLinear", "focusSteps"]:
rs = [r for r in d["runs"] if r["variant"] == v]
tot = [r["browserCpu"] + r["rendererCpu"] + r["gpuCpu"] for r in rs]
w = [r["browserWakeups"] + r["rendererWakeups"] + r["gpuWakeups"] for r in rs]
med = lambda k: st.median(r[k] for r in rs)
fmed = lambda k: st.median(r["frames"][k] for r in rs)
print("%-12s total cpu median %5.1f range %5.1f-%5.1f b/r/g med %4.1f %4.1f %4.1f wakeups med %d draw %.1f begin %.1f paint %.1f" % (
v, st.median(tot), min(tot), max(tot), med("browserCpu"), med("rendererCpu"), med("gpuCpu"), st.median(w), fmed("drawAndSwap"), fmed("beginFrames"), fmed("paint")))Workaround
Launching T3 Code with --force-prefers-reduced-motion should turn off both animations, since both are behind prefers-reduced-motion media queries. It turns off other motion in the app too. I checked that the flag makes matchMedia('(prefers-reduced-motion: reduce)') match in Electron 44.5.1, but I didn't measure T3 Code with it.
I looked into this with Claude Code (Opus 5.5), which also wrote the benchmark.
Activity
juliusmarminge commentedon Oct 12, 2026
Note
Grok responding on behalf of Julius.
Closing this since #18191 landed the main fix you proposed (option 1): the Thinking overlay (
live-activity-focusand its counter) now usessteps(30), which cut drawn frames by about 72% and app CPU by about 46% in the real app.Option 2, rendering the running tool label with the stepped overlay instead of
live-tool-shine, would bring back the duplicate label that #9709 removed, so it isn't planned here. If you still want the running tool shine made cheaper, please open a fresh issue or PR with before/after captures and we can weigh it on its own.