Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 201 additions & 28 deletions src/browser/components/ai-elements/shimmer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,43 +15,214 @@ export interface TextShimmerProps {
colorClass?: string;
}

// ─────────────────────────────────────────────────────────────────────────────
// Worker Management (singleton)
// ─────────────────────────────────────────────────────────────────────────────

let workerAPI: Comlink.Remote<ShimmerWorkerAPI> | null = null;
let workerFailed = false;

function getWorkerAPI(): Comlink.Remote<ShimmerWorkerAPI> | 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<ShimmerWorkerAPI>(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<HTMLCanvasElement>(null);
const instanceIdRef = useRef<number | null>(null);
const transferredRef = useRef(false);

const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;

return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
data-chromatic="ignore"
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage: `var(--bg), linear-gradient(${colorClass}, ${colorClass})`,
} as CSSProperties
const api = getWorkerAPI();

// Get computed styles for font matching
const computedStyle = getComputedStyle(canvas);
const font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;

// Resolve CSS variable to actual color
const tempEl = document.createElement("span");
tempEl.style.color = colorClass;
document.body.appendChild(tempEl);
const resolvedColor = getComputedStyle(tempEl).color;
document.body.removeChild(tempEl);

// Get background color for highlight
const bgColor =
getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim() ||
"hsl(0 0% 12%)";

// Measure text and size canvas
const ctx2d = canvas.getContext("2d");
if (!ctx2d) return;
ctx2d.font = font;
const metrics = ctx2d.measureText(children);
const textWidth = Math.ceil(metrics.width);
const ascent = metrics.actualBoundingBoxAscent;
const descent = metrics.actualBoundingBoxDescent;
const textHeight = Math.ceil(ascent + descent);

// Handle HiDPI
const dpr = window.devicePixelRatio || 1;
canvas.width = textWidth * dpr;
canvas.height = textHeight * dpr;
canvas.style.width = `${textWidth}px`;
canvas.style.height = `${textHeight}px`;
canvas.style.verticalAlign = `${-descent}px`;

const config = {
text: children,
font,
color: resolvedColor,
bgColor,
duration,
spread,
dpr,
textWidth,
textHeight,
baselineY: ascent,
};

// Worker path: transfer canvas and register
if (api && !transferredRef.current) {
try {
const offscreen = canvas.transferControlToOffscreen();
transferredRef.current = true;
void api.register(Comlink.transfer(offscreen, [offscreen]), config).then((id) => {
instanceIdRef.current = id;
});
} catch {
// Transfer failed, fall back to main thread
runMainThreadAnimation(canvas, config);
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
} 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 (
<Component className={cn("inline", className)} data-chromatic="ignore">
<canvas ref={canvasRef} className="inline" />
</Component>
);
};

// ─────────────────────────────────────────────────────────────────────────────
// 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);
68 changes: 36 additions & 32 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ export class WorkspaceStore {
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup
private queuedMessages = new Map<string, QueuedMessage | null>(); // 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<string, ReturnType<typeof setTimeout>>();
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<string, number>();

/**
* Map of event types to their handlers. This is the single source of truth for:
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand Down
Loading