Skip to content

[Bug]: The Thinking and running tool shimmers use 14–25% of a CPU core while visible #17897

Closed

Description

@davidvornholt

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

  1. Save the four files from "Benchmark files" below into one folder. index.html is a standalone page with 12 static work log rows and one active row, with the shimmer CSS copied from apps/web/src/index.css at dacd2cb. main.js is an Electron script that measures it.
  2. On Linux, run electron . 4 15 1 in that folder with Electron 44.5.1, on a 120 Hz display.
  3. Click Start and keep the window in front for about 6 minutes.
  4. Run python3 summary.py result.json.

The active row is shown in four variants, in a different order each round:

  • none: no animation
  • shine: live-tool-shine as it is (running tool rows, active work group header)
  • focusLinear: the ActivityShimmerOverlay markup with live-activity-focus as it is (the Thinking row, worktree setup, compacting)
  • focusSteps: the same overlay with animation-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 linear timing 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:

  1. Set animation-timing-function: steps(30) on .live-activity-focus and .live-activity-focus-counter. The overlay would then step the same way the shine already does (24.7% to 8.3% here).
  2. 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.0

Screenshots, 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, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
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

  1. juliusmarminge commented on Oct 12, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Closing this since #18191 landed the main fix you proposed (option 1): the Thinking overlay (live-activity-focus and its counter) now uses steps(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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions