fix(status): keep connected MCP indicators static at rest (#806)

Keep connected MCP status dots green without a permanent pulse. Pending connect/disconnect operations retain their warning pulse and spinner; the shared reduced-motion and metadata refresh policies are unchanged.

Add real StatusTab/SUID browser regressions at 150% scale, a bounded historical/current Chromium profiler, and measurement-limit documentation. Five browser scenarios, UI typecheck/build and repeatable profiler assertions passed. Fix the gatekeeper P2 by creating the temporary evidence parent on clean machines; the second independent review reported zero findings.

The change addresses the idle-animation source in #804, not its unconfirmed generation GPU symptom or #805. Remote CI was pending at the explicitly requested admin merge.
This commit is contained in:
Pascal André 2026-09-30 14:03:19 +02:00 • committed by GitHub
parent eb1bf48e21
commit d995750065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 389 additions and 1 deletions

View file

@ -0,0 +1,57 @@
# UI motion profiling (#804)
Connected MCP servers describe a stable state, not active work. Their status
dots stay green without pulsing. Only a pending connect/disconnect retains the
warning pulse and spinner; success and failure both stop that feedback. This is
shared UI behavior for web, Electron and Tauri.
The existing global `prefers-reduced-motion: reduce` rule limits animations to
one near-instant iteration and shortens transitions. The MCP fix does not add a
second motion policy or alter refresh/cache semantics.
## Regression and bounded profiling
```powershell
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/mcp-motion.test.ts packages/ui/tests/browser/status-panel.test.ts
node scripts/profile-ui-motion.mjs eb1bf48e
```
The optional Git revision substitutes only the historical MCP component. The
remaining source and installed frontend dependencies are identical. The script
writes Chromium traces, screenshots and `results.json` under a temporary
`opencode/issue804-motion-*` directory. No native daemon or user data is used.
Fixtures use the real StatusTab, SUID switches, full stylesheet and SessionView
with the native event dispatcher. MCP calls and session HTTP are simulated. The
session fixture seeds 620 short history messages, then samples idle, token-free
single/grouped reasoning, 24 text deltas with a 50 ms delay between deliveries,
and completion. Both motion settings use a 150% device scale.
Observed in the September 30 isolated Chromium run:
| State | Infinite animations | Layout activity in ~1.2 s sample |
| --- | --- | --- |
| Eight connected MCPs, before fix | 8 | 0 layouts (opacity animation) |
| Eight connected MCPs, after fix | 0 | 0 layouts |
| Collapsed MCP section | 0 | 0 layouts |
| Single reasoning step, no deltas | 0 | 0 layouts |
| Grouped reasoning, no deltas | 1 spinner | 0 layouts |
| Grouped reasoning, reduced motion | 0 | 0 layouts |
| Session complete | 0 | 0 layouts |
Text streaming still generates rendering work with reduced motion: 52 layouts
were recorded for 24 deliveries in both settings (~1.7 s including delivery
overhead). The mounted transcript remained at 16 rows, rather than 620. These
observations do not establish a full-transcript redraw on every animation frame.
## Limits / follow-up
This is a **headless Chromium rendering sample**, not a WebView2/NVIDIA GPU
measurement. Compositor-only animation can consume GPU without producing main
thread layout/paint counters. No GPU percentage reduction is claimed.
The session fixture omits application/sidebar chrome and does not supply the
full-history timeline RPC. Long reasoning, large tool results, remote HTTP
bursts and full-app freezes still require a representative native recording.
The generation symptom of #804 and the remote Electron freeze in #805 remain
separate, unconfirmed problems; this change must not close either as fully fixed.

View file

@ -127,7 +127,8 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
const switchDisabled = () => isPending() || !instance().client
const statusDotClass = () => {
if (isPending()) return "status-dot animate-pulse"
if (server.status === "running") return "status-dot ready animate-pulse"
// A connected server is idle; only pending operations need motion.
if (server.status === "running") return "status-dot ready"
if (server.status === "error") return "status-dot error"
return "status-dot stopped"
}

View file

@ -0,0 +1,58 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import StatusTab from "../../../src/components/instance/shell/right-panel/tabs/StatusTab"
import { DEFAULT_RIGHT_PANEL_CUSTOMIZATION } from "../../../src/components/instance/shell/right-panel/registry"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider, useI18n } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import { shellStore } from "../../../src/stores/shells"
import type { Instance } from "../../../src/types/instance"
import { applyUiSettings } from "./ui-settings"
import "../../../src/index.css"
const names = ["dnd-5e", "dnd-rules", "fastctx", "foundry", "open5e", "qmd", "remoteshell", "serena"]
const calls: unknown[] = []
let settle: ((fail: boolean) => void) | undefined
const client = { mcp: {
connect: (input: any) => mutate(input, "connected"),
disconnect: (input: any) => mutate(input, "disabled"),
} }
const [instance, setInstance] = createSignal<Instance>({
id: "mcp-motion", folder: "/fixture", port: 0, pid: 0, proxyPath: "/fixture", status: "ready", client: client as any,
metadata: { mcpStatus: { location: { directory: "/fixture" }, data: names.map(name => ({ name, status: { status: "connected" } })) } as any },
})
function mutate(input: any, status: string) {
calls.push(input)
return new Promise<void>((resolve, reject) => {
settle = fail => {
settle = undefined
if (fail) return reject(new Error("Fixture connection failure"))
setStatus(input.server, status)
resolve()
}
})
}
function setStatus(name: string, status: string) {
setInstance(previous => ({ ...previous, metadata: { mcpStatus: {
...previous.metadata!.mcpStatus!,
data: previous.metadata!.mcpStatus!.data.map(server => server.name !== name ? server : {
...server, status: { status, ...(status === "failed" ? { error: "Fixture server failed" } : {}) },
}),
} as any } }))
}
// No native runtime is contacted: only the transport and unrelated Shell inventory are replaced.
shellStore.load = async () => {}
await applyUiSettings({ locale: "en" })
function Fixture() {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal<string[]>(["mcp"])
return <main style={{ width: "360px" }}>
<StatusTab instanceId="mcp-motion" instance={instance()} t={t} activeSession={() => null}
isActive={() => true} expandedItems={expanded} onExpandedItemsChange={setExpanded}
customization={() => ({ ...DEFAULT_RIGHT_PANEL_CUSTOMIZATION,
hiddenStatusSectionIds: ["tokens", "yolo-mode", "provider-usage", "background-processes", "plugins"],
})} onCustomizationChange={() => {}} />
</main>
}
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Fixture /></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).mcpFixture = { settle: (fail = false) => settle?.(fail), setStatus, calls }

