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 && ( -