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
5 changes: 2 additions & 3 deletions app/src/components/channels/activity-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb";
import { EASE_OUT } from "@/lib/motion";

/**
Expand Down Expand Up @@ -62,9 +63,7 @@ export function AgentActivityBar({
ease: EASE_OUT,
}}
>
<span aria-hidden className="thinking-orb-halo">
<span className="thinking-orb scale-75" />
</span>
<LiquidThinkingOrb />
<span className="tool-line-running min-w-0 truncate text-muted-foreground text-sm">
{actionLabel ?? "Thinking"}
</span>
Expand Down
10 changes: 4 additions & 6 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
useState,
} from "react";
import { Streamdown } from "streamdown";
import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
MessageContent,
Expand Down Expand Up @@ -218,13 +219,10 @@ function Thinking() {
>
{/*
* The shimmer lives on the TEXT SPAN, not the paragraph: `background-clip: text` makes the
* paragraph's own colour transparent, and the orb sitting inside a transparent-text element
* is fine (it paints backgrounds, not glyphs) — but keeping them separate means neither
* effect has to know the other exists.
* paragraph's own colour transparent. Keeping the liquid canvas beside that span means the
* shader and the text treatment never have to know how the other paints.
*/}
<span aria-hidden className="thinking-orb-halo">
<span className="thinking-orb" />
</span>
<LiquidThinkingOrb />
<span className="tool-line-running">
Thinking{seconds >= 5 ? ` · ${seconds}s` : ""}
</span>
Expand Down
304 changes: 304 additions & 0 deletions app/src/components/channels/liquid-thinking-orb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import { useReducedMotion } from "motion/react";
import { type ComponentProps, useEffect, useRef, useState } from "react";

// WebGPU is not yet part of TypeScript's DOM library. Keep this surface deliberately narrow: these
// are only the methods the supplied liquid-orb renderer uses.
interface WebGpuApi {
getPreferredCanvasFormat(): string;
requestAdapter(): Promise<WebGpuAdapter | null>;
}

interface WebGpuAdapter {
requestDevice(): Promise<WebGpuDevice>;
}

interface WebGpuCompilationMessage {
lineNum: number;
linePos: number;
message: string;
type: string;
}

interface WebGpuShaderModule {
getCompilationInfo(): Promise<{ messages: WebGpuCompilationMessage[] }>;
}

interface WebGpuRenderPipeline {
getBindGroupLayout(index: number): unknown;
}

interface WebGpuBuffer {
destroy(): void;
}

interface WebGpuRenderPass {
draw(vertexCount: number): void;
end(): void;
setBindGroup(index: number, bindGroup: unknown): void;
setPipeline(pipeline: WebGpuRenderPipeline): void;
}

interface WebGpuCommandEncoder {
beginRenderPass(descriptor: object): WebGpuRenderPass;
finish(): unknown;
}

interface WebGpuDevice {
createBindGroup(descriptor: object): unknown;
createBuffer(descriptor: object): WebGpuBuffer;
createCommandEncoder(): WebGpuCommandEncoder;
createRenderPipeline(descriptor: object): WebGpuRenderPipeline;
createShaderModule(descriptor: { code: string }): WebGpuShaderModule;
lost: Promise<unknown>;
queue: {
submit(commands: unknown[]): void;
writeBuffer(
buffer: WebGpuBuffer,
bufferOffset: number,
data: Float32Array,
): void;
};
}

interface WebGpuCanvasContext {
configure(descriptor: {
alphaMode: "premultiplied";
device: WebGpuDevice;
format: string;
}): void;
getCurrentTexture(): { createView(): unknown };
unconfigure(): void;
}

interface SharedRenderer {
device: WebGpuDevice;
format: string;
pipeline: WebGpuRenderPipeline;
}

// WebGPUBufferUsage.UNIFORM and WebGPUBufferUsage.COPY_DST. Numeric flags avoid adding a package
// just for WebGPU globals while TypeScript's DOM library catches up with browsers.
const UNIFORM_BUFFER_USAGE = 0x40 | 0x08;

// This is the exact preset supplied with the Liquid Orb reference. The first three slots are
// replaced per frame with canvas width, canvas height, and elapsed time.
const LIQUID_ORB_UNIFORM_SEED = [
1, 1, 0, 1.3200000524520874, 0.7200000286102295, 0.550000011920929,
5.400000095367432, 0.7799999713897705, 4.199999809265137, 0.1599999964237213,
0.36000001430511475, 0.2800000011920929, 0.20000000298023224,
0.2199999988079071, 1.25, 11, 0.004999999888241291, 0, 0, 1,
0.41999998688697815, 0.05000000074505806, 2, 0.41999998688697815,
0.7699999809265137, 0.23000000417232513, 65, 0, 0, 1, 0.2199999988079071,
0.25, 0.0235294122248888, 0.007843137718737125, 0.054901961237192154, 1, 0,
0.6000000238418579, 1, 1, 0.14509804546833038, 0.545098066329956, 1, 1,
0.07450980693101883, 0.4588235318660736, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
0.09803921729326248, 0.3176470696926117, 0.7607843279838562, 1, 0,
0.9137254953384399, 1, 1, 0.9176470637321472, 0.95686274766922, 1, 1,
0.8627451062202454, 0.9176470637321472, 1, 1, 0.007843137718737125,
0.003921568859368563, 0.019607843831181526, 1, 0, 0.6000000238418579, 1, 1,
0.9686274528503418, 0.9843137264251709, 1, 1, 0.9372549057006836,
0.9647058844566345, 0.9921568632125854, 1, 0.8784313797950745,
0.9333333373069763, 0.9764705896377563, 1, 0.8313725590705872,
0.9019607901573181, 0.9686274528503418, 1, 0.7333333492279053,
0.8352941274642944, 0.9529411792755127, 1, 0.6509804129600525,
0.7803921699523926, 0.9411764740943909, 1, 0.529411792755127,
0.6901960968971252, 0.9215686321258545, 1, 0.43529412150382996,
0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996,
0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996,
0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996,
0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996,
0.6196078658103943, 0.9098039269447327, 1,
] as const;