View file

@ -0,0 +1,40 @@
import "./session"
import { sseManager } from "../../../src/lib/sse-manager"
import { applyUiSettings } from "./ui-settings"
// Reuse the real SessionView/native dispatcher fixture; drive lifecycle events
// independently from text deltas so token-free thinking can also be measured.
const instanceId = "browser-instance", sessionID = "browser-session", assistantMessageID = "msg_assistant"
let time = Date.now()
let grouped = false
const emit = (type: string, data: object = {}) => (sseManager as any).handleEvent(instanceId, {
id: `motion-${++time}`, type, created: time, location: { directory: "/fixture" },
data: { sessionID, assistantMessageID, ...data },
})
await applyUiSettings({ locale: "en", showThinkingBlocks: true,
toolCallExpansionDefaults: { preset: "custom", thinking: "expanded", tools: {} } })
const fixture = (window as any).fixture
;(window as any).motionFixture = {
seed: () => fixture.seedHistory(620),
start: () => {
emit("session.execution.started")
fixture.startEmpty()
emit("session.reasoning.started")
emit("session.reasoning.delta", { ordinal: 0, delta: "**Considering the answer**\n\nInitial reasoning." })
},
text: () => emit("session.text.started"),
groupThinking: () => {
emit("session.reasoning.ended", { ordinal: 0, text: "**Considering the answer**\n\nInitial reasoning." })
emit("session.reasoning.started")
emit("session.reasoning.delta", { ordinal: 1, delta: "**Checking the result**\n\nAnother reasoning step." })
grouped = true
},
delta: (delta: string) => fixture.delta(delta),
finish: (text: string) => {
emit("session.reasoning.ended", { ordinal: 0, text: "**Considering the answer**\n\nInitial reasoning." })
if (grouped) emit("session.reasoning.ended", { ordinal: 1, text: "**Checking the result**\n\nAnother reasoning step." })
fixture.end(text)
emit("session.step.ended", { finish: "stop" })
emit("session.execution.succeeded")
},
}

