From d9b815048d42278e444d04fbf0a6a97e08f227aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 24 Aug 2026 20:40:02 +0000 Subject: [PATCH] perf: reduce frontend idle CPU (#2965) --- apps/packages/ui/src/compositor-spin.tsx | 61 ++++ apps/packages/ui/src/index.ts | 1 + apps/pnpm-lock.yaml | 9 + .../runs/components/recent-runs-sidebar.tsx | 9 +- .../app/office/components/agent-card.test.tsx | 7 + apps/web/app/office/components/agent-card.tsx | 10 +- apps/web/app/office/tasks/task-row.tsx | 9 +- .../components/kanban-card-content.test.tsx | 14 +- apps/web/components/kanban-card-content.tsx | 10 +- .../kanban-card-status-icon.test.tsx | 4 + .../mobile/mobile-sessions-section.test.tsx | 7 +- .../simple/components/agent-turn-panel.tsx | 5 +- .../session-timeline-entry.test.tsx | 6 +- .../components/session-timeline-entry.tsx | 8 +- .../topbar-working-indicator.test.tsx | 5 +- .../components/topbar-working-indicator.tsx | 5 +- .../user-comment-run-badge.test.tsx | 7 +- .../components/user-comment-run-badge.tsx | 5 +- apps/web/components/task/task-item.test.tsx | 15 + apps/web/components/task/task-item.tsx | 46 ++- .../e2e/tests/office/agent-run-detail.spec.ts | 20 +- .../task/mobile-task-status-summary.spec.ts | 20 +- .../task/sidebar-settled-spinner.spec.ts | 12 +- apps/web/lib/logger/indexeddb-store.test.ts | 342 +++++++++++++++++- apps/web/lib/logger/indexeddb-store.ts | 197 +++++++--- apps/web/lib/logger/runtime.test.ts | 70 +++- apps/web/lib/logger/runtime.ts | 34 +- apps/web/lib/ui/state-icons.test.tsx | 76 +++- apps/web/lib/ui/state-icons.tsx | 33 +- apps/web/package.json | 3 +- ...26-07-30-file-backed-diagnostic-bundles.md | 20 +- docs/decisions/INDEX.md | 2 +- docs/plans/frontend-idle-cpu/plan.md | 101 ++++++ .../task-01-compositor-status-motion.md | 158 ++++++++ ...sk-02-incremental-browser-log-retention.md | 125 +++++++ docs/specs/platform/README.md | 2 + .../requirements/browser-console-retention.md | 66 ++++ .../browser-console-retention.md | 104 ++++++ .../system-design/diagnostic-logging-01.md | 26 +- docs/specs/ui/README.md | 2 + .../requirements/persistent-status-motion.md | 56 +++ .../system-design/persistent-status-motion.md | 112 ++++++ 42 files changed, 1683 insertions(+), 141 deletions(-) create mode 100644 apps/packages/ui/src/compositor-spin.tsx create mode 100644 docs/plans/frontend-idle-cpu/plan.md create mode 100644 docs/plans/frontend-idle-cpu/task-01-compositor-status-motion.md create mode 100644 docs/plans/frontend-idle-cpu/task-02-incremental-browser-log-retention.md create mode 100644 docs/specs/platform/requirements/browser-console-retention.md create mode 100644 docs/specs/platform/system-design/browser-console-retention.md create mode 100644 docs/specs/ui/requirements/persistent-status-motion.md create mode 100644 docs/specs/ui/system-design/persistent-status-motion.md diff --git a/apps/packages/ui/src/compositor-spin.tsx b/apps/packages/ui/src/compositor-spin.tsx new file mode 100644 index 0000000000..77a660628c --- /dev/null +++ b/apps/packages/ui/src/compositor-spin.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { cn } from "./lib/utils"; + +const DEFAULT_DURATION_MS = 1_000; +const useCompositorEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; + +/** + * Keeps a persistent rotation on an HTML transform target so its SVG child can + * remain static and be promoted without per-frame SVG style work. + */ +function CompositorSpin({ className, style, ...props }: React.ComponentProps<"span">) { + const elementRef = React.useRef(null); + const [compositorReady, setCompositorReady] = React.useState(false); + + useCompositorEffect(() => { + const element = elementRef.current; + if (!element || typeof element.animate !== "function") return; + + element.style.removeProperty("animation"); + const computedStyle = window.getComputedStyle(element); + const duration = parseAnimationDuration(computedStyle.animationDuration); + element.style.animation = "none"; + void window.getComputedStyle(element).animationName; + element.style.transform = "translateZ(0)"; + const animation = element.animate( + [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], + { + duration, + easing: "linear", + iterations: Infinity, + }, + ); + setCompositorReady(true); + + return () => animation.cancel(); + }, [className]); + + const compositorStyle = compositorReady + ? { ...style, animation: "none", transform: "translateZ(0)" } + : style; + + return ( + + ); +} + +function parseAnimationDuration(value: string): number { + const duration = value.split(",")[0]?.trim(); + if (!duration) return DEFAULT_DURATION_MS; + + const numericValue = Number.parseFloat(duration); + if (!Number.isFinite(numericValue)) return DEFAULT_DURATION_MS; + return duration.endsWith("ms") ? numericValue : numericValue * 1_000; +} + +export { CompositorSpin }; diff --git a/apps/packages/ui/src/index.ts b/apps/packages/ui/src/index.ts index e6ab2887d9..4238af1ef1 100644 --- a/apps/packages/ui/src/index.ts +++ b/apps/packages/ui/src/index.ts @@ -14,6 +14,7 @@ export * from "./chart"; export * from "./checkbox"; export * from "./collapsible"; export * from "./command"; +export * from "./compositor-spin"; export * from "./context-menu"; export * from "./dialog"; export * from "./drawer"; diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 518c445bff..dd0229b963 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -571,6 +571,9 @@ importers: eslint-plugin-unused-imports: specifier: ^4.4.1 version: 4.4.1(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 happy-dom: specifier: ^20.8.9 version: 20.8.9 @@ -4159,6 +4162,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -10075,6 +10082,8 @@ snapshots: extend@3.0.2: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} diff --git a/apps/web/app/office/agents/[id]/runs/components/recent-runs-sidebar.tsx b/apps/web/app/office/agents/[id]/runs/components/recent-runs-sidebar.tsx index 114ba62801..8b1ff85014 100644 --- a/apps/web/app/office/agents/[id]/runs/components/recent-runs-sidebar.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/recent-runs-sidebar.tsx @@ -4,6 +4,7 @@ import Link from "@/components/routing/app-link"; import { usePathname } from "@/lib/routing/client-router"; import { IconLoader2 } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { CompositorSpin } from "@kandev/ui/compositor-spin"; import { cn } from "@/lib/utils"; import type { AgentRunSummary } from "@/lib/api/domains/office-extended-api"; import { timeAgo } from "@/lib/utils/time"; @@ -84,11 +85,13 @@ function RecentRunsRow({ run, agentId, active }: RowProps) { >
{isRunning && ( -