From f7c1688edbee9a202cd3d03c31cb0c7e2f5b83c9 Mon Sep 17 00:00:00 2001 From: Francisco Moretti Date: Wed, 12 Nov 2025 11:05:52 +0000 Subject: [PATCH 1/5] Implement Sandbox component with collapsible sections and tabs; export getStatusBadge function for external use --- packages/elements/src/sandbox.tsx | 151 ++++++++++++++++++++++++++++++ packages/elements/src/tool.tsx | 2 +- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/elements/src/sandbox.tsx diff --git a/packages/elements/src/sandbox.tsx b/packages/elements/src/sandbox.tsx new file mode 100644 index 00000000..09ea6d5a --- /dev/null +++ b/packages/elements/src/sandbox.tsx @@ -0,0 +1,151 @@ +'use client'; + +import type { ToolUIPart } from 'ai'; +import { ChevronDownIcon, Code } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import type { BundledLanguage } from 'shiki'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; +import { CodeBlock, CodeBlockCopyButton } from './code-block'; +import { getStatusBadge } from './tool'; + +export type SandboxRootProps = ComponentProps; + +export const Sandbox = ({ className, ...props }: SandboxRootProps) => ( + +); + +export type SandboxHeaderProps = { + title?: string; + state: ToolUIPart['state']; + className?: string; +}; + +export const SandboxHeader = ({ + className, + title, + state, + ...props +}: SandboxHeaderProps) => ( + +
+ + {title} + {getStatusBadge(state)} +
+ +
+); + +export type SandboxContentProps = ComponentProps; + +export const SandboxContent = ({ + className, + ...props +}: SandboxContentProps) => ( + +); + +export type SandboxTabsProps = ComponentProps; + +export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => ( + +); + +export type SandboxTabsListProps = ComponentProps; + +export const SandboxTabsList = ({ + className, + ...props +}: SandboxTabsListProps) => ( +
+ +
+); + +export type SandboxTabsTriggerProps = ComponentProps; + +export const SandboxTabsTrigger = ({ + className, + ...props +}: SandboxTabsTriggerProps) => ( + +); + +export type SandboxCopyButtonProps = { + code: string; + className?: string; +}; + +export const SandboxCopyButton = ({ + code, + className, +}: SandboxCopyButtonProps) => ( +
+ navigator.clipboard.writeText(code)} + size="sm" + /> +
+); + +export type SandboxCodeProps = { + code: string; + language?: BundledLanguage; + className?: string; +}; + +export const SandboxCode = ({ + code, + language = 'tsx', + className, +}: SandboxCodeProps) => ( + + + +); + +export type SandboxOutputProps = { + output: string; + className?: string; +}; + +export const SandboxOutput = ({ output, className }: SandboxOutputProps) => ( + + + +); diff --git a/packages/elements/src/tool.tsx b/packages/elements/src/tool.tsx index 4c197e0e..bfa3e601 100644 --- a/packages/elements/src/tool.tsx +++ b/packages/elements/src/tool.tsx @@ -36,7 +36,7 @@ export type ToolHeaderProps = { className?: string; }; -const getStatusBadge = (status: ToolUIPart["state"]) => { +export const getStatusBadge = (status: ToolUIPart["state"]) => { const labels: Record = { "input-streaming": "Pending", "input-available": "Running", From 9bf776ac0096ed5a5550569585d65ca9603842ec Mon Sep 17 00:00:00 2001 From: Francisco Moretti Date: Thu, 27 Nov 2025 18:11:37 +0000 Subject: [PATCH 2/5] sandbox --- .github/CONTRIBUTING.md | 11 + .../docs/components/(chatbot)/sandbox.mdx | 223 ++++++ packages/elements/__tests__/sandbox.test.tsx | 660 ++++++++++++++++++ packages/elements/src/sandbox.tsx | 225 ++++-- packages/examples/src/sandbox.tsx | 106 +++ 5 files changed, 1169 insertions(+), 56 deletions(-) create mode 100644 apps/docs/content/docs/components/(chatbot)/sandbox.mdx create mode 100644 packages/elements/__tests__/sandbox.test.tsx create mode 100644 packages/examples/src/sandbox.tsx diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c9c68340..cbccc1aa 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -81,6 +81,15 @@ When adding code examples: - Use semantic HTML in components - Follow Tailwind CSS conventions for styling +### Adding a New Component + +When adding a new component to the library, follow these steps: + +1. **Create the component file** in `packages/elements/src/` +2. **Create example file(s)** in `packages/examples/src/` +3. **Create documentation** in `apps/docs/content/docs/components/` +4. **Wire up examples with Preview** + ## Pull Request Guidelines 1. **Ensure your PR has a clear purpose** @@ -174,6 +183,8 @@ When reporting issues: - Review the documentation at [ai-sdk.dev/elements](https://ai-sdk.dev/elements) - Reach out to maintainers if you need guidance + + ## Code of Conduct This project follows a Code of Conduct. By participating, you agree to uphold professional, respectful, and inclusive behavior. diff --git a/apps/docs/content/docs/components/(chatbot)/sandbox.mdx b/apps/docs/content/docs/components/(chatbot)/sandbox.mdx new file mode 100644 index 00000000..fb98c714 --- /dev/null +++ b/apps/docs/content/docs/components/(chatbot)/sandbox.mdx @@ -0,0 +1,223 @@ +--- +title: Sandbox +description: A collapsible container for displaying AI-generated code and output in chat interfaces. +path: elements/components/sandbox +--- + +The `Sandbox` component provides a structured way to display AI-generated code alongside its execution output in chat conversations. It features a collapsible container with status indicators and tabbed navigation between code and output views. + + + +## Installation + + + +## Features + +- Collapsible container with smooth animations +- Status badges showing execution state (Pending, Running, Completed, Error) +- Tabs for Code and Output views +- Syntax-highlighted code display +- Copy button for easy code sharing +- Works with AI SDK tool state patterns + +## Usage with AI SDK + +The Sandbox component integrates with the AI SDK's tool state to show code generation progress: + +```tsx title="components/code-sandbox.tsx" +"use client"; + +import type { ToolUIPart } from "ai"; +import { + Sandbox, + SandboxCode, + SandboxContent, + SandboxCopyButton, + SandboxHeader, + SandboxOutput, + SandboxTabContent, + SandboxTabs, + SandboxTabsBar, + SandboxTabsList, + SandboxTabsTrigger, +} from "@/components/ai-elements/sandbox"; + +type CodeSandboxProps = { + toolPart: ToolUIPart; +}; + +export const CodeSandbox = ({ toolPart }: CodeSandboxProps) => { + const code = toolPart.input?.code ?? ""; + const output = toolPart.output?.logs ?? ""; + + return ( + + + + + + + Code + Output + + + + + + + + + + + + + ); +}; +``` + +## Props + +### `` + +", + }, + }} +/> + +### `` + + + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +Automatically copies the content of the currently active tab. Must be used within `SandboxTabs`. + + + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +, 'language'>", + }, + }} +/> diff --git a/packages/elements/__tests__/sandbox.test.tsx b/packages/elements/__tests__/sandbox.test.tsx new file mode 100644 index 00000000..212943ae --- /dev/null +++ b/packages/elements/__tests__/sandbox.test.tsx @@ -0,0 +1,660 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { + Sandbox, + SandboxCode, + SandboxContent, + SandboxCopyButton, + SandboxHeader, + SandboxOutput, + SandboxTabContent, + SandboxTabs, + SandboxTabsBar, + SandboxTabsList, + SandboxTabsTrigger, +} from "../src/sandbox"; + +describe("Sandbox", () => { + it("renders children", () => { + render(Content); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render(Test); + expect(container.firstChild).toHaveClass("custom"); + }); + + it("is open by default", () => { + const { container } = render(Content); + expect(container.firstChild).toHaveAttribute("data-state", "open"); + }); + + it("has base styles", () => { + const { container } = render(Test); + expect(container.firstChild).toHaveClass("rounded-md"); + expect(container.firstChild).toHaveClass("border"); + }); +}); + +describe("SandboxHeader", () => { + it("renders title", () => { + render( + + + + ); + expect(screen.getByText("Code Sandbox")).toBeInTheDocument(); + }); + + it("shows pending status", () => { + render( + + + + ); + expect(screen.getByText("Pending")).toBeInTheDocument(); + }); + + it("shows running status", () => { + render( + + + + ); + expect(screen.getByText("Running")).toBeInTheDocument(); + }); + + it("shows completed status", () => { + render( + + + + ); + expect(screen.getByText("Completed")).toBeInTheDocument(); + }); + + it("shows error status", () => { + render( + + + + ); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("has code icon", () => { + const { container } = render( + + + + ); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render( + + + + ); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveClass("custom-header"); + }); + + it("toggles content on click", async () => { + const user = userEvent.setup(); + const { container } = render( + + + Hidden content + + ); + + expect(container.firstChild).toHaveAttribute("data-state", "open"); + + const trigger = screen.getByRole("button"); + await user.click(trigger); + + expect(container.firstChild).toHaveAttribute("data-state", "closed"); + }); +}); + +describe("SandboxContent", () => { + it("renders content", () => { + render( + + + Sandbox details + + ); + expect(screen.getByText("Sandbox details")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render( + + Content + + ); + const content = screen.getByText("Content").closest("[class*='custom']"); + expect(content).toHaveClass("custom-content"); + }); +}); + +describe("SandboxTabs", () => { + it("renders tabs", () => { + render( + + + + + + Code + Output + + + + + + ); + expect(screen.getByText("Code")).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + }); + + it("switches tabs on click", async () => { + const user = userEvent.setup(); + render( + + + + + + Code + Output + + + Code content + Output content + + + + ); + + expect(screen.getByText("Code content")).toBeInTheDocument(); + + await user.click(screen.getByText("Output")); + + expect(screen.getByText("Output content")).toBeInTheDocument(); + }); + + it("calls onValueChange when tab changes", async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + Code + Output + + + + + + ); + + await user.click(screen.getByText("Output")); + + expect(onValueChange).toHaveBeenCalledWith("output"); + }); + + it("applies custom className", () => { + const { container } = render( + + + + + + Code + + + + + + ); + expect(container.querySelector(".custom-tabs")).toBeInTheDocument(); + }); +}); + +describe("SandboxTabsBar", () => { + it("renders children", () => { + render( + + Bar content + + ); + expect(screen.getByText("Bar content")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + Content + ); + expect(container.firstChild).toHaveClass("custom-bar"); + expect(container.firstChild).toHaveClass("border-t"); + expect(container.firstChild).toHaveClass("border-b"); + }); +}); + +describe("SandboxTabsList", () => { + it("applies custom className", () => { + render( + + + + + + Code + + + + + + ); + const list = screen.getByRole("tablist"); + expect(list).toHaveClass("custom-list"); + expect(list).toHaveClass("bg-transparent"); + }); +}); + +describe("SandboxTabsTrigger", () => { + it("applies custom className", () => { + render( + + + + + + + Code + + + + + + + ); + const trigger = screen.getByRole("tab"); + expect(trigger).toHaveClass("custom-trigger"); + }); + + it("shows active state", () => { + render( + + + + + + Code + + + + + + ); + const trigger = screen.getByRole("tab"); + expect(trigger).toHaveAttribute("data-state", "active"); + }); +}); + +describe("SandboxCopyButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders copy button", () => { + render( + + + + + + Code + + + + + + + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("copies active tab content to clipboard", async () => { + const user = userEvent.setup(); + const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); + + render( + + + + + + Code + + + + + + + + + + ); + + const button = screen.getByRole("button"); + await user.click(button); + + expect(writeTextSpy).toHaveBeenCalledWith("const x = 1;"); + }); + + it("applies custom className", () => { + render( + + + + + + Code + + + + + + + ); + const wrapper = screen.getByRole("button").parentElement; + expect(wrapper).toHaveClass("custom-copy"); + }); +}); + +describe("SandboxTabContent", () => { + it("renders content for active tab", () => { + render( + + + + + + Code + + + Tab content + + + + ); + expect(screen.getByText("Tab content")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + render( + + + + + + Code + + + + Content + + + + + ); + const tabPanel = screen.getByRole("tabpanel"); + expect(tabPanel).toHaveClass("custom-content"); + }); +}); + +describe("SandboxCode", () => { + it("renders code block", async () => { + const { container } = render( + + + + + + Code + + + + + + + + + ); + await waitFor(() => { + expect(container.textContent).toContain("console"); + }); + }); + + it("registers content with tabs context", async () => { + const user = userEvent.setup(); + const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); + + render( + + + + + + Code + Output + + + + + + + + + + + + + ); + + // Copy code tab + const copyButton = screen.getByRole("button"); + await user.click(copyButton); + expect(writeTextSpy).toHaveBeenCalledWith("const x = 1;"); + + // Switch to output tab and copy + await user.click(screen.getByText("Output")); + await user.click(copyButton); + expect(writeTextSpy).toHaveBeenCalledWith("Result: 1"); + }); + + it("applies custom className", async () => { + const { container } = render( + + + + + + Code + + + + + + + + + ); + await waitFor(() => { + expect(container.querySelector(".custom-code")).toBeInTheDocument(); + }); + }); +}); + +describe("SandboxOutput", () => { + it("renders output as log language", async () => { + const { container } = render( + + + + + + Output + + + + + + + + + ); + await waitFor(() => { + expect(container.textContent).toContain("Hello, World!"); + }); + }); + + it("registers content with tabs context", async () => { + const user = userEvent.setup(); + const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); + + render( + + + + + + Output + + + + + + + + + + ); + + const copyButton = screen.getByRole("button"); + await user.click(copyButton); + expect(writeTextSpy).toHaveBeenCalledWith("Output text"); + }); + + it("applies custom className", async () => { + const { container } = render( + + + + + + Output + + + + + + + + + ); + await waitFor(() => { + expect(container.querySelector(".custom-output")).toBeInTheDocument(); + }); + }); +}); + +describe("Sandbox integration", () => { + it("renders complete sandbox with code and output tabs", async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + + + Code + Output + + + + + + + + + + + + + ); + + // Check header + expect(screen.getByText("Python Sandbox")).toBeInTheDocument(); + expect(screen.getByText("Completed")).toBeInTheDocument(); + + // Check tabs + expect(screen.getByText("Code")).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + + // Check code content + await waitFor(() => { + expect(container.textContent).toContain("print"); + }); + + // Switch to output + await user.click(screen.getByText("Output")); + await waitFor(() => { + expect(container.textContent).toContain("Hello"); + }); + }); + + it("can be controlled externally", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + render( + + + + + + Code + Output + + + Code content + Output content + + + + ); + + await user.click(screen.getByText("Output")); + expect(onValueChange).toHaveBeenCalledWith("output"); + }); +}); diff --git a/packages/elements/src/sandbox.tsx b/packages/elements/src/sandbox.tsx index 09ea6d5a..6d5b1b9a 100644 --- a/packages/elements/src/sandbox.tsx +++ b/packages/elements/src/sandbox.tsx @@ -1,24 +1,45 @@ -'use client'; +"use client"; -import type { ToolUIPart } from 'ai'; -import { ChevronDownIcon, Code } from 'lucide-react'; -import type { ComponentProps } from 'react'; -import type { BundledLanguage } from 'shiki'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { cn } from '@/lib/utils'; -import { CodeBlock, CodeBlockCopyButton } from './code-block'; -import { getStatusBadge } from './tool'; +} from "@repo/shadcn-ui/components/ui/collapsible"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@repo/shadcn-ui/components/ui/tabs"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { ToolUIPart } from "ai"; +import { ChevronDownIcon, Code } from "lucide-react"; +import { + type ComponentProps, + createContext, + type MutableRefObject, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { CodeBlock, CodeBlockCopyButton } from "./code-block"; +import { getStatusBadge } from "./tool"; + +type SandboxTabsContextValue = { + activeTab: string; + contents: MutableRefObject>; + registerContent: (tab: string, content: string) => void; +}; + +const SandboxTabsContext = createContext(null); +const SandboxTabContentContext = createContext(null); export type SandboxRootProps = ComponentProps; export const Sandbox = ({ className, ...props }: SandboxRootProps) => ( @@ -26,7 +47,7 @@ export const Sandbox = ({ className, ...props }: SandboxRootProps) => ( export type SandboxHeaderProps = { title?: string; - state: ToolUIPart['state']; + state: ToolUIPart["state"]; className?: string; }; @@ -38,7 +59,7 @@ export const SandboxHeader = ({ }: SandboxHeaderProps) => ( ( ; -export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => ( - +export const SandboxTabs = ({ + className, + defaultValue, + value, + onValueChange, + ...props +}: SandboxTabsProps) => { + const [internalActiveTab, setInternalActiveTab] = useState( + defaultValue ?? value ?? "" + ); + const contentsRef = useRef(new Map()); + + const activeTab = value ?? internalActiveTab; + + const registerContent = (tab: string, content: string) => { + contentsRef.current.set(tab, content); + }; + + const handleValueChange = (newValue: string) => { + setInternalActiveTab(newValue); + onValueChange?.(newValue); + }; + + return ( + + + + ); +}; + +export type SandboxTabsBarProps = ComponentProps<"div">; + +export const SandboxTabsBar = ({ + className, + ...props +}: SandboxTabsBarProps) => ( +
); export type SandboxTabsListProps = ComponentProps; @@ -79,15 +149,10 @@ export const SandboxTabsList = ({ className, ...props }: SandboxTabsListProps) => ( -
- -
+ ); export type SandboxTabsTriggerProps = ComponentProps; @@ -98,7 +163,7 @@ export const SandboxTabsTrigger = ({ }: SandboxTabsTriggerProps) => ( { + const ctx = useContext(SandboxTabsContext); + + const handleCopy = () => { + if (ctx) { + const content = ctx.contents.current.get(ctx.activeTab) ?? ""; + navigator.clipboard.writeText(content); + } + }; + + return ( +
+ +
+ ); +}; + +export type SandboxTabContentProps = ComponentProps; + +export const SandboxTabContent = ({ className, -}: SandboxCopyButtonProps) => ( -
- navigator.clipboard.writeText(code)} - size="sm" + value, + ...props +}: SandboxTabContentProps) => ( + + -
+ ); -export type SandboxCodeProps = { - code: string; - language?: BundledLanguage; - className?: string; -}; +export type SandboxCodeProps = ComponentProps; export const SandboxCode = ({ - code, - language = 'tsx', className, -}: SandboxCodeProps) => ( - - - -); + code, + ...props +}: SandboxCodeProps) => { + const tabsCtx = useContext(SandboxTabsContext); + const tabValue = useContext(SandboxTabContentContext); -export type SandboxOutputProps = { - output: string; - className?: string; + useEffect(() => { + if (tabsCtx && tabValue && code) { + tabsCtx.registerContent(tabValue, code); + } + }, [tabsCtx, tabValue, code]); + + return ( + + ); }; -export const SandboxOutput = ({ output, className }: SandboxOutputProps) => ( - - - -); +export type SandboxOutputProps = Omit< + ComponentProps, + "language" +>; + +export const SandboxOutput = ({ + className, + code, + ...props +}: SandboxOutputProps) => { + const tabsCtx = useContext(SandboxTabsContext); + const tabValue = useContext(SandboxTabContentContext); + + useEffect(() => { + if (tabsCtx && tabValue && code) { + tabsCtx.registerContent(tabValue, code); + } + }, [tabsCtx, tabValue, code]); + + return ( + + ); +}; diff --git a/packages/examples/src/sandbox.tsx b/packages/examples/src/sandbox.tsx new file mode 100644 index 00000000..2b816500 --- /dev/null +++ b/packages/examples/src/sandbox.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + Sandbox, + SandboxCode, + SandboxContent, + SandboxCopyButton, + SandboxHeader, + SandboxOutput, + SandboxTabContent, + SandboxTabs, + SandboxTabsBar, + SandboxTabsList, + SandboxTabsTrigger, +} from "@repo/elements/sandbox"; +import { Button } from "@repo/shadcn-ui/components/ui/button"; +import type { ToolUIPart } from "ai"; +import { useState } from "react"; + +const code = `import math + +def calculate_primes(limit): + """Find all prime numbers up to a given limit using Sieve of Eratosthenes.""" + sieve = [True] * (limit + 1) + sieve[0] = sieve[1] = False + + for i in range(2, int(math.sqrt(limit)) + 1): + if sieve[i]: + for j in range(i * i, limit + 1, i): + sieve[j] = False + + return [i for i, is_prime in enumerate(sieve) if is_prime] + +if __name__ == "__main__": + primes = calculate_primes(50) + print(f"Found {len(primes)} prime numbers up to 50:") + print(primes)`; + +const outputs: Record = { + "input-streaming": undefined, + "input-available": undefined, + "output-available": `Found 15 prime numbers up to 50: +[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]`, + "output-error": `Traceback (most recent call last): + File "primes.py", line 15, in + primes = calculate_primes(50) + File "primes.py", line 4, in calculate_primes + sieve = [True] * (limit + 1) +TypeError: can only concatenate str (not "int") to str`, +}; + +const states: ToolUIPart["state"][] = [ + "input-streaming", + "input-available", + "output-available", + "output-error", +]; + +const Example = () => { + const [state, setState] = useState("output-available"); + + return ( +
+
+ {states.map((s) => ( + + ))} +
+ + + + + + + + Code + Output + + + + + + + + + + + + +
+ ); +}; + +export default Example; From 9e38a459e016a5ce9fc9fb7acb3e5ca5aaff2a02 Mon Sep 17 00:00:00 2001 From: Francisco Moretti Date: Thu, 27 Nov 2025 18:12:29 +0000 Subject: [PATCH 3/5] feat: add new Sandbox component --- .changeset/three-bees-follow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/three-bees-follow.md diff --git a/.changeset/three-bees-follow.md b/.changeset/three-bees-follow.md new file mode 100644 index 00000000..eb45b6ae --- /dev/null +++ b/.changeset/three-bees-follow.md @@ -0,0 +1,5 @@ +--- +"ai-elements": patch +--- + +Added Sandbox componet From 792109c3de3de953444322e8dc5db091d69f60aa Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Thu, 27 Nov 2025 12:31:48 -0800 Subject: [PATCH 4/5] Update packages/elements/src/sandbox.tsx Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- packages/elements/src/sandbox.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/elements/src/sandbox.tsx b/packages/elements/src/sandbox.tsx index 6d5b1b9a..a62f74cc 100644 --- a/packages/elements/src/sandbox.tsx +++ b/packages/elements/src/sandbox.tsx @@ -177,10 +177,18 @@ export type SandboxCopyButtonProps = { export const SandboxCopyButton = ({ className }: SandboxCopyButtonProps) => { const ctx = useContext(SandboxTabsContext); - const handleCopy = () => { - if (ctx) { + const handleCopy = async () => { + if ( + ctx && + typeof window !== "undefined" && + navigator?.clipboard?.writeText + ) { const content = ctx.contents.current.get(ctx.activeTab) ?? ""; - navigator.clipboard.writeText(content); + try { + await navigator.clipboard.writeText(content); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + } } }; From ce781de4175f5fe50011ba513a6e771d8dcb168a Mon Sep 17 00:00:00 2001 From: Francisco Moretti Date: Tue, 9 Dec 2025 09:54:23 +0000 Subject: [PATCH 5/5] Refactor Sandbox component: remove SandboxCopyButton and integrate copy functionality into SandboxCode and SandboxOutput components. Update related tests and documentation accordingly. --- .../docs/components/(chatbot)/sandbox.mdx | 19 +-- packages/elements/__tests__/sandbox.test.tsx | 128 +++++--------- packages/elements/src/sandbox.tsx | 158 +++--------------- packages/examples/src/sandbox.tsx | 2 - 4 files changed, 66 insertions(+), 241 deletions(-) diff --git a/apps/docs/content/docs/components/(chatbot)/sandbox.mdx b/apps/docs/content/docs/components/(chatbot)/sandbox.mdx index fb98c714..69c9bb03 100644 --- a/apps/docs/content/docs/components/(chatbot)/sandbox.mdx +++ b/apps/docs/content/docs/components/(chatbot)/sandbox.mdx @@ -33,7 +33,6 @@ import { Sandbox, SandboxCode, SandboxContent, - SandboxCopyButton, SandboxHeader, SandboxOutput, SandboxTabContent, @@ -64,7 +63,6 @@ export const CodeSandbox = ({ toolPart }: CodeSandboxProps) => { Code Output - @@ -173,19 +171,6 @@ export const CodeSandbox = ({ toolPart }: CodeSandboxProps) => { }} /> -### `` - -Automatically copies the content of the currently active tab. Must be used within `SandboxTabs`. - - - ### `` ` +Renders a syntax-highlighted code block with a built-in copy button. + ` +Renders output with log syntax highlighting and a built-in copy button. + { }); }); -describe("SandboxCopyButton", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders copy button", () => { - render( - - - - - - Code - - - - - - - ); - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - - it("copies active tab content to clipboard", async () => { - const user = userEvent.setup(); - const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); - - render( - - - - - - Code - - - - - - - - - - ); - - const button = screen.getByRole("button"); - await user.click(button); - - expect(writeTextSpy).toHaveBeenCalledWith("const x = 1;"); - }); - - it("applies custom className", () => { - render( - - - - - - Code - - - - - - - ); - const wrapper = screen.getByRole("button").parentElement; - expect(wrapper).toHaveClass("custom-copy"); - }); -}); describe("SandboxTabContent", () => { it("renders content for active tab", () => { @@ -450,7 +379,27 @@ describe("SandboxCode", () => { }); }); - it("registers content with tabs context", async () => { + it("includes copy button", () => { + render( + + + + + + Code + + + + + + + + + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("copies code to clipboard", async () => { const user = userEvent.setup(); const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); @@ -461,30 +410,19 @@ describe("SandboxCode", () => { Code - Output - - - - ); - // Copy code tab const copyButton = screen.getByRole("button"); await user.click(copyButton); expect(writeTextSpy).toHaveBeenCalledWith("const x = 1;"); - - // Switch to output tab and copy - await user.click(screen.getByText("Output")); - await user.click(copyButton); - expect(writeTextSpy).toHaveBeenCalledWith("Result: 1"); }); it("applies custom className", async () => { @@ -537,7 +475,27 @@ describe("SandboxOutput", () => { }); }); - it("registers content with tabs context", async () => { + it("includes copy button", () => { + render( + + + + + + Output + + + + + + + + + ); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("copies output to clipboard", async () => { const user = userEvent.setup(); const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText"); @@ -549,7 +507,6 @@ describe("SandboxOutput", () => { Output - @@ -600,7 +557,6 @@ describe("Sandbox integration", () => { Code Output - diff --git a/packages/elements/src/sandbox.tsx b/packages/elements/src/sandbox.tsx index a62f74cc..6cf68e79 100644 --- a/packages/elements/src/sandbox.tsx +++ b/packages/elements/src/sandbox.tsx @@ -14,27 +14,10 @@ import { import { cn } from "@repo/shadcn-ui/lib/utils"; import type { ToolUIPart } from "ai"; import { ChevronDownIcon, Code } from "lucide-react"; -import { - type ComponentProps, - createContext, - type MutableRefObject, - useContext, - useEffect, - useRef, - useState, -} from "react"; +import type { ComponentProps } from "react"; import { CodeBlock, CodeBlockCopyButton } from "./code-block"; import { getStatusBadge } from "./tool"; -type SandboxTabsContextValue = { - activeTab: string; - contents: MutableRefObject>; - registerContent: (tab: string, content: string) => void; -}; - -const SandboxTabsContext = createContext(null); -const SandboxTabContentContext = createContext(null); - export type SandboxRootProps = ComponentProps; export const Sandbox = ({ className, ...props }: SandboxRootProps) => ( @@ -90,43 +73,9 @@ export const SandboxContent = ({ export type SandboxTabsProps = ComponentProps; -export const SandboxTabs = ({ - className, - defaultValue, - value, - onValueChange, - ...props -}: SandboxTabsProps) => { - const [internalActiveTab, setInternalActiveTab] = useState( - defaultValue ?? value ?? "" - ); - const contentsRef = useRef(new Map()); - - const activeTab = value ?? internalActiveTab; - - const registerContent = (tab: string, content: string) => { - contentsRef.current.set(tab, content); - }; - - const handleValueChange = (newValue: string) => { - setInternalActiveTab(newValue); - onValueChange?.(newValue); - }; - - return ( - - - - ); -}; +export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => ( + +); export type SandboxTabsBarProps = ComponentProps<"div">; @@ -170,103 +119,36 @@ export const SandboxTabsTrigger = ({ /> ); -export type SandboxCopyButtonProps = { - className?: string; -}; - -export const SandboxCopyButton = ({ className }: SandboxCopyButtonProps) => { - const ctx = useContext(SandboxTabsContext); - - const handleCopy = async () => { - if ( - ctx && - typeof window !== "undefined" && - navigator?.clipboard?.writeText - ) { - const content = ctx.contents.current.get(ctx.activeTab) ?? ""; - try { - await navigator.clipboard.writeText(content); - } catch (error) { - console.error("Failed to copy to clipboard:", error); - } - } - }; - - return ( -
- -
- ); -}; - export type SandboxTabContentProps = ComponentProps; export const SandboxTabContent = ({ className, - value, ...props }: SandboxTabContentProps) => ( - - - + ); export type SandboxCodeProps = ComponentProps; -export const SandboxCode = ({ - className, - code, - ...props -}: SandboxCodeProps) => { - const tabsCtx = useContext(SandboxTabsContext); - const tabValue = useContext(SandboxTabContentContext); - - useEffect(() => { - if (tabsCtx && tabValue && code) { - tabsCtx.registerContent(tabValue, code); - } - }, [tabsCtx, tabValue, code]); - - return ( - - ); -}; +export const SandboxCode = ({ className, ...props }: SandboxCodeProps) => ( + + + +); export type SandboxOutputProps = Omit< ComponentProps, "language" >; -export const SandboxOutput = ({ - className, - code, - ...props -}: SandboxOutputProps) => { - const tabsCtx = useContext(SandboxTabsContext); - const tabValue = useContext(SandboxTabContentContext); - - useEffect(() => { - if (tabsCtx && tabValue && code) { - tabsCtx.registerContent(tabValue, code); - } - }, [tabsCtx, tabValue, code]); - - return ( - ( + + - ); -}; + +); diff --git a/packages/examples/src/sandbox.tsx b/packages/examples/src/sandbox.tsx index 2b816500..dfd17d6c 100644 --- a/packages/examples/src/sandbox.tsx +++ b/packages/examples/src/sandbox.tsx @@ -4,7 +4,6 @@ import { Sandbox, SandboxCode, SandboxContent, - SandboxCopyButton, SandboxHeader, SandboxOutput, SandboxTabContent, @@ -83,7 +82,6 @@ const Example = () => { Code Output -