View file

@ -0,0 +1,101 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "mcp-motion", configureServer(s) {
s.middlewares.use("/mcp-motion", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/mcp-motion", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/mcp-motion.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/mcp-motion`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close() })
async function withFixture(motion: "reduce" | "no-preference", run: (page: Page) => Promise<void>) {
const page = await browser.newPage({ viewport: { width: 700, height: 850 }, deviceScaleFactor: 1.5, reducedMotion: motion, locale: "en-US" })
page.setDefaultTimeout(10_000)
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ json: {} }))
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 })
await page.waitForFunction(() => Boolean((window as any).mcpFixture), undefined, { timeout: 30_000 })
await page.locator(".status-dot").first().waitFor()
await run(page)
assert.deepEqual(errors, [])
} finally { await page.close() }
}
async function perpetualAnimations(page: Page) {
return page.evaluate(() => document.getAnimations().filter(animation =>
animation.playState === "running" && animation.effect?.getComputedTiming().iterations === Infinity,
).length)
}
for (const motion of ["no-preference", "reduce"] as const) {
test(`eight connected MCP servers stay static at rest (${motion}, 150% scale)`, () => withFixture(motion, async page => {
assert.equal(await page.locator(".status-dot.ready").count(), 8)
assert.equal(await page.getByRole("checkbox").count(), 8)
assert.equal(await perpetualAnimations(page), 0, "connected servers must not schedule perpetual animation frames")
assert.deepEqual(await page.evaluate(() => (window as any).mcpFixture.calls), [])
const header = page.locator(".right-panel-accordion-trigger")
await header.click()
await page.locator(".status-dot").first().waitFor({ state: "hidden" })
assert.equal(await perpetualAnimations(page), 0)
await header.click()
await page.locator(".status-dot").first().waitFor()
assert.equal(await page.locator(".status-dot.ready").count(), 8)
assert.equal(await perpetualAnimations(page), 0)
}))
test(`MCP operation feedback stops on success and failure (${motion})`, () => withFixture(motion, async page => {
const toggle = page.getByRole("checkbox", { name: "Toggle dnd-5e MCP server", exact: true })
assert.equal(await toggle.isChecked(), true)
await toggle.click()
await page.locator("svg.animate-spin").waitFor()
assert.equal(await toggle.isDisabled(), true)
assert.equal(await perpetualAnimations(page), motion === "reduce" ? 0 : 2, "only the pending dot and spinner may loop")
if (motion === "reduce") {
// Exercise live OS-preference changes rather than only the initial media query.
await page.emulateMedia({ reducedMotion: "no-preference" })
await page.waitForFunction(() => document.getAnimations().some(a => a.effect?.getComputedTiming().iterations === Infinity))
await page.emulateMedia({ reducedMotion: "reduce" })
assert.equal(await perpetualAnimations(page), 0)
}
await page.evaluate(() => (window as any).mcpFixture.settle())
await page.locator("svg.animate-spin").waitFor({ state: "detached" })
assert.equal(await toggle.isChecked(), false)
assert.equal(await toggle.isEnabled(), true)
assert.equal(await perpetualAnimations(page), 0)
await toggle.click()
await page.locator("svg.animate-spin").waitFor()
await page.evaluate(() => (window as any).mcpFixture.settle(true))
await page.locator("svg.animate-spin").waitFor({ state: "detached" })
assert.equal(await toggle.isChecked(), false)
assert.equal(await toggle.isEnabled(), true)
assert.equal(await perpetualAnimations(page), 0)
await toggle.click()
await page.locator("svg.animate-spin").waitFor()
await page.evaluate(() => (window as any).mcpFixture.settle())
await page.locator("svg.animate-spin").waitFor({ state: "detached" })
assert.equal(await toggle.isChecked(), true)
assert.equal(await page.locator(".status-dot.ready").count(), 8)
assert.equal(await perpetualAnimations(page), 0)
assert.deepEqual(await page.evaluate(() => (window as any).mcpFixture.calls), Array(3).fill({ server: "dnd-5e", location: { directory: "/fixture" } }))
await page.evaluate(() => (window as any).mcpFixture.setStatus("serena", "failed"))
await page.getByText("Fixture server failed", { exact: true }).waitFor()
assert.equal(await page.locator(".status-dot.error").count(), 1)
assert.equal(await perpetualAnimations(page), 0)
}))
}

