diff --git a/docs/storybook/package.json b/docs/storybook/package.json
index 9087cb986..e9ef91c25 100644
--- a/docs/storybook/package.json
+++ b/docs/storybook/package.json
@@ -5,6 +5,8 @@
"license": "Apache-2.0",
"main": "index.js",
"scripts": {
+ "build:deps": "pnpm --filter @livekit/component-docs-storybook^... build",
+ "predev": "pnpm build:deps",
"build": "storybook build",
"clean": "rm -rf .turbo && rm -rf node_modules",
"dev": "storybook dev -p 6006",
diff --git a/package.json b/package.json
index 320f3fe3a..2e8065a89 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"gen:docs:watch": "nodemon --watch \"tooling/api-documenter/src/**/*\" --watch \"packages/react/src/**/*\" --ignore \"packages/react/src/assets/**/*\" -e js,jsx,ts,tsx -x \"pnpm gen:docs\"",
"lint": "turbo run lint -- --quiet",
"preinstall": "npx only-allow pnpm",
+ "shadcn:publish:all": "TURBO_UI=true turbo run shadcn:publish:all --filter=@livekit/agents-ui...",
"ci:publish": "turbo run build --filter=./packages/* && pnpm gen:docs && changeset publish",
"start:next": "turbo run start --filter=@livekit/component-example-next...",
"test": "turbo run test",
diff --git a/packages/shadcn/README.md b/packages/shadcn/README.md
index a9b7d772a..b12da9d76 100644
--- a/packages/shadcn/README.md
+++ b/packages/shadcn/README.md
@@ -144,18 +144,64 @@ You can find the components in [Storybook](http://localhost:6006) under the `Age
```bash
# Build the shadcn registry
-pnpm registry:build
+pnpm shadcn:build
# Serve the registry locally (http://localhost:3210)
-pnpm registry:serve
+pnpm shadcn:serve
# Generate prop documentation
-pnpm registry:doc-gen
+pnpm shadcn:doc-gen
# Build and deploy to configured destination paths
-pnpm registry:update
+pnpm shadcn:deploy
```
+### Publishing downstream updates
+
+These scripts are maintainer-only release helpers. They require an authenticated `gh`
+CLI session with access to the target repositories, push branches to those
+repositories, and open GitHub PRs.
+
+```bash
+# Sync the registry into livekit-examples/agent-starter-react
+pnpm shadcn:publish:agent-starter-react
+
+# Sync the hosted registry, prop docs, and installed @agents-ui components
+# in apps/www and apps/docs into livekit/web
+pnpm shadcn:publish:livekit-web
+
+# Run both downstream publish scripts
+pnpm shadcn:publish:all
+```
+
+Each script clones the target repo into a temp directory, makes the change, and prints the
+repo, branch, title, body, and a `git diff --stat` summary before asking `Create this PR?
+(y/N)`. Pass `-y`/`--yes` to skip that prompt and auto-approve (still prints the summary
+first):
+
+```bash
+# From packages/shadcn — no -- needed
+pnpm shadcn:publish:livekit-web -y
+
+# From the repo root — shadcn:publish:all goes through turbo, which requires
+# -- to forward flags to the underlying task
+pnpm shadcn:publish:all -- -y
+```
+
+#### Dry run
+
+To see the full diff a run would produce — including the confirmation summary — without
+ever committing, pushing, or opening a PR, just answer `n` (or let it default) at the
+prompt. This still does all the real work (build, clone, install, format), it just stops
+before touching anything remote:
+
+```bash
+echo "n" | pnpm shadcn:publish:livekit-web
+```
+
+The temp clone, local registry server, and any `.env.local` changes are cleaned up
+automatically whether you confirm, decline, or `Ctrl-C` out mid-run.
+
### Environment Variables
Create a `.env.local` file with:
diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx
index c01e39cc3..305fed9a7 100644
--- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx
+++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-aura.tsx
@@ -1,3 +1,5 @@
+'use client';
+
/**
* @license
*
@@ -10,8 +12,6 @@
* © 2026 UNCRN LLC
*/
-'use client';
-
import React, { useMemo, type ComponentProps } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx
index f38ac261f..b049b6081 100644
--- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx
+++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx
@@ -24,7 +24,7 @@ import { cn } from '@/lib/utils';
* Excess values are trimmed from the end; if there are too few, the last
* value is duplicated to fill the remainder. An empty array is padded with 0s.
*/
-function normalizeVolumeBands(bands: number[], count: number): number[] {
+export function normalizeVolumeBands(bands: number[], count: number): number[] {
if (bands.length === count) return bands;
if (bands.length > count) return bands.slice(0, count);
const lastValue = bands[bands.length - 1] ?? 0;
diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx
index 84603b947..3b0ca9b12 100644
--- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx
+++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx
@@ -11,7 +11,7 @@ import React, {
useMemo,
} from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
-import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
+import type { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
import {
type AgentState,
type TrackReferenceOrPlaceholder,
@@ -28,7 +28,7 @@ import { cn } from '@/lib/utils';
* Excess values are trimmed from the end; if there are too few, the last
* value is duplicated to fill the remainder. An empty array is padded with 0s.
*/
-function normalizeVolumeBands(bands: number[], count: number): number[] {
+export function normalizeVolumeBands(bands: number[], count: number): number[] {
if (bands.length === count) return bands;
if (bands.length > count) return bands.slice(0, count);
const lastValue = bands[bands.length - 1] ?? 0;
diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx
index 1676f992b..2961a6079 100644
--- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx
+++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx
@@ -16,7 +16,7 @@ import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-age
* Excess values are trimmed from the end; if there are too few, the last
* value is duplicated to fill the remainder. An empty array is padded with 0s.
*/
-function normalizeVolumeBands(bands: number[], count: number): number[] {
+export function normalizeVolumeBands(bands: number[], count: number): number[] {
if (bands.length === count) return bands;
if (bands.length > count) return bands.slice(0, count);
const lastValue = bands[bands.length - 1] ?? 0;
diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx
index 1d2190674..9eb790d12 100644
--- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx
+++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-wave.tsx
@@ -7,7 +7,7 @@ import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/comp
import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy';
import { useAgentAudioVisualizerWave } from '@/hooks/agents-ui/use-agent-audio-visualizer-wave';
import { cn } from '@/lib/utils';
-import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
+import type { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
const DEFAULT_COLOR = '#1FD5F9';
diff --git a/packages/shadcn/components/agents-ui/agent-control-bar.tsx b/packages/shadcn/components/agents-ui/agent-control-bar.tsx
index f64cc116f..ea39cdd91 100644
--- a/packages/shadcn/components/agents-ui/agent-control-bar.tsx
+++ b/packages/shadcn/components/agents-ui/agent-control-bar.tsx
@@ -397,8 +397,8 @@ export function AgentControlBar({
'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider',
)}
>
- End call
- End
+ End call
+ End
)}
diff --git a/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx b/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx
index d4e2f9044..976e7bc0c 100644
--- a/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx
+++ b/packages/shadcn/components/agents-ui/agent-disconnect-button.tsx
@@ -1,7 +1,7 @@
'use client';
import { type ComponentProps } from 'react';
-import { Button, buttonVariants } from '@/components/ui/button';
+import { Button, type buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useSessionContext } from '@livekit/components-react';
import { type VariantProps } from 'class-variance-authority';
diff --git a/packages/shadcn/components/agents-ui/agent-session-provider.tsx b/packages/shadcn/components/agents-ui/agent-session-provider.tsx
index 54ffe7465..d9c0af47c 100644
--- a/packages/shadcn/components/agents-ui/agent-session-provider.tsx
+++ b/packages/shadcn/components/agents-ui/agent-session-provider.tsx
@@ -5,7 +5,7 @@ import {
type SessionProviderProps,
type RoomAudioRendererProps,
} from '@livekit/components-react';
-import { Room } from 'livekit-client';
+import { type Room } from 'livekit-client';
/**
* Props for the AgentSessionProvider component.
diff --git a/packages/shadcn/components/agents-ui/agent-track-control.tsx b/packages/shadcn/components/agents-ui/agent-track-control.tsx
index 21e3050c7..d74c3e147 100644
--- a/packages/shadcn/components/agents-ui/agent-track-control.tsx
+++ b/packages/shadcn/components/agents-ui/agent-track-control.tsx
@@ -2,7 +2,6 @@
import { useEffect, useMemo, useState } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
-import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
import {
type TrackReferenceOrPlaceholder,
useMaybeRoomContext,
@@ -17,7 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { toggleVariants } from '@/components/ui/toggle';
+import { type toggleVariants } from '@/components/ui/toggle';
import { cn } from '@/lib/utils';
const selectVariants = cva(
diff --git a/packages/shadcn/components/agents-ui/react-shader-toy.tsx b/packages/shadcn/components/agents-ui/react-shader-toy.tsx
index 8d05a2e9a..e46e36161 100644
--- a/packages/shadcn/components/agents-ui/react-shader-toy.tsx
+++ b/packages/shadcn/components/agents-ui/react-shader-toy.tsx
@@ -171,10 +171,6 @@ const uniformTypeToGLSLType = (t: string) => {
const LinearFilter = 9729;
const NearestFilter = 9728;
const LinearMipMapLinearFilter = 9987;
-const NearestMipMapLinearFilter = 9986;
-const LinearMipMapNearestFilter = 9985;
-const NearestMipMapNearestFilter = 9984;
-const MirroredRepeatWrapping = 33648;
const ClampToEdgeWrapping = 33071;
const RepeatWrapping = 10497;
diff --git a/packages/shadcn/components/agents-ui/start-audio-button.tsx b/packages/shadcn/components/agents-ui/start-audio-button.tsx
index d8f74dd83..7ab90d139 100644
--- a/packages/shadcn/components/agents-ui/start-audio-button.tsx
+++ b/packages/shadcn/components/agents-ui/start-audio-button.tsx
@@ -1,7 +1,7 @@
import { type ComponentProps } from 'react';
import { useEnsureRoom, useStartAudio } from '@livekit/components-react';
import { Button } from '@/components/ui/button';
-import { Room } from 'livekit-client';
+import { type Room } from 'livekit-client';
/**
* Props for the StartAudioButton component.
diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json
index 2d5ad653a..77e03d664 100644
--- a/packages/shadcn/package.json
+++ b/packages/shadcn/package.json
@@ -6,10 +6,15 @@
"type": "module",
"main": "index.ts",
"scripts": {
- "registry:build": "pnpm test && rm -rf dist && shadcn build --output ./dist/r && pnpm registry:doc-gen",
- "registry:serve": "python3 -m http.server 3210 -d ./dist",
- "registry:doc-gen": "node --experimental-strip-types --env-file=.env.local ./scripts/doc-gen.ts",
- "registry:update": "pnpm registry:build && node --experimental-strip-types --env-file=.env.local ./scripts/update.ts",
+ "build:deps": "pnpm --filter @livekit/agents-ui^... build",
+ "pretest": "pnpm build:deps",
+ "shadcn:build": "pnpm test && rm -rf dist && shadcn build --output ./dist/r && pnpm shadcn:doc-gen",
+ "shadcn:serve": "pnpm shadcn:build && python3 -m http.server 3210 -d ./dist",
+ "shadcn:doc-gen": "node --experimental-strip-types ./scripts/doc-gen.ts",
+ "shadcn:deploy": "pnpm shadcn:build && node --experimental-strip-types --env-file=.env.local ./scripts/update.ts",
+ "shadcn:publish:agent-starter-react": "node --experimental-strip-types ./scripts/publish-agent-starter-react.ts",
+ "shadcn:publish:livekit-web": "node --experimental-strip-types ./scripts/publish-livekit-web.ts",
+ "shadcn:publish:all": "node --experimental-strip-types ./scripts/publish-downstream.ts",
"shadcn:update": "pnpm dlx shadcn@latest add alert button button-group select separator sonner toggle tooltip message-scroller message bubble && pnpm format",
"test": "vitest --run",
"test:watch": "vitest",
diff --git a/packages/shadcn/scripts/lib/publish-utils.ts b/packages/shadcn/scripts/lib/publish-utils.ts
new file mode 100644
index 000000000..72730bec2
--- /dev/null
+++ b/packages/shadcn/scripts/lib/publish-utils.ts
@@ -0,0 +1,308 @@
+import { execFileSync, spawn, type ChildProcess } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import readline from 'node:readline/promises';
+
+export function run(cmd: string[], opts: { cwd?: string } = {}): void {
+ execFileSync(cmd[0], cmd.slice(1), { cwd: opts.cwd, stdio: 'inherit' });
+}
+
+export function runCapture(cmd: string[], opts: { cwd?: string } = {}): string {
+ return execFileSync(cmd[0], cmd.slice(1), { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] })
+ .toString()
+ .trim();
+}
+
+export function mkTempClone(prefix: string): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+export function hasDiff(cwd: string): boolean {
+ return runCapture(['git', 'status', '--porcelain'], { cwd }).length > 0;
+}
+
+export function buildRegistry(shadcnPkgDir: string): void {
+ console.log('--------------------------------');
+ console.log('Building registry');
+ run(['pnpm', 'shadcn:build'], { cwd: shadcnPkgDir });
+}
+
+export function installDependencies(cwd: string): void {
+ console.log('--------------------------------');
+ console.log('Installing dependencies');
+ run(['pnpm', 'install'], { cwd });
+}
+
+export function formatChanges(cwd: string, cmd: string[] = ['pnpm', 'format']): void {
+ console.log('--------------------------------');
+ console.log('Formatting changes');
+ run(cmd, { cwd });
+}
+
+export async function serveRegistry(shadcnPkgDir: string, port: number): Promise {
+ console.log('--------------------------------');
+ console.log(`Serving registry on port ${port}`);
+ const server = spawn('python3', ['-m', 'http.server', String(port), '-d', './dist'], {
+ cwd: shadcnPkgDir,
+ stdio: 'ignore',
+ });
+ await waitForHttp(`http://localhost:${port}/r/registry.json`);
+ return server;
+}
+
+export function pointRegistryAt(componentsJsonPath: string, registryUrl: string): void {
+ console.log('--------------------------------');
+ console.log(`Pointing ${componentsJsonPath} at ${registryUrl}`);
+ const componentsJson = JSON.parse(fs.readFileSync(componentsJsonPath, 'utf-8'));
+ componentsJson.registries['@agents-ui'] = registryUrl;
+ fs.writeFileSync(componentsJsonPath, JSON.stringify(componentsJson, null, 2) + '\n');
+}
+
+export function revertFile(cwd: string, relativePath: string): void {
+ console.log('--------------------------------');
+ console.log(`Reverting temporary change to ${relativePath}`);
+ run(['git', 'checkout', '--', relativePath], { cwd });
+}
+
+export function ghAuthPreflight(): void {
+ try {
+ execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' });
+ } catch {
+ throw new Error('gh CLI is not authenticated. Run `gh auth login` and try again.');
+ }
+}
+
+export function isPortInUse(port: number): boolean {
+ try {
+ const pids = execFileSync('lsof', ['-ti', `tcp:${port}`], {
+ stdio: ['ignore', 'pipe', 'ignore'],
+ })
+ .toString()
+ .trim();
+ return pids.length > 0;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * killServer() sends SIGTERM without waiting for the process to actually exit, so a
+ * server killed at the end of one publish run may still hold the port for a moment
+ * when the next run starts (e.g. back-to-back in publish-downstream.ts). Poll instead
+ * of failing immediately on the first check.
+ */
+export async function waitForPortFree(port: number, timeoutMs = 5_000): Promise {
+ const start = Date.now();
+ while (isPortInUse(port)) {
+ if (Date.now() - start > timeoutMs) {
+ throw new Error(
+ `Port ${port} is already in use. Stop any running shadcn:serve process and try again.`,
+ );
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+}
+
+export async function waitForHttp(url: string, timeoutMs = 10_000): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const res = await fetch(url);
+ if (res.ok) return;
+ } catch {
+ // server not up yet
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ throw new Error(`Timed out waiting for ${url} to respond`);
+}
+
+export function branchName(prefix: string): string {
+ const timestamp = new Date()
+ .toISOString()
+ .replace(/[-:]/g, '')
+ .replace(/\..+/, '')
+ .replace('T', '-');
+ return `${prefix}-${timestamp}`;
+}
+
+export function ghPrCreate(opts: {
+ repo: string;
+ cwd: string;
+ title: string;
+ body: string;
+ head: string;
+ base: string;
+}): string {
+ return runCapture(
+ [
+ 'gh',
+ 'pr',
+ 'create',
+ '--repo',
+ opts.repo,
+ '--title',
+ opts.title,
+ '--body',
+ opts.body,
+ '--head',
+ opts.head,
+ '--base',
+ opts.base,
+ ],
+ { cwd: opts.cwd },
+ );
+}
+
+export function killServer(server: ChildProcess | undefined): void {
+ if (server && !server.killed) {
+ server.kill();
+ }
+}
+
+export function removeTempDir(dir: string | undefined): void {
+ if (dir) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+export function hasCliFlag(...flags: string[]): boolean {
+ return process.argv.slice(2).some((arg) => flags.includes(arg));
+}
+
+export function onInterrupt(cleanup: () => void): () => void {
+ const handler = () => {
+ cleanup();
+ process.exit(1);
+ };
+ process.once('SIGINT', handler);
+ process.once('SIGTERM', handler);
+ return () => {
+ process.off('SIGINT', handler);
+ process.off('SIGTERM', handler);
+ };
+}
+
+// A second readline.createInterface() on process.stdin after closing the first one
+// never receives further input (it hangs indefinitely) — so when a single process
+// prompts more than once (e.g. publish-downstream.ts running both publish scripts
+// in-process), all prompts must share one interface, closed exactly once at exit
+// via closePromptInterface().
+let sharedPromptInterface: readline.Interface | undefined;
+
+function getPromptInterface(): readline.Interface {
+ sharedPromptInterface ??= readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+ return sharedPromptInterface;
+}
+
+export function closePromptInterface(): void {
+ sharedPromptInterface?.close();
+ sharedPromptInterface = undefined;
+}
+
+export async function promptYesNo(question: string): Promise {
+ const rl = getPromptInterface();
+ const answer = await rl.question(`${question} (y/N): `);
+ return ['y', 'yes'].includes(answer.trim().toLowerCase());
+}
+
+export function cloneAndCreateBranch(
+ repo: string,
+ tmpDirPrefix: string,
+ branchPrefix: string,
+ baseBranch: string,
+): { tmpDir: string; branch: string } {
+ const tmpDir = mkTempClone(tmpDirPrefix);
+ console.log('--------------------------------');
+ console.log(`Cloning ${repo} (${baseBranch}, depth 1) into ${tmpDir}`);
+ // Shallow, single-branch clone: this helper is only ever used to branch off the
+ // tip of baseBranch, diff, commit, and push — never git log/blame/tags. Don't
+ // reuse this path for a caller that needs history beyond HEAD.
+ run([
+ 'gh',
+ 'repo',
+ 'clone',
+ repo,
+ tmpDir,
+ '--',
+ '--depth',
+ '1',
+ '--single-branch',
+ '--branch',
+ baseBranch,
+ '--no-tags',
+ ]);
+
+ const branch = branchName(branchPrefix);
+ run(['git', 'checkout', '-b', branch, `origin/${baseBranch}`], { cwd: tmpDir });
+
+ return { tmpDir, branch };
+}
+
+async function confirmPrCreation(opts: {
+ repo: string;
+ cwd: string;
+ branch: string;
+ base: string;
+ title: string;
+ body: string;
+ autoApprove?: boolean;
+}): Promise {
+ const diffStat = runCapture(['git', 'diff', '--stat'], { cwd: opts.cwd });
+
+ console.log('--------------------------------');
+ console.log('About to open a pull request:');
+ console.log(` Repo: ${opts.repo}`);
+ console.log(` Branch: ${opts.branch} -> ${opts.base}`);
+ console.log(` Title: ${opts.title}`);
+ console.log(' Body:');
+ for (const line of opts.body.split('\n')) console.log(` ${line}`);
+ console.log(' Changes:');
+ for (const line of diffStat.split('\n')) console.log(` ${line}`);
+ console.log('--------------------------------');
+
+ if (opts.autoApprove) {
+ console.log('Auto-approved (-y)');
+ return true;
+ }
+
+ return promptYesNo('Create this PR?');
+}
+
+export async function commitPushAndOpenPr(opts: {
+ repo: string;
+ cwd: string;
+ branch: string;
+ base: string;
+ title: string;
+ body: string;
+ commitMessage: string;
+ autoApprove?: boolean;
+}): Promise {
+ const confirmed = await confirmPrCreation(opts);
+ if (!confirmed) {
+ console.log('PR creation cancelled by user');
+ return undefined;
+ }
+
+ run(['git', 'add', '-A'], { cwd: opts.cwd });
+ run(['git', 'commit', '-m', opts.commitMessage], { cwd: opts.cwd });
+ run(['git', 'push', '-u', 'origin', opts.branch], { cwd: opts.cwd });
+
+ const prUrl = ghPrCreate({
+ repo: opts.repo,
+ cwd: opts.cwd,
+ title: opts.title,
+ body: opts.body,
+ head: opts.branch,
+ base: opts.base,
+ });
+
+ console.log('--------------------------------');
+ console.log(`Opened PR: ${prUrl}`);
+ return prUrl;
+}
diff --git a/packages/shadcn/scripts/publish-agent-starter-react.ts b/packages/shadcn/scripts/publish-agent-starter-react.ts
new file mode 100644
index 000000000..7249a7347
--- /dev/null
+++ b/packages/shadcn/scripts/publish-agent-starter-react.ts
@@ -0,0 +1,123 @@
+import { type ChildProcess } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ buildRegistry,
+ cloneAndCreateBranch,
+ commitPushAndOpenPr,
+ formatChanges,
+ closePromptInterface,
+ ghAuthPreflight,
+ hasCliFlag,
+ hasDiff,
+ killServer,
+ onInterrupt,
+ pointRegistryAt,
+ removeTempDir,
+ revertFile,
+ run,
+ runCapture,
+ serveRegistry,
+ waitForPortFree,
+} from './lib/publish-utils.ts';
+
+type PublishOptions = {
+ registryAlreadyBuilt?: boolean;
+ autoApprove?: boolean;
+};
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SHADCN_PKG_DIR = path.join(__dirname, '..');
+const REPO = 'livekit-examples/agent-starter-react';
+const PORT = 3210;
+const REGISTRY_URL = `http://localhost:${PORT}/r/{name}.json`;
+const BASE_BRANCH = 'main';
+const BRANCH_PREFIX = 'chore/update-agents-ui-registry';
+const COMMIT_MESSAGE = 'chore: sync agents-ui components from livekit/components-js';
+const PR_TITLE = 'chore: sync agents-ui components';
+
+function installComponents(tmpDir: string): void {
+ console.log('--------------------------------');
+ console.log('Installing dependencies');
+ run(['pnpm', 'install'], { cwd: tmpDir });
+
+ console.log('--------------------------------');
+ console.log('Installing agents-ui components');
+ run(['pnpm', 'dlx', 'shadcn@latest', 'add', '--yes', '--overwrite', '@agents-ui/all'], {
+ cwd: tmpDir,
+ });
+}
+
+function buildPrBody(sourceSha: string): string {
+ return `Syncs \`@agents-ui\` components from the latest build of [livekit/components-js](https://github.com/livekit/components-js) at ${sourceSha}.\n\nGenerated by \`packages/shadcn/scripts/publish-agent-starter-react.ts\`.`;
+}
+
+export async function publishAgentStarterReact(options: PublishOptions = {}): Promise<{
+ success: boolean;
+ skipped?: boolean;
+ prUrl?: string;
+}> {
+ ghAuthPreflight();
+ await waitForPortFree(PORT);
+
+ let server: ChildProcess | undefined;
+ let tmpDir: string | undefined;
+ const cleanup = () => {
+ killServer(server);
+ removeTempDir(tmpDir);
+ };
+ const unregisterInterruptHandler = onInterrupt(cleanup);
+
+ try {
+ if (!options.registryAlreadyBuilt) {
+ buildRegistry(SHADCN_PKG_DIR);
+ }
+ server = await serveRegistry(SHADCN_PKG_DIR, PORT);
+
+ const cloned = cloneAndCreateBranch(REPO, 'agent-starter-react-', BRANCH_PREFIX, BASE_BRANCH);
+ tmpDir = cloned.tmpDir;
+
+ const componentsJsonPath = path.join(tmpDir, 'components.json');
+ pointRegistryAt(componentsJsonPath, REGISTRY_URL);
+ installComponents(tmpDir);
+ revertFile(tmpDir, 'components.json');
+ formatChanges(tmpDir);
+
+ if (!hasDiff(tmpDir)) {
+ console.log('No changes after sync — skipping PR');
+ return { success: true, skipped: true };
+ }
+
+ const sourceSha = runCapture(['git', 'rev-parse', 'HEAD'], { cwd: SHADCN_PKG_DIR });
+ const prUrl = await commitPushAndOpenPr({
+ repo: REPO,
+ cwd: tmpDir,
+ branch: cloned.branch,
+ base: BASE_BRANCH,
+ title: PR_TITLE,
+ body: buildPrBody(sourceSha),
+ commitMessage: COMMIT_MESSAGE,
+ autoApprove: options.autoApprove,
+ });
+
+ if (!prUrl) {
+ return { success: true, skipped: true };
+ }
+ return { success: true, prUrl };
+ } finally {
+ unregisterInterruptHandler();
+ cleanup();
+ }
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
+ publishAgentStarterReact({ autoApprove: hasCliFlag('-y', '--yes') })
+ .then((result) => {
+ console.log('Done', result);
+ })
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(() => closePromptInterface());
+}
diff --git a/packages/shadcn/scripts/publish-downstream.ts b/packages/shadcn/scripts/publish-downstream.ts
new file mode 100644
index 000000000..58f19c6a5
--- /dev/null
+++ b/packages/shadcn/scripts/publish-downstream.ts
@@ -0,0 +1,54 @@
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { buildRegistry, closePromptInterface, hasCliFlag } from './lib/publish-utils.ts';
+import { publishAgentStarterReact } from './publish-agent-starter-react.ts';
+import { publishLivekitWeb } from './publish-livekit-web.ts';
+
+type Result = { name: string; success: boolean; skipped?: boolean; prUrl?: string; error?: string };
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SHADCN_PKG_DIR = path.join(__dirname, '..');
+
+async function runAgentStarterReact(autoApprove: boolean): Promise {
+ try {
+ const result = await publishAgentStarterReact({ registryAlreadyBuilt: true, autoApprove });
+ return { name: 'agent-starter-react', ...result };
+ } catch (err) {
+ return { name: 'agent-starter-react', success: false, error: String(err) };
+ }
+}
+
+async function runLivekitWeb(autoApprove: boolean): Promise {
+ try {
+ const result = await publishLivekitWeb({ registryAlreadyBuilt: true, autoApprove });
+ return { name: 'livekit-web', ...result };
+ } catch (err) {
+ return { name: 'livekit-web', success: false, error: String(err) };
+ }
+}
+
+function printSummary(results: Result[]): void {
+ console.log('--------------------------------');
+ console.log('Summary');
+ for (const result of results) {
+ const status = !result.success ? '❌' : result.skipped ? '⏭ skipped' : '✅';
+ console.log(
+ `${status} ${result.name}${result.prUrl ? ` — ${result.prUrl}` : ''}${result.error ? ` — ${result.error}` : ''}`,
+ );
+ }
+}
+
+async function publishDownstream(autoApprove: boolean): Promise {
+ buildRegistry(SHADCN_PKG_DIR);
+ const results: Result[] = [];
+ results.push(await runAgentStarterReact(autoApprove));
+ results.push(await runLivekitWeb(autoApprove));
+ return results;
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
+ const autoApprove = hasCliFlag('-y', '--yes');
+ const results = await publishDownstream(autoApprove);
+ closePromptInterface();
+ printSummary(results);
+ process.exitCode = results.some((r) => !r.success) ? 1 : 0;
+}
diff --git a/packages/shadcn/scripts/publish-livekit-web.ts b/packages/shadcn/scripts/publish-livekit-web.ts
new file mode 100644
index 000000000..504750296
--- /dev/null
+++ b/packages/shadcn/scripts/publish-livekit-web.ts
@@ -0,0 +1,207 @@
+import { type ChildProcess } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ buildRegistry,
+ cloneAndCreateBranch,
+ commitPushAndOpenPr,
+ closePromptInterface,
+ ghAuthPreflight,
+ hasCliFlag,
+ hasDiff,
+ installDependencies,
+ killServer,
+ onInterrupt,
+ pointRegistryAt,
+ removeTempDir,
+ revertFile,
+ run,
+ runCapture,
+ serveRegistry,
+ waitForPortFree,
+} from './lib/publish-utils.ts';
+
+type PublishOptions = {
+ registryAlreadyBuilt?: boolean;
+ autoApprove?: boolean;
+};
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SHADCN_PKG_DIR = path.join(__dirname, '..');
+const ENV_LOCAL_PATH = path.join(SHADCN_PKG_DIR, '.env.local');
+const REPO = 'livekit/web';
+const PORT = 3210;
+const REGISTRY_URL = `http://localhost:${PORT}/r/{name}.json`;
+const BASE_BRANCH = 'main';
+const BRANCH_PREFIX = 'chore/update-agents-ui-registry';
+const COMMIT_MESSAGE = 'chore(agents-ui): update registry + prop-types from @livekit/agents-ui';
+const PR_TITLE = 'chore(agents-ui): update registry + prop-types';
+const APPS = ['www', 'docs'];
+// shadcn add doesn't know about pnpm's catalog: protocol — it overwrites these with a
+// concrete version pinned to whatever the @agents-ui registry entry declares. Restore
+// the workspace's catalog reference afterward so we don't fork these off the catalog.
+const CATALOG_DEPENDENCIES = ['@livekit/components-react', 'livekit-client'];
+
+function writeTempEnvLocal(tmpDir: string): void {
+ console.log('--------------------------------');
+ console.log('Pointing .env.local at the tmp livekit/web checkout');
+ const destRegistryPath = path.join(tmpDir, 'apps/www/agents-ui-registry');
+ const destPropTypesPath = path.join(tmpDir, 'apps/docs/lib/shadcn/prop-types.json');
+ fs.writeFileSync(
+ ENV_LOCAL_PATH,
+ `DEST_REGISTRY_PATH=${destRegistryPath}\nDEST_PROP_TYPES_PATH=${destPropTypesPath}\n`,
+ );
+}
+
+function copyRegistryFiles(): void {
+ console.log('--------------------------------');
+ console.log('Copying built registry into the tmp livekit/web checkout');
+ run(['node', '--experimental-strip-types', '--env-file=.env.local', './scripts/update.ts'], {
+ cwd: SHADCN_PKG_DIR,
+ });
+}
+
+function buildFormatDependencies(tmpDir: string): void {
+ console.log('--------------------------------');
+ console.log('Building @repo/prettier-plugin-markdoc (required by apps/docs format script)');
+ run(['pnpm', '--filter', '@repo/prettier-plugin-markdoc', 'build'], { cwd: tmpDir });
+}
+
+function restoreCatalogDependencies(packageJsonPath: string): void {
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
+ let changed = false;
+
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies']) {
+ const deps = pkg[section];
+ if (!deps) continue;
+ for (const name of CATALOG_DEPENDENCIES) {
+ if (name in deps && deps[name] !== 'catalog:') {
+ deps[name] = 'catalog:';
+ changed = true;
+ }
+ }
+ }
+
+ if (changed) {
+ console.log('--------------------------------');
+ console.log(`Restoring catalog: versions in ${packageJsonPath}`);
+ fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
+ }
+}
+
+function installAgentsUiComponents(tmpDir: string): void {
+ const componentsJsonPaths = APPS.map((app) => path.join(tmpDir, 'apps', app, 'components.json'));
+
+ for (const componentsJsonPath of componentsJsonPaths) {
+ pointRegistryAt(componentsJsonPath, REGISTRY_URL);
+ }
+
+ for (const app of APPS) {
+ console.log('--------------------------------');
+ console.log(`Installing agents-ui components in apps/${app}`);
+ run(['pnpm', '--filter', app, 'shadcn:install'], { cwd: tmpDir });
+ }
+
+ for (const app of APPS) {
+ revertFile(tmpDir, path.join('apps', app, 'components.json'));
+ }
+
+ let restoredAnyCatalogVersion = false;
+ for (const app of APPS) {
+ const packageJsonPath = path.join(tmpDir, 'apps', app, 'package.json');
+ const before = fs.readFileSync(packageJsonPath, 'utf-8');
+ restoreCatalogDependencies(packageJsonPath);
+ if (fs.readFileSync(packageJsonPath, 'utf-8') !== before) {
+ restoredAnyCatalogVersion = true;
+ }
+ }
+ if (restoredAnyCatalogVersion) {
+ installDependencies(tmpDir);
+ }
+}
+
+function restoreEnvLocal(hadEnvLocal: boolean, envBackup: string | null): void {
+ if (hadEnvLocal && envBackup !== null) {
+ fs.writeFileSync(ENV_LOCAL_PATH, envBackup);
+ } else {
+ fs.rmSync(ENV_LOCAL_PATH, { force: true });
+ }
+}
+
+function buildPrBody(sourceSha: string): string {
+ return `Updates \`apps/www/agents-ui-registry\`, \`apps/docs/lib/shadcn/prop-types.json\`, and the installed \`@agents-ui\` components in \`apps/www\` and \`apps/docs\` from the latest build of [livekit/components-js](https://github.com/livekit/components-js) at ${sourceSha}.\n\nGenerated by \`packages/shadcn/scripts/publish-livekit-web.ts\`.`;
+}
+
+export async function publishLivekitWeb(options: PublishOptions = {}): Promise<{
+ success: boolean;
+ skipped?: boolean;
+ prUrl?: string;
+}> {
+ ghAuthPreflight();
+ await waitForPortFree(PORT);
+
+ let server: ChildProcess | undefined;
+ let tmpDir: string | undefined;
+ const hadEnvLocal = fs.existsSync(ENV_LOCAL_PATH);
+ const envBackup = hadEnvLocal ? fs.readFileSync(ENV_LOCAL_PATH, 'utf-8') : null;
+ const cleanup = () => {
+ killServer(server);
+ restoreEnvLocal(hadEnvLocal, envBackup);
+ removeTempDir(tmpDir);
+ };
+ const unregisterInterruptHandler = onInterrupt(cleanup);
+
+ try {
+ if (!options.registryAlreadyBuilt) {
+ buildRegistry(SHADCN_PKG_DIR);
+ }
+ server = await serveRegistry(SHADCN_PKG_DIR, PORT);
+
+ const cloned = cloneAndCreateBranch(REPO, 'livekit-web-', BRANCH_PREFIX, BASE_BRANCH);
+ tmpDir = cloned.tmpDir;
+
+ writeTempEnvLocal(tmpDir);
+ copyRegistryFiles();
+ installDependencies(tmpDir);
+ buildFormatDependencies(tmpDir);
+ installAgentsUiComponents(tmpDir);
+
+ if (!hasDiff(tmpDir)) {
+ console.log('No changes after sync — skipping PR');
+ return { success: true, skipped: true };
+ }
+
+ const sourceSha = runCapture(['git', 'rev-parse', 'HEAD'], { cwd: SHADCN_PKG_DIR });
+ const prUrl = await commitPushAndOpenPr({
+ repo: REPO,
+ cwd: tmpDir,
+ branch: cloned.branch,
+ base: BASE_BRANCH,
+ title: PR_TITLE,
+ body: buildPrBody(sourceSha),
+ commitMessage: COMMIT_MESSAGE,
+ autoApprove: options.autoApprove,
+ });
+
+ if (!prUrl) {
+ return { success: true, skipped: true };
+ }
+ return { success: true, prUrl };
+ } finally {
+ unregisterInterruptHandler();
+ cleanup();
+ }
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
+ publishLivekitWeb({ autoApprove: hasCliFlag('-y', '--yes') })
+ .then((result) => {
+ console.log('Done', result);
+ })
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(() => closePromptInterface());
+}
diff --git a/packages/shadcn/scripts/update.ts b/packages/shadcn/scripts/update.ts
index 9ad0e051e..1b82fe7a9 100644
--- a/packages/shadcn/scripts/update.ts
+++ b/packages/shadcn/scripts/update.ts
@@ -1,24 +1,50 @@
-import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
-// copy .env.example to .env.local and edit DEST_REGISTRY_PATH
-if (!process.env.DEST_REGISTRY_PATH) {
- throw new Error('DEST_REGISTRY_PATH is not set');
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SHADCN_PKG_DIR = path.join(__dirname, '..');
+
+function requireEnv(name: string): string {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(`${name} is not set`);
+ }
+ return value;
}
-console.log('--------------------------------');
-console.log(`Cleaning ${process.env.DEST_REGISTRY_PATH}`);
-execSync(`rm -rf ${process.env.DEST_REGISTRY_PATH}/*`);
-console.log('--------------------------------');
-console.log(`Copying dist/r to ${process.env.DEST_REGISTRY_PATH}`);
-execSync(`cp -r dist/r/* ${process.env.DEST_REGISTRY_PATH}`);
+const destRegistryPath = requireEnv('DEST_REGISTRY_PATH');
+const destPropTypesPath = requireEnv('DEST_PROP_TYPES_PATH');
+const registrySourcePath = path.join(SHADCN_PKG_DIR, 'dist/r');
+const propTypesSourcePath = path.join(SHADCN_PKG_DIR, 'dist/prop-types.json');
+
+function assertSafeCleanTarget(targetPath: string): void {
+ const resolvedPath = path.resolve(targetPath);
+ if (
+ resolvedPath === path.parse(resolvedPath).root ||
+ resolvedPath === SHADCN_PKG_DIR ||
+ resolvedPath === path.dirname(SHADCN_PKG_DIR)
+ ) {
+ throw new Error(`Refusing to clean unsafe DEST_REGISTRY_PATH: ${targetPath}`);
+ }
+}
-if (!process.env.DEST_PROP_TYPES_PATH) {
- throw new Error('DEST_PROP_TYPES_PATH is not set');
+console.log('--------------------------------');
+console.log(`Cleaning ${destRegistryPath}`);
+assertSafeCleanTarget(destRegistryPath);
+fs.mkdirSync(destRegistryPath, { recursive: true });
+for (const entry of fs.readdirSync(destRegistryPath)) {
+ fs.rmSync(path.join(destRegistryPath, entry), { recursive: true, force: true });
}
console.log('--------------------------------');
-console.log(`Copying dist/prop-types.json to ${process.env.DEST_PROP_TYPES_PATH}`);
-execSync(`cp dist/prop-types.json ${process.env.DEST_PROP_TYPES_PATH}`);
+console.log(`Copying dist/r to ${destRegistryPath}`);
+fs.cpSync(registrySourcePath, destRegistryPath, { recursive: true });
+
+console.log('--------------------------------');
+console.log(`Copying dist/prop-types.json to ${destPropTypesPath}`);
+fs.mkdirSync(path.dirname(destPropTypesPath), { recursive: true });
+fs.copyFileSync(propTypesSourcePath, destPropTypesPath);
console.log('--------------------------------');
console.log('Done');
diff --git a/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx
index cdb0857f0..b106d1f51 100644
--- a/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx
+++ b/packages/shadcn/tests/agent-audio-visualizer-bar.test.tsx
@@ -1,6 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
-import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
+import {
+ AgentAudioVisualizerBar,
+ normalizeVolumeBands,
+} from '@/components/agents-ui/agent-audio-visualizer-bar';
import * as LiveKitComponents from '@livekit/components-react';
// Mock the @livekit/components-react hooks
@@ -149,3 +152,25 @@ describe('AgentAudioVisualizerBar', () => {
).toThrow('AgentAudioVisualizerBar children must be a single element.');
});
});
+
+describe('normalizeVolumeBands', () => {
+ it('returns the array unchanged when the length already matches', () => {
+ expect(normalizeVolumeBands([0.1, 0.2, 0.3], 3)).toEqual([0.1, 0.2, 0.3]);
+ });
+
+ it('trims excess trailing values', () => {
+ expect(normalizeVolumeBands([0.1, 0.2, 0.3, 0.4], 2)).toEqual([0.1, 0.2]);
+ });
+
+ it('pads by duplicating the last value', () => {
+ expect(normalizeVolumeBands([0.1, 0.2], 4)).toEqual([0.1, 0.2, 0.2, 0.2]);
+ });
+
+ it('pads a single-element array by duplicating it', () => {
+ expect(normalizeVolumeBands([0.5], 3)).toEqual([0.5, 0.5, 0.5]);
+ });
+
+ it('pads an empty array with 0s', () => {
+ expect(normalizeVolumeBands([], 3)).toEqual([0, 0, 0]);
+ });
+});
diff --git a/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx
index 4f2548f58..9eefd814d 100644
--- a/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx
+++ b/packages/shadcn/tests/agent-audio-visualizer-grid.test.tsx
@@ -1,6 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
-import { AgentAudioVisualizerGrid } from '@/components/agents-ui/agent-audio-visualizer-grid';
+import {
+ AgentAudioVisualizerGrid,
+ normalizeVolumeBands,
+} from '@/components/agents-ui/agent-audio-visualizer-grid';
import { useMultibandTrackVolume } from '@livekit/components-react';
import { useAgentAudioVisualizerGridAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-grid';
@@ -202,3 +205,25 @@ describe('AgentAudioVisualizerGrid', () => {
).toThrow('AgentAudioVisualizerGrid children must be a single element.');
});
});
+
+describe('normalizeVolumeBands', () => {
+ it('returns the array unchanged when the length already matches', () => {
+ expect(normalizeVolumeBands([0.1, 0.2, 0.3], 3)).toEqual([0.1, 0.2, 0.3]);
+ });
+
+ it('trims excess trailing values', () => {
+ expect(normalizeVolumeBands([0.1, 0.2, 0.3, 0.4], 2)).toEqual([0.1, 0.2]);
+ });
+
+ it('pads by duplicating the last value', () => {
+ expect(normalizeVolumeBands([0.1, 0.2], 4)).toEqual([0.1, 0.2, 0.2, 0.2]);
+ });
+
+ it('pads a single-element array by duplicating it', () => {
+ expect(normalizeVolumeBands([0.5], 3)).toEqual([0.5, 0.5, 0.5]);
+ });
+
+ it('pads an empty array with 0s', () => {
+ expect(normalizeVolumeBands([], 3)).toEqual([0, 0, 0]);
+ });
+});
diff --git a/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx
index 955f01097..2b4e2f642 100644
--- a/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx
+++ b/packages/shadcn/tests/agent-audio-visualizer-radial.test.tsx
@@ -1,6 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
-import { AgentAudioVisualizerRadial } from '@/components/agents-ui/agent-audio-visualizer-radial';
+import {
+ AgentAudioVisualizerRadial,
+ normalizeVolumeBands,
+} from '@/components/agents-ui/agent-audio-visualizer-radial';
import * as LiveKitComponents from '@livekit/components-react';
// Mock hooks
vi.mock('@livekit/components-react', async () => {
@@ -108,3 +111,25 @@ describe('AgentAudioVisualizerRadial', () => {
expect(bars[2]).not.toHaveStyle({ height: '0px' });
});
});
+
+describe('normalizeVolumeBands', () => {
+ it('returns the array unchanged when the length already matches', () => {
+ expect(normalizeVolumeBands([0.1, 0.2, 0.3], 3)).toEqual([0.1, 0.2, 0.3]);
+ });
+
+ it('trims excess trailing values', () => {
+ expect(normalizeVolumeBands([0.1, 0.2, 0.3, 0.4], 2)).toEqual([0.1, 0.2]);
+ });
+
+ it('pads by duplicating the last value', () => {
+ expect(normalizeVolumeBands([0.1, 0.2], 4)).toEqual([0.1, 0.2, 0.2, 0.2]);
+ });
+
+ it('pads a single-element array by duplicating it', () => {
+ expect(normalizeVolumeBands([0.5], 3)).toEqual([0.5, 0.5, 0.5]);
+ });
+
+ it('pads an empty array with 0s', () => {
+ expect(normalizeVolumeBands([], 3)).toEqual([0, 0, 0]);
+ });
+});
diff --git a/packages/shadcn/tsconfig.json b/packages/shadcn/tsconfig.json
index 47b54192e..656e2d593 100644
--- a/packages/shadcn/tsconfig.json
+++ b/packages/shadcn/tsconfig.json
@@ -9,6 +9,7 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
diff --git a/turbo.json b/turbo.json
index 8349aff41..967ca825b 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,5 +1,6 @@
{
"$schema": "https://turborepo.org/schema.json",
+ "ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
@@ -36,6 +37,10 @@
"cache": false,
"dependsOn": ["^build"]
},
- "api-check:update": {}
+ "api-check:update": {},
+ "shadcn:publish:all": {
+ "cache": false,
+ "interactive": true
+ }
}
}