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 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..69c9bb03 --- /dev/null +++ b/apps/docs/content/docs/components/(chatbot)/sandbox.mdx @@ -0,0 +1,212 @@ +--- +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, + 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 + +### `` + +", + }, + }} +/> + +### `` + + + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +", + }, + }} +/> + +### `` + +Renders a syntax-highlighted code block with a built-in copy button. + +", + }, + }} +/> + +### `` + +Renders output with log syntax highlighting and a built-in copy button. + +, 'language'>", + }, + }} +/> diff --git a/packages/elements/__tests__/sandbox.test.tsx b/packages/elements/__tests__/sandbox.test.tsx new file mode 100644 index 00000000..2819041f --- /dev/null +++ b/packages/elements/__tests__/sandbox.test.tsx @@ -0,0 +1,616 @@ +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, + 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("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("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"); + + render( + + + + + + Code + + + + + + + + + ); + + const copyButton = screen.getByRole("button"); + await user.click(copyButton); + expect(writeTextSpy).toHaveBeenCalledWith("const x = 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("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"); + + 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 new file mode 100644 index 00000000..6cf68e79 --- /dev/null +++ b/packages/elements/src/sandbox.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} 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 } from "react"; +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 SandboxTabsBarProps = ComponentProps<"div">; + +export const SandboxTabsBar = ({ + className, + ...props +}: SandboxTabsBarProps) => ( + +); + +export type SandboxTabsListProps = ComponentProps; + +export const SandboxTabsList = ({ + className, + ...props +}: SandboxTabsListProps) => ( + +); + +export type SandboxTabsTriggerProps = ComponentProps; + +export const SandboxTabsTrigger = ({ + className, + ...props +}: SandboxTabsTriggerProps) => ( + +); + +export type SandboxTabContentProps = ComponentProps; + +export const SandboxTabContent = ({ + className, + ...props +}: SandboxTabContentProps) => ( + +); + +export type SandboxCodeProps = ComponentProps; + +export const SandboxCode = ({ className, ...props }: SandboxCodeProps) => ( + + + +); + +export type SandboxOutputProps = Omit< + ComponentProps, + "language" +>; + +export const SandboxOutput = ({ className, ...props }: 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", diff --git a/packages/examples/src/sandbox.tsx b/packages/examples/src/sandbox.tsx new file mode 100644 index 00000000..dfd17d6c --- /dev/null +++ b/packages/examples/src/sandbox.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { + Sandbox, + SandboxCode, + SandboxContent, + 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) => ( + setState(s)} + size="sm" + variant={state === s ? "default" : "outline"} + > + {s} + + ))} + + + + + + + + + Code + Output + + + + + + + + + + + + + ); +}; + +export default Example;