let sharedRendererPromise: Promise<SharedRenderer | null> | undefined;

async function createSharedRenderer(): Promise<SharedRenderer | null> {
const gpu = (navigator as Navigator & { gpu?: WebGpuApi }).gpu;
if (!gpu) return null;

// Loading the 55KB shader is conditional on a browser both needing the indicator and supporting
// WebGPU. Adapter discovery and the code-split shader request are independent, so start together.
const [adapter, shaderSource] = await Promise.all([
gpu.requestAdapter(),
import("./liquid-thinking-orb.wgsl?raw"),
]);
if (!adapter) return null;

const device = await adapter.requestDevice();
const format = gpu.getPreferredCanvasFormat();
const shader = device.createShaderModule({ code: shaderSource.default });
const compilation = await shader.getCompilationInfo();
const errors = compilation.messages.filter(
(message) => message.type === "error",
);
if (errors.length > 0) {
throw new Error(
errors
.map(
(message) =>
`${message.lineNum}:${message.linePos} ${message.message}`,
)
.join("\n"),
);
}

const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shader, entryPoint: "vs_main" },
fragment: {
module: shader,
entryPoint: "fs_main",
targets: [{ format }],
},
primitive: { topology: "triangle-list" },
});

// A lost device cannot serve a later thinking cursor. Let the next mount ask for a fresh one.
void device.lost.then(() => {
sharedRendererPromise = undefined;
});

return { device, format, pipeline };
}

function getSharedRenderer(): Promise<SharedRenderer | null> {
sharedRendererPromise ??= createSharedRenderer().catch(() => null);
return sharedRendererPromise;
}

/**
* The supplied liquid-glass animation, shared by every surface that means "the model is working".
*
* Each visible orb owns only a tiny canvas, uniform buffer, and render loop. The device, compiled
* shader, and pipeline are shared across instances. Browsers without WebGPU keep the CSS-painted
* glass fallback; reduced-motion users receive one still WebGPU frame instead of a loop.
*/
export function LiquidThinkingOrb({
className,
...props
}: ComponentProps<"canvas">) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const shouldReduceMotion = useReducedMotion();
const [rendererReady, setRendererReady] = useState(false);

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const canvasElement: HTMLCanvasElement = canvas;

let animationFrame = 0;
let buffer: WebGpuBuffer | null = null;
let context: WebGpuCanvasContext | null = null;
let stopped = false;

async function start() {
const renderer = await getSharedRenderer();
if (stopped || !renderer) return;

const webGpuCanvas = canvasElement as HTMLCanvasElement & {
getContext(contextId: "webgpu"): WebGpuCanvasContext | null;
};
context = webGpuCanvas.getContext("webgpu");
if (!context) return;

context.configure({
device: renderer.device,
format: renderer.format,
alphaMode: "premultiplied",
});

const values = new Float32Array(LIQUID_ORB_UNIFORM_SEED);
buffer = renderer.device.createBuffer({
size: values.byteLength,
usage: UNIFORM_BUFFER_USAGE,
});
const bindGroup = renderer.device.createBindGroup({
layout: renderer.pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer } }],
});
const startedAt = performance.now();

const renderFrame = (now: number) => {
if (stopped || !context || !buffer) return false;

try {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(
1,
Math.floor(canvasElement.clientWidth * dpr),
);
const height = Math.max(
1,
Math.floor(canvasElement.clientHeight * dpr),
);
if (
canvasElement.width !== width ||
canvasElement.height !== height
) {
canvasElement.width = width;
canvasElement.height = height;
}

values[0] = width;
values[1] = height;
values[2] = (now - startedAt) / 1000;
renderer.device.queue.writeBuffer(buffer, 0, values);

const encoder = renderer.device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: "clear",
storeOp: "store",
},
],
});
pass.setPipeline(renderer.pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
renderer.device.queue.submit([encoder.finish()]);
return true;
} catch {
return false;
}
};

const tick = (now: number) => {
if (!renderFrame(now)) {
if (!stopped) setRendererReady(false);
return;
}
animationFrame = requestAnimationFrame(tick);
};

if (!renderFrame(startedAt)) return;
if (!stopped) setRendererReady(true);
if (shouldReduceMotion !== true) {
animationFrame = requestAnimationFrame(tick);
}
}

void start().catch(() => {
if (!stopped) setRendererReady(false);
});
return () => {
stopped = true;
cancelAnimationFrame(animationFrame);
buffer?.destroy();
context?.unconfigure();
};
}, [shouldReduceMotion]);

return (
<canvas
{...props}
aria-hidden
className={["liquid-thinking-orb", className].filter(Boolean).join(" ")}
data-renderer={rendererReady ? "webgpu" : "fallback"}
ref={canvasRef}
/>
);
}
Loading
Loading