View file

@ -0,0 +1,131 @@
// Bounded Chromium rendering traces, not a Task Manager/WebView2 GPU benchmark.
// Usage: node scripts/profile-ui-motion.mjs [baseline-git-ref]
// Only the baseline MCP component is substituted; all other sources/dependencies
// and the real StatusTab/SessionView fixture paths remain identical.
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { chromium } from "playwright"
import { createServer } from "vite"
import solid from "vite-plugin-solid"
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const root = path.join(repo, "packages/ui")
const baselineRef = process.argv[2]
const temporaryParent = path.join(os.tmpdir(), "opencode")
await mkdir(temporaryParent, { recursive: true })
const evidence = await mkdtemp(path.join(temporaryParent, "issue804-motion-"))
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
const browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
const results = []
async function sample(page, name, drive) {
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
await delay(300) // let one-off disclosure/scroll/transition work settle
const animationSnapshot = await page.evaluate(() => document.getAnimations().filter(a =>
a.playState === "running" && a.effect?.getComputedTiming().iterations === Infinity,
).map(a => ({ name: a.animationName, target: a.effect?.target?.getAttribute("class") })))
const before = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map(m => [m.name, m.value]))
const events = []
cdp.on("Tracing.dataCollected", ({ value }) => events.push(...value))
await cdp.send("Tracing.start", { categories: "devtools.timeline,disabled-by-default-devtools.timeline,cc", transferMode: "ReportEvents" })
const started = performance.now()
if (drive) await drive()
else await delay(1200)
const elapsedMs = performance.now() - started
const done = new Promise(resolve => cdp.once("Tracing.tracingComplete", resolve))
await cdp.send("Tracing.end")
await done
const after = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map(m => [m.name, m.value]))
const counts = {}
for (const event of events) if (["Paint", "Layout", "UpdateLayoutTree", "DrawFrame"].includes(event.name)) {
counts[event.name] = (counts[event.name] ?? 0) + 1
}
const result = { name, elapsedMs, animations: animationSnapshot, events: counts,
metrics: Object.fromEntries(["LayoutCount", "RecalcStyleCount", "LayoutDuration", "RecalcStyleDuration", "ScriptDuration", "TaskDuration"].map(key => [key, after[key] - before[key]])),
mountedRows: await page.locator("[data-virtual-follow-key]").count(),
}
await writeFile(path.join(evidence, `${name}.json`), JSON.stringify({ traceEvents: events }))
await page.screenshot({ path: path.join(evidence, `${name}.png`) })
await cdp.detach()
results.push(result)
}
try {
for (const baseline of baselineRef ? [true, false] : [false]) {
const component = "packages/ui/src/components/instance-service-status.tsx"
const oldSource = baseline ? execFileSync("git", ["show", `${baselineRef}:${component}`], { cwd: repo, encoding: "utf8" }) : undefined
const server = await createServer({ configFile: false, root, logLevel: "error", plugins: [
{ name: "motion-fixture", enforce: "pre", load(id) {
if (id.replaceAll("\\", "/") === path.join(repo, component).replaceAll("\\", "/")) return oldSource
}, configureServer(server) { server.middlewares.use("/motion", async (req, res) => {
const fixture = req.url?.includes("streaming") ? "streaming-motion" : "mcp-motion"
res.setHeader("Content-Type", "text/html")
res.end(await server.transformIndexHtml("/motion", `<html><body><div id="root" style="display:flex;height:700px;width:1100px"></div><script type="module" src="/tests/browser/fixtures/${fixture}.tsx"></script></body></html>`))
}) } }, solid()], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
try {
await server.listen()
const url = `http://127.0.0.1:${server.httpServer.address().port}/motion`
for (const reducedMotion of ["no-preference", "reduce"]) {
const page = await browser.newPage({ viewport: { width: 1100, height: 850 }, deviceScaleFactor: 1.5, reducedMotion, locale: "en-US" })
const errors = []
page.on("pageerror", error => errors.push(error.message))
page.setDefaultTimeout(30_000)
await page.route("**/api/**", route => route.fulfill({ contentType: route.request().url().includes("events") ? "text/event-stream" : "application/json", body: "" }))
const label = `${baseline ? "baseline" : "fixed"}-${reducedMotion}`
try {
await page.goto(url, { waitUntil: "domcontentloaded" })
await page.waitForFunction(() => Boolean(window.mcpFixture))
await sample(page, `${label}-mcp-expanded`)
await page.locator(".right-panel-accordion-trigger").click()
await page.locator(".status-dot").first().waitFor({ state: "hidden" })
await sample(page, `${label}-mcp-collapsed`)
if (!baseline) {
await page.goto(`${url}?streaming`, { waitUntil: "domcontentloaded" })
await page.waitForFunction(() => Boolean(window.motionFixture))
await page.evaluate(() => window.motionFixture.seed())
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("History 619"))
await sample(page, `${label}-session-idle`)
await page.evaluate(() => window.motionFixture.start())
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("Initial reasoning"))
await sample(page, `${label}-thinking-no-deltas`)
await page.evaluate(() => window.motionFixture.groupThinking())
await page.locator(".message-technical-group-spinner").waitFor()
await sample(page, `${label}-grouped-thinking-no-deltas`)
await page.evaluate(() => window.motionFixture.text())
let text = ""
await sample(page, `${label}-streaming-paced-50ms`, async () => {
for (let i = 0; i < 24; i++) {
const delta = `Token ${i}. `
text += delta
await page.evaluate(delta => window.motionFixture.delta(delta), delta)
await delay(50)
}
})
await page.evaluate(text => window.motionFixture.finish(text), text)
await sample(page, `${label}-session-complete`)
}
assert.deepEqual(errors, [])
} finally { await page.close() }
}
} finally { await server.close() }
}
for (const result of results) {
if (result.name.includes("-reduce-") || result.name.startsWith("fixed-") && result.name.includes("-mcp-")) {
assert.equal(result.animations.length, 0, `${result.name} must not have perpetual animations`)
}
if (result.name.includes("-session-") || result.name.includes("-thinking-") || result.name.includes("-streaming-")) {
assert.ok(result.mountedRows < 60, `${result.name} must retain bounded transcript mounting`)
}
}
console.log(JSON.stringify({ evidence, results }, null, 2))
} finally {
await browser.close()
await writeFile(path.join(evidence, "results.json"), JSON.stringify(results, null, 2))
}