diff --git a/src/browser/components/ai-elements/shimmer.tsx b/src/browser/components/ai-elements/shimmer.tsx index ce67337ad6..166cc94a72 100644 --- a/src/browser/components/ai-elements/shimmer.tsx +++ b/src/browser/components/ai-elements/shimmer.tsx @@ -1,8 +1,10 @@ "use client"; import { cn } from "@/common/lib/utils"; -import { motion } from "motion/react"; -import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from "react"; +import * as Comlink from "comlink"; +import type { ElementType } from "react"; +import { memo, useEffect, useRef } from "react"; +import type { ShimmerWorkerAPI } from "@/browser/workers/shimmerWorker"; export interface TextShimmerProps { children: string; @@ -13,43 +15,214 @@ export interface TextShimmerProps { colorClass?: string; } +// ───────────────────────────────────────────────────────────────────────────── +// Worker Management (singleton) +// ───────────────────────────────────────────────────────────────────────────── + +let workerAPI: Comlink.Remote | null = null; +let workerFailed = false; + +function getWorkerAPI(): Comlink.Remote | null { + if (workerFailed) return null; + if (workerAPI) return workerAPI; + + try { + const worker = new Worker(new URL("../../workers/shimmerWorker.ts", import.meta.url), { + type: "module", + name: "shimmer-animation", + }); + + worker.onerror = (e) => { + console.error("[Shimmer] Worker failed to load:", e); + workerFailed = true; + workerAPI = null; + }; + + workerAPI = Comlink.wrap(worker); + return workerAPI; + } catch (e) { + console.error("[Shimmer] Failed to create worker:", e); + workerFailed = true; + return null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shimmer Component +// ───────────────────────────────────────────────────────────────────────────── + +/** + * GPU-accelerated shimmer text effect using OffscreenCanvas in a Web Worker. + * + * Renders text with a sweeping highlight animation entirely off the main thread. + * All animation logic runs in a dedicated worker, leaving the main thread free + * for streaming and other UI work. + */ const ShimmerComponent = ({ children, - as: Component = "p", + as: Component = "span", className, duration = 2, spread = 2, colorClass = "var(--color-muted-foreground)", }: TextShimmerProps) => { - const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements); + const canvasRef = useRef(null); + const instanceIdRef = useRef(null); + const transferredRef = useRef(false); - const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; - return ( - { + instanceIdRef.current = id; + }); + } catch { + // Transfer failed, fall back to main thread + runMainThreadAnimation(canvas, config); } - transition={{ - repeat: Number.POSITIVE_INFINITY, - duration, - ease: "linear", - }} - > - {children} - + } else if (api && instanceIdRef.current !== null) { + // Already registered, just update config + void api.update(instanceIdRef.current, config); + } else if (!api) { + // No worker, run on main thread + return runMainThreadAnimation(canvas, config); + } + + return () => { + if (api && instanceIdRef.current !== null) { + void api.unregister(instanceIdRef.current); + instanceIdRef.current = null; + } + }; + }, [children, colorClass, duration, spread]); + + return ( + + + ); }; +// ───────────────────────────────────────────────────────────────────────────── +// Main Thread Fallback +// ───────────────────────────────────────────────────────────────────────────── + +interface ShimmerConfig { + text: string; + font: string; + color: string; + bgColor: string; + duration: number; + spread: number; + dpr: number; + textWidth: number; + textHeight: number; + baselineY: number; +} + +function runMainThreadAnimation(canvas: HTMLCanvasElement, config: ShimmerConfig): () => void { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return function cleanup() { + // No animation started - nothing to clean up + }; + } + + const { text, font, color, bgColor, duration, spread, dpr, textWidth, textHeight, baselineY } = + config; + const durationMs = duration * 1000; + const startTime = performance.now(); + let animationId: number; + + const animate = (now: number) => { + const elapsed = now - startTime; + const progress = 1 - (elapsed % durationMs) / durationMs; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.scale(dpr, dpr); + + ctx.font = font; + ctx.fillStyle = color; + ctx.fillText(text, 0, baselineY); + + const dynamicSpread = (text?.length ?? 0) * spread; + const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75; + const gradient = ctx.createLinearGradient( + gradientCenter - dynamicSpread, + 0, + gradientCenter + dynamicSpread, + 0 + ); + gradient.addColorStop(0, "transparent"); + gradient.addColorStop(0.5, bgColor); + gradient.addColorStop(1, "transparent"); + + ctx.globalCompositeOperation = "source-atop"; + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, textWidth, textHeight); + + ctx.restore(); + animationId = requestAnimationFrame(animate); + }; + + animationId = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animationId); +} + export const Shimmer = memo(ShimmerComponent); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 0a2eb6c041..18ba5c1dd5 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -125,10 +125,10 @@ export class WorkspaceStore { private workspaceMetadata = new Map(); // Store metadata for name lookup private queuedMessages = new Map(); // Cached queued messages - // Debounce timers for high-frequency delta events to reduce re-renders during streaming - // Data is always updated immediately in the aggregator; only UI notification is debounced - private deltaDebounceTimers = new Map>(); - private static readonly DELTA_DEBOUNCE_MS = 16; // ~60fps cap for smooth streaming + // Idle callback handles for high-frequency delta events to reduce re-renders during streaming. + // Data is always updated immediately in the aggregator; only UI notification is scheduled. + // Using requestIdleCallback adapts to actual CPU availability rather than a fixed timer. + private deltaIdleHandles = new Map(); /** * Map of event types to their handlers. This is the single source of truth for: @@ -159,7 +159,7 @@ export class WorkspaceStore { }, "stream-delta": (workspaceId, aggregator, data) => { aggregator.handleStreamDelta(data as never); - this.debouncedStateBump(workspaceId); + this.scheduleIdleStateBump(workspaceId); }, "stream-end": (workspaceId, aggregator, data) => { const streamEndData = data as StreamEndEvent; @@ -173,7 +173,7 @@ export class WorkspaceStore { updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); // Flush any pending debounced bump before final bump to avoid double-bump - this.flushPendingDebouncedBump(workspaceId); + this.cancelPendingIdleBump(workspaceId); this.states.bump(workspaceId); this.checkAndBumpRecencyIfChanged(); this.finalizeUsageStats(workspaceId, streamEndData.metadata); @@ -199,7 +199,7 @@ export class WorkspaceStore { } // Flush any pending debounced bump before final bump to avoid double-bump - this.flushPendingDebouncedBump(workspaceId); + this.cancelPendingIdleBump(workspaceId); this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); this.finalizeUsageStats(workspaceId, streamAbortData.metadata); @@ -210,7 +210,7 @@ export class WorkspaceStore { }, "tool-call-delta": (workspaceId, aggregator, data) => { aggregator.handleToolCallDelta(data as never); - this.debouncedStateBump(workspaceId); + this.scheduleIdleStateBump(workspaceId); }, "tool-call-end": (workspaceId, aggregator, data) => { aggregator.handleToolCallEnd(data as never); @@ -219,7 +219,7 @@ export class WorkspaceStore { }, "reasoning-delta": (workspaceId, aggregator, data) => { aggregator.handleReasoningDelta(data as never); - this.debouncedStateBump(workspaceId); + this.scheduleIdleStateBump(workspaceId); }, "reasoning-end": (workspaceId, aggregator, data) => { aggregator.handleReasoningEnd(data as never); @@ -314,36 +314,40 @@ export class WorkspaceStore { } /** - * Debounced state bump for high-frequency delta events. - * Coalesces rapid updates (stream-delta, tool-call-delta, reasoning-delta) - * into a single bump per frame (~60fps), reducing React re-renders during streaming. + * Schedule a state bump during browser idle time. + * Instead of updating UI on every delta, wait until the browser has spare capacity. + * This adapts to actual CPU availability - fast machines update more frequently, + * slow machines naturally throttle without dropping data. * - * Data is always updated immediately in the aggregator - only UI notification is debounced. + * Data is always updated immediately in the aggregator - only UI notification is deferred. */ - private debouncedStateBump(workspaceId: string): void { + private scheduleIdleStateBump(workspaceId: string): void { // Skip if already scheduled - if (this.deltaDebounceTimers.has(workspaceId)) { + if (this.deltaIdleHandles.has(workspaceId)) { return; } - const timer = setTimeout(() => { - this.deltaDebounceTimers.delete(workspaceId); - this.states.bump(workspaceId); - }, WorkspaceStore.DELTA_DEBOUNCE_MS); + const handle = requestIdleCallback( + () => { + this.deltaIdleHandles.delete(workspaceId); + this.states.bump(workspaceId); + }, + { timeout: 100 } // Force update within 100ms even if browser stays busy + ); - this.deltaDebounceTimers.set(workspaceId, timer); + this.deltaIdleHandles.set(workspaceId, handle); } /** - * Flush any pending debounced state bump for a workspace (without double-bumping). + * Cancel any pending idle state bump for a workspace. * Used when immediate state visibility is needed (e.g., stream-end). - * Just clears the timer - the caller will bump() immediately after. + * Just cancels the callback - the caller will bump() immediately after. */ - private flushPendingDebouncedBump(workspaceId: string): void { - const timer = this.deltaDebounceTimers.get(workspaceId); - if (timer) { - clearTimeout(timer); - this.deltaDebounceTimers.delete(workspaceId); + private cancelPendingIdleBump(workspaceId: string): void { + const handle = this.deltaIdleHandles.get(workspaceId); + if (handle) { + cancelIdleCallback(handle); + this.deltaIdleHandles.delete(workspaceId); } } @@ -787,11 +791,11 @@ export class WorkspaceStore { // Clean up consumer manager state this.consumerManager.removeWorkspace(workspaceId); - // Clean up debounce timer to prevent stale callbacks - const timer = this.deltaDebounceTimers.get(workspaceId); - if (timer) { - clearTimeout(timer); - this.deltaDebounceTimers.delete(workspaceId); + // Clean up idle callback to prevent stale callbacks + const handle = this.deltaIdleHandles.get(workspaceId); + if (handle) { + cancelIdleCallback(handle); + this.deltaIdleHandles.delete(workspaceId); } // Unsubscribe from IPC diff --git a/src/browser/workers/shimmerWorker.ts b/src/browser/workers/shimmerWorker.ts new file mode 100644 index 0000000000..633ea69535 --- /dev/null +++ b/src/browser/workers/shimmerWorker.ts @@ -0,0 +1,186 @@ +/** + * Web Worker for shimmer animation + * + * Runs the animation loop entirely off the main thread using OffscreenCanvas. + * Each shimmer instance registers its canvas and config, and the worker + * manages all animations in a single rAF loop. + */ + +import * as Comlink from "comlink"; + +interface ShimmerInstance { + canvas: OffscreenCanvas; + ctx: OffscreenCanvasRenderingContext2D; + text: string; + font: string; + color: string; + bgColor: string; + duration: number; + spread: number; + dpr: number; + textWidth: number; + textHeight: number; + baselineY: number; + startTime: number; +} + +const instances = new Map(); +let nextId = 0; +let animationRunning = false; + +function animate() { + if (instances.size === 0) { + animationRunning = false; + return; + } + + const now = performance.now(); + + for (const instance of instances.values()) { + const { + canvas, + ctx, + text, + font, + color, + bgColor, + duration, + spread, + dpr, + textWidth, + textHeight, + baselineY, + startTime, + } = instance; + + const durationMs = duration * 1000; + const elapsed = now - startTime; + // Progress from 1 to 0 (right to left, matching original) + const progress = 1 - (elapsed % durationMs) / durationMs; + + // Clear + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Scale for HiDPI + ctx.save(); + ctx.scale(dpr, dpr); + + // Draw base text + ctx.font = font; + ctx.fillStyle = color; + ctx.fillText(text, 0, baselineY); + + // Create gradient for highlight at current position + const dynamicSpread = (text?.length ?? 0) * spread; + const gradientCenter = progress * textWidth * 2.5 - textWidth * 0.75; + const gradient = ctx.createLinearGradient( + gradientCenter - dynamicSpread, + 0, + gradientCenter + dynamicSpread, + 0 + ); + gradient.addColorStop(0, "transparent"); + gradient.addColorStop(0.5, bgColor); + gradient.addColorStop(1, "transparent"); + + // Draw highlight on top using composite + ctx.globalCompositeOperation = "source-atop"; + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, textWidth, textHeight); + + ctx.restore(); + } + + requestAnimationFrame(animate); +} + +function ensureAnimationRunning() { + if (!animationRunning) { + animationRunning = true; + requestAnimationFrame(animate); + } +} + +const api = { + /** + * Register a new shimmer instance + * @returns Instance ID for later removal + */ + register( + canvas: OffscreenCanvas, + config: { + text: string; + font: string; + color: string; + bgColor: string; + duration: number; + spread: number; + dpr: number; + textWidth: number; + textHeight: number; + baselineY: number; + } + ): number { + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get 2d context from OffscreenCanvas"); + } + + const id = nextId++; + instances.set(id, { + canvas, + ctx, + ...config, + startTime: performance.now(), + }); + + ensureAnimationRunning(); + return id; + }, + + /** + * Update an existing shimmer instance (e.g., text changed) + */ + update( + id: number, + config: { + text: string; + font: string; + color: string; + bgColor: string; + duration: number; + spread: number; + dpr: number; + textWidth: number; + textHeight: number; + baselineY: number; + } + ): void { + const instance = instances.get(id); + if (!instance) return; + + // Resize canvas if dimensions changed + if ( + config.textWidth * config.dpr !== instance.canvas.width || + config.textHeight * config.dpr !== instance.canvas.height + ) { + instance.canvas.width = config.textWidth * config.dpr; + instance.canvas.height = config.textHeight * config.dpr; + } + + // Update config (keep startTime for smooth animation) + Object.assign(instance, config); + }, + + /** + * Unregister a shimmer instance + */ + unregister(id: number): void { + instances.delete(id); + // Animation loop will stop itself when no instances remain + }, +}; + +export type ShimmerWorkerAPI = typeof api; + +Comlink.expose(api);