diff --git a/web/components.json b/web/components.json index 82d44be..b1736bb 100644 --- a/web/components.json +++ b/web/components.json @@ -9,12 +9,15 @@ "baseColor": "stone", "cssVariables": true }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", + "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", - "hooks": "@/hooks", - "utils": "@/lib/utils" + "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + } } diff --git a/web/components/ai-elements/artifact.spec.tsx b/web/components/ai-elements/artifact.spec.tsx new file mode 100644 index 0000000..224526b --- /dev/null +++ b/web/components/ai-elements/artifact.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +jest.mock("@/components/ui/tooltip", () => ({ + __esModule: true, + TooltipProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + Tooltip: ({ + children, + content, + }: { + children: React.ReactNode; + content: React.ReactNode; + }) => ( +
+ {children} +
{content}
+
+ ), + TooltipTrigger: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => ( +
{children}
+ ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { + Artifact, + ArtifactAction, + ArtifactActions, + ArtifactClose, + ArtifactContent, + ArtifactHeader, + ArtifactTitle, +} from "./artifact"; + +describe("Artifact", () => { + it("renders header, content, and close affordance", () => { + render( + + + Preview artifact + + + + + content area + + ); + + expect(screen.getByTestId("artifact")).toHaveClass("custom-artifact"); + expect(screen.getByText("Preview artifact")).toBeInTheDocument(); + expect(screen.getByText("content area")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Close/i })).toBeInTheDocument(); + }); + + it("supports tooltip-backed actions and forwards clicks", async () => { + const onClick = jest.fn(); + + render( + + + Copy + + + ); + + const action = screen.getByRole("button", { name: /copy/i }); + expect(screen.getByText("Copy artifact")).toBeInTheDocument(); + + await userEvent.click(action); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/web/components/ai-elements/artifact.stories.tsx b/web/components/ai-elements/artifact.stories.tsx new file mode 100644 index 0000000..3bd7fc9 --- /dev/null +++ b/web/components/ai-elements/artifact.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Artifact, ArtifactAction, ArtifactActions, ArtifactClose, ArtifactContent, ArtifactDescription, ArtifactHeader, ArtifactTitle } from "./artifact"; +import { Image } from "./image"; + +const meta = { + title: "AI Elements/Artifact", + component: Artifact, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const pixelBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HwAF/gL+I3VI4QAAAABJRU5ErkJggg=="; +const pixelBytes = new Uint8Array([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, + 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, + 120, 218, 99, 252, 207, 240, 191, 31, 0, 5, 254, 2, 254, 35, 117, 72, 225, + 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, +]); + +export const Preview: Story = { + render: () => ( + + +
+ Generated chart + + Exported from the reasoning engine as a PNG. + +
+ + + + +
+ + Pixel preview + +
+ ), +}; diff --git a/web/components/ai-elements/artifact.tsx b/web/components/ai-elements/artifact.tsx new file mode 100644 index 0000000..76a17fb --- /dev/null +++ b/web/components/ai-elements/artifact.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Tooltip } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { type LucideIcon, XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const accessibleLabel = label ?? tooltip; + const button = ( + + ); + + if (tooltip) { + return ( + {button} + ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/web/components/ai-elements/canvas.spec.tsx b/web/components/ai-elements/canvas.spec.tsx new file mode 100644 index 0000000..2ced740 --- /dev/null +++ b/web/components/ai-elements/canvas.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import type { ReactFlowProps } from "@xyflow/react"; + +const mockReactFlow = jest.fn( + ({ children }: ReactFlowProps & ComponentProps<"div">) => ( +
{children}
+ ) +); +const mockBackground = jest.fn(() =>
); + +jest.mock("@xyflow/react", () => ({ + __esModule: true, + ReactFlow: (props: ReactFlowProps & ComponentProps<"div">) => + mockReactFlow(props), + Background: (props: ComponentProps<"div">) => mockBackground(props), +})); + +import { Canvas } from "./canvas"; + +describe("Canvas", () => { + beforeEach(() => { + mockReactFlow.mockClear(); + mockBackground.mockClear(); + }); + + it("renders background and forwards default ReactFlow props", () => { + render( + +
child
+
+ ); + + expect(screen.getByTestId("flow-background")).toBeInTheDocument(); + + expect(mockReactFlow).toHaveBeenCalledWith( + expect.objectContaining({ + deleteKeyCode: ["Backspace", "Delete"], + fitView: true, + panOnDrag: false, + panOnScroll: true, + selectionOnDrag: true, + zoomOnDoubleClick: false, + nodesDraggable: false, + children: expect.anything(), + "data-id": "canvas", + }) + ); + }); +}); diff --git a/web/components/ai-elements/canvas.tsx b/web/components/ai-elements/canvas.tsx new file mode 100644 index 0000000..5aa83cb --- /dev/null +++ b/web/components/ai-elements/canvas.tsx @@ -0,0 +1,22 @@ +import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; +import type { ReactNode } from "react"; +import "@xyflow/react/dist/style.css"; + +type CanvasProps = ReactFlowProps & { + children?: ReactNode; +}; + +export const Canvas = ({ children, ...props }: CanvasProps) => ( + + + {children} + +); diff --git a/web/components/ai-elements/chain-of-thought.spec.tsx b/web/components/ai-elements/chain-of-thought.spec.tsx new file mode 100644 index 0000000..26a6ea2 --- /dev/null +++ b/web/components/ai-elements/chain-of-thought.spec.tsx @@ -0,0 +1,57 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { + ChainOfThought, + ChainOfThoughtContent, + ChainOfThoughtHeader, + ChainOfThoughtImage, + ChainOfThoughtSearchResult, + ChainOfThoughtSearchResults, + ChainOfThoughtStep, +} from "./chain-of-thought"; + +describe("ChainOfThought", () => { + it("toggles visibility when the header is activated", async () => { + render( + + Show chain + + + + Search result + + + + + ); + + const trigger = screen.getByText("Show chain"); + expect(screen.queryByText("First reasoning step")).toBeNull(); + + await userEvent.click(trigger); + await waitFor(() => { + expect(screen.getByText("First reasoning step")).toBeVisible(); + }); + expect(screen.getByText("Search result")).toBeVisible(); + }); + + it("renders media and caption for images inside steps", () => { + render( + + + +
diagram
+
+
+
+ ); + + expect(screen.getByText("diagram")).toBeInTheDocument(); + expect(screen.getByText("Supporting evidence")).toBeInTheDocument(); + }); +}); diff --git a/web/components/ai-elements/chain-of-thought.tsx b/web/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000..457186f --- /dev/null +++ b/web/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought" + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = useMemo( + () => ({ isOpen, setIsOpen }), + [isOpen, setIsOpen] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen, setIsOpen } = useChainOfThought(); + + return ( + + + + + {children ?? "Chain of Thought"} + + + + + ); + } +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => { + const statusStyles = { + complete: "text-muted-foreground", + active: "text-foreground", + pending: "text-muted-foreground/50", + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); + } +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ) +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ) +); + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children} + + + ); + } +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ) +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/web/components/ai-elements/checkpoint.spec.tsx b/web/components/ai-elements/checkpoint.spec.tsx new file mode 100644 index 0000000..4d67d4a --- /dev/null +++ b/web/components/ai-elements/checkpoint.spec.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +jest.mock("@/components/ui/tooltip", () => ({ + __esModule: true, + Tooltip: ({ + children, + content, + }: { + children: React.ReactNode; + content: React.ReactNode; + }) => ( +
+ {children} +
{content}
+
+ ), + TooltipTrigger: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => + _asChild && React.isValidElement(children) ? ( + React.cloneElement(children, props) + ) : ( +
{children}
+ ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { + Checkpoint, + CheckpointIcon, + CheckpointTrigger, +} from "./checkpoint"; + +describe("Checkpoint", () => { + it("renders indicator with default icon and separator", () => { + const { container } = render( + + + bookmark + + ); + + expect(screen.getByText("bookmark")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.querySelector('[data-slot="separator"]')).toBeInTheDocument(); + }); + + it("shows tooltip text and bubbles clicks through the trigger", async () => { + const onClick = jest.fn(); + + render( + + Save + + ); + + expect(screen.getByText("Save progress")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/web/components/ai-elements/checkpoint.tsx b/web/components/ai-elements/checkpoint.tsx new file mode 100644 index 0000000..d226f50 --- /dev/null +++ b/web/components/ai-elements/checkpoint.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Tooltip } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { BookmarkIcon, type LucideProps } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type CheckpointProps = HTMLAttributes; + +export const Checkpoint = ({ + className, + children, + ...props +}: CheckpointProps) => ( +
+ {children} + +
+); + +export type CheckpointIconProps = LucideProps; + +export const CheckpointIcon = ({ + className, + children, + ...props +}: CheckpointIconProps) => + children ?? ( + + ); + +export type CheckpointTriggerProps = ComponentProps & { + tooltip?: string; +}; + +export const CheckpointTrigger = ({ + children, + className, + variant = "ghost", + size = "sm", + tooltip, + ...props +}: CheckpointTriggerProps) => + tooltip ? ( + + + + ) : ( + + ); diff --git a/web/components/ai-elements/code-block.spec.tsx b/web/components/ai-elements/code-block.spec.tsx new file mode 100644 index 0000000..ce1ebd0 --- /dev/null +++ b/web/components/ai-elements/code-block.spec.tsx @@ -0,0 +1,79 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { CodeBlock, CodeBlockCopyButton } from "./code-block"; + +jest.mock("shiki", () => ({ + codeToHtml: jest.fn(async (code: string) => `
${code}
`), +})); + +const originalClipboard = navigator.clipboard; + +afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + writable: true, + }); +}); + +describe("CodeBlock", () => { + it("renders highlighted code in both themes", async () => { + const { container } = render( + + ); + + await waitFor(() => { + expect(container.querySelectorAll("pre").length).toBeGreaterThan(0); + }); + }); + + it("copies code to clipboard and calls callbacks", async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + writable: true, + }); + const onCopy = jest.fn(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getAllByTestId("shiki").length).toBeGreaterThan(0); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + + expect(writeText).toHaveBeenCalledWith("console.log('copy');"); + await waitFor(() => expect(onCopy).toHaveBeenCalled()); + }); + + it("reports errors when the clipboard API is unavailable", async () => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + writable: true, + }); + const onError = jest.fn(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getAllByTestId("shiki").length).toBeGreaterThan(0); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button")); + }); + await waitFor(() => expect(onError).toHaveBeenCalled()); + }); +}); diff --git a/web/components/ai-elements/code-block.stories.tsx b/web/components/ai-elements/code-block.stories.tsx new file mode 100644 index 0000000..93a8627 --- /dev/null +++ b/web/components/ai-elements/code-block.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { CodeBlock, CodeBlockCopyButton } from "./code-block"; + +const sampleCode = `async function getUser(id: string) { + const response = await fetch(\`/api/users/\${id}\`); + if (!response.ok) throw new Error("Request failed"); + return response.json(); +}`; + +const meta = { + title: "AI Elements/Code Block", + component: CodeBlock, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + args: { + code: sampleCode, + language: "typescript", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: args => ( + + + + ), +}; + +export const WithLineNumbers: Story = { + args: { + showLineNumbers: true, + }, + render: args => ( + + + + ), +}; diff --git a/web/components/ai-elements/code-block.tsx b/web/components/ai-elements/code-block.tsx new file mode 100644 index 0000000..6fce420 --- /dev/null +++ b/web/components/ai-elements/code-block.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + type HTMLAttributes, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; + +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +type CodeBlockContextType = { + code: string; +}; + +const CodeBlockContext = createContext({ + code: "", +}); + +const lineNumberTransformer: ShikiTransformer = { + name: "line-numbers", + line(node, line) { + node.children.unshift({ + type: "element", + tagName: "span", + properties: { + className: [ + "inline-block", + "min-w-10", + "mr-4", + "text-right", + "select-none", + "text-muted-foreground", + ], + }, + children: [{ type: "text", value: String(line) }], + }); + }, +}; + +export async function highlightCode( + code: string, + language: BundledLanguage, + showLineNumbers = false +) { + const transformers: ShikiTransformer[] = showLineNumbers + ? [lineNumberTransformer] + : []; + + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: "one-light", + transformers, + }), + codeToHtml(code, { + lang: language, + theme: "one-dark-pro", + transformers, + }), + ]); +} + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const [html, setHtml] = useState(""); + const [darkHtml, setDarkHtml] = useState(""); + const mounted = useRef(false); + + useEffect(() => { + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { + if (!mounted.current) { + setHtml(light); + setDarkHtml(dark); + mounted.current = true; + } + }); + + return () => { + mounted.current = false; + }; + }, [code, language, showLineNumbers]); + + return ( + +
+
+
+
+ {children && ( +
+ {children} +
+ )} +
+
+ + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + setTimeout(() => setIsCopied(false), timeout); + } catch (error) { + onError?.(error as Error); + } + }; + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; diff --git a/web/components/ai-elements/confirmation.spec.tsx b/web/components/ai-elements/confirmation.spec.tsx new file mode 100644 index 0000000..d8f8cdf --- /dev/null +++ b/web/components/ai-elements/confirmation.spec.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; + +import { + Confirmation, + ConfirmationAccepted, + ConfirmationAction, + ConfirmationActions, + ConfirmationRejected, + ConfirmationRequest, + ConfirmationTitle, +} from "./confirmation"; + +describe("Confirmation", () => { + it("renders nothing when approval state is not actionable", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows request and actions when approval is requested", () => { + render( + + Approval needed + +

Do you want to continue?

+
+ + Approve + Reject + +
+ ); + + expect(screen.getByText("Approval needed")).toBeInTheDocument(); + expect(screen.getByText("Do you want to continue?")).toBeInTheDocument(); + expect(screen.getAllByRole("button")).toHaveLength(2); + }); + + it("renders accepted or rejected sections based on approval result", () => { + render( + + +

Approved content

+
+ +

Rejected content

+
+
+ ); + + expect(screen.getByText("Approved content")).toBeInTheDocument(); + expect(screen.queryByText("Rejected content")).toBeNull(); + }); +}); diff --git a/web/components/ai-elements/confirmation.stories.tsx b/web/components/ai-elements/confirmation.stories.tsx new file mode 100644 index 0000000..04cf322 --- /dev/null +++ b/web/components/ai-elements/confirmation.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ToolUIPart } from "ai"; + +import { + Confirmation, + ConfirmationAccepted, + ConfirmationAction, + ConfirmationActions, + ConfirmationRejected, + ConfirmationRequest, + ConfirmationTitle, +} from "./confirmation"; + +const meta = { + title: "AI Elements/Confirmation", + component: Confirmation, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const approvalRequestedState = + "approval-requested" as unknown as ToolUIPart["state"]; +const outputAvailableState = + "output-available" as unknown as ToolUIPart["state"]; +const outputDeniedState = + "output-denied" as unknown as ToolUIPart["state"]; + +export const AwaitingApproval: Story = { + args: { + approval: { id: "docs-access" }, + state: approvalRequestedState, + }, + render: args => ( + + + Allow the assistant to read the shared drive? + + +

+ This request lets the assistant list recent documents before drafting a summary. +

+
+ + Deny + Approve + +
+ ), +}; + +export const Approved: Story = { + args: { + approval: { + id: "docs-access", + approved: true, + reason: "Granted by workspace admins.", + }, + state: outputAvailableState, + }, + render: args => ( + + Request approved + +

+ The assistant will continue with the tool call and share results here. +

+
+
+ ), +}; + +export const Rejected: Story = { + args: { + approval: { + id: "docs-access", + approved: false, + reason: "The folder contains drafts that are not ready to share.", + }, + state: outputDeniedState, + }, + render: args => ( + + Request denied + +

+ Access was declined. The assistant will skip this step and continue without the tool. +

+
+
+ ), +}; diff --git a/web/components/ai-elements/confirmation.tsx b/web/components/ai-elements/confirmation.tsx new file mode 100644 index 0000000..f2e910c --- /dev/null +++ b/web/components/ai-elements/confirmation.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ToolUIPart } from "ai"; +import { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from "react"; + +type ToolUIPartApproval = + | { + id: string; + approved?: never; + reason?: never; + } + | { + id: string; + approved: boolean; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: true; + reason?: string; + } + | { + id: string; + approved: false; + reason?: string; + } + | undefined; + +type ConfirmationContextValue = { + approval: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +const ConfirmationContext = createContext( + null +); + +const useConfirmation = () => { + const context = useContext(ConfirmationContext); + + if (!context) { + throw new Error("Confirmation components must be used within Confirmation"); + } + + return context; +}; + +export type ConfirmationProps = ComponentProps & { + approval?: ToolUIPartApproval; + state: ToolUIPart["state"]; +}; + +export const Confirmation = ({ + className, + approval, + state, + ...props +}: ConfirmationProps) => { + if (!approval || state === "input-streaming" || state === "input-available") { + return null; + } + + return ( + + + + ); +}; + +export type ConfirmationTitleProps = ComponentProps; + +export const ConfirmationTitle = ({ + className, + ...props +}: ConfirmationTitleProps) => ( + +); + +export type ConfirmationRequestProps = { + children?: ReactNode; +}; + +export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + // @ts-expect-error state only available in AI SDK v6 + if (state !== "approval-requested") { + return null; + } + + return children; +}; + +export type ConfirmationAcceptedProps = { + children?: ReactNode; +}; + +export const ConfirmationAccepted = ({ + children, +}: ConfirmationAcceptedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when approved and in response states + if ( + !approval?.approved || + // @ts-expect-error state only available in AI SDK v6 + (state !== "approval-responded" && + // @ts-expect-error state only available in AI SDK v6 + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationRejectedProps = { + children?: ReactNode; +}; + +export const ConfirmationRejected = ({ + children, +}: ConfirmationRejectedProps) => { + const { approval, state } = useConfirmation(); + + // Only show when rejected and in response states + if ( + approval?.approved !== false || + // @ts-expect-error state only available in AI SDK v6 + (state !== "approval-responded" && + // @ts-expect-error state only available in AI SDK v6 + state !== "output-denied" && + state !== "output-available") + ) { + return null; + } + + return children; +}; + +export type ConfirmationActionsProps = ComponentProps<"div">; + +export const ConfirmationActions = ({ + className, + ...props +}: ConfirmationActionsProps) => { + const { state } = useConfirmation(); + + // Only show when approval is requested + // @ts-expect-error state only available in AI SDK v6 + if (state !== "approval-requested") { + return null; + } + + return ( +
+ ); +}; + +export type ConfirmationActionProps = ComponentProps; + +export const ConfirmationAction = (props: ConfirmationActionProps) => ( + + )} + + ); +}; + +export type ContextContentProps = ComponentProps; + +export const ContextContent = ({ + className, + ...props +}: ContextContentProps) => ( + +); + +export type ContextContentHeaderProps = ComponentProps<"div">; + +export const ContextContentHeader = ({ + children, + className, + ...props +}: ContextContentHeaderProps) => { + const { usedTokens, maxTokens } = useContextValue(); + const usedPercent = usedTokens / maxTokens; + const displayPct = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, + }).format(usedPercent); + const used = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(usedTokens); + const total = new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(maxTokens); + + return ( +
+ {children ?? ( + <> +
+

{displayPct}

+

+ {used} / {total} +

+
+
+ +
+ + )} +
+ ); +}; + +export type ContextContentBodyProps = ComponentProps<"div">; + +export const ContextContentBody = ({ + children, + className, + ...props +}: ContextContentBodyProps) => ( +
+ {children} +
+); + +export type ContextContentFooterProps = ComponentProps<"div">; + +export const ContextContentFooter = ({ + children, + className, + ...props +}: ContextContentFooterProps) => { + const { modelId, usage } = useContextValue(); + const costUSD = modelId + ? getUsage({ + modelId, + usage: { + input: usage?.inputTokens ?? 0, + output: usage?.outputTokens ?? 0, + }, + }).costUSD?.totalUSD + : undefined; + const totalCost = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(costUSD ?? 0); + + return ( +
+ {children ?? ( + <> + Total cost + {totalCost} + + )} +
+ ); +}; + +export type ContextInputUsageProps = ComponentProps<"div">; + +export const ContextInputUsage = ({ + className, + children, + ...props +}: ContextInputUsageProps) => { + const { usage, modelId } = useContextValue(); + const inputTokens = usage?.inputTokens ?? 0; + + if (children) { + return children; + } + + if (!inputTokens) { + return null; + } + + const inputCost = modelId + ? getUsage({ + modelId, + usage: { input: inputTokens, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const inputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(inputCost ?? 0); + + return ( +
+ Input + +
+ ); +}; + +export type ContextOutputUsageProps = ComponentProps<"div">; + +export const ContextOutputUsage = ({ + className, + children, + ...props +}: ContextOutputUsageProps) => { + const { usage, modelId } = useContextValue(); + const outputTokens = usage?.outputTokens ?? 0; + + if (children) { + return children; + } + + if (!outputTokens) { + return null; + } + + const outputCost = modelId + ? getUsage({ + modelId, + usage: { input: 0, output: outputTokens }, + }).costUSD?.totalUSD + : undefined; + const outputCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(outputCost ?? 0); + + return ( +
+ Output + +
+ ); +}; + +export type ContextReasoningUsageProps = ComponentProps<"div">; + +export const ContextReasoningUsage = ({ + className, + children, + ...props +}: ContextReasoningUsageProps) => { + const { usage, modelId } = useContextValue(); + const reasoningTokens = usage?.reasoningTokens ?? 0; + + if (children) { + return children; + } + + if (!reasoningTokens) { + return null; + } + + const reasoningCost = modelId + ? getUsage({ + modelId, + usage: { reasoningTokens }, + }).costUSD?.totalUSD + : undefined; + const reasoningCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(reasoningCost ?? 0); + + return ( +
+ Reasoning + +
+ ); +}; + +export type ContextCacheUsageProps = ComponentProps<"div">; + +export const ContextCacheUsage = ({ + className, + children, + ...props +}: ContextCacheUsageProps) => { + const { usage, modelId } = useContextValue(); + const cacheTokens = usage?.cachedInputTokens ?? 0; + + if (children) { + return children; + } + + if (!cacheTokens) { + return null; + } + + const cacheCost = modelId + ? getUsage({ + modelId, + usage: { cacheReads: cacheTokens, input: 0, output: 0 }, + }).costUSD?.totalUSD + : undefined; + const cacheCostText = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cacheCost ?? 0); + + return ( +
+ Cache + +
+ ); +}; + +const TokensWithCost = ({ + tokens, + costText, +}: { + tokens?: number; + costText?: string; +}) => ( + + {tokens === undefined + ? "—" + : new Intl.NumberFormat("en-US", { + notation: "compact", + }).format(tokens)} + {costText ? ( + • {costText} + ) : null} + +); diff --git a/web/components/ai-elements/controls.spec.tsx b/web/components/ai-elements/controls.spec.tsx new file mode 100644 index 0000000..34a9a96 --- /dev/null +++ b/web/components/ai-elements/controls.spec.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import type { ControlsProps } from "@xyflow/react"; + +const mockControls = jest.fn(({ children }: ControlsProps) => ( +
{children}
+)); + +jest.mock("@xyflow/react", () => ({ + __esModule: true, + Controls: (props: ControlsProps | ComponentProps<"div">) => mockControls(props as ControlsProps), +})); + +import { Controls } from "./controls"; + +describe("Controls", () => { + beforeEach(() => { + mockControls.mockClear(); + }); + + it("applies styling presets and forwards props", () => { + render(); + + expect(mockControls).toHaveBeenCalledWith( + expect.objectContaining({ + className: expect.stringContaining("rounded-md"), + showZoom: false, + }) + ); + expect(mockControls).toHaveBeenCalledWith( + expect.objectContaining({ + className: expect.stringContaining("custom"), + }) + ); + }); +}); diff --git a/web/components/ai-elements/controls.tsx b/web/components/ai-elements/controls.tsx new file mode 100644 index 0000000..770a826 --- /dev/null +++ b/web/components/ai-elements/controls.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Controls as ControlsPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type ControlsProps = ComponentProps; + +export const Controls = ({ className, ...props }: ControlsProps) => ( + button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!", + className + )} + {...props} + /> +); diff --git a/web/components/ai-elements/conversation.spec.tsx b/web/components/ai-elements/conversation.spec.tsx new file mode 100644 index 0000000..8d4b891 --- /dev/null +++ b/web/components/ai-elements/conversation.spec.tsx @@ -0,0 +1,107 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +type StickContext = { + isAtBottom: boolean; + scrollToBottom: jest.Mock; +}; + +let mockStickContext: StickContext = { + isAtBottom: true, + scrollToBottom: jest.fn(), +}; + +jest.mock("use-stick-to-bottom", () => { + mockStickContext = { + isAtBottom: true, + scrollToBottom: jest.fn(), + }; + + const Context = React.createContext(mockStickContext); + + const StickToBottom = ({ + children, + ...props + }: React.ComponentProps<"div">) => ( + +
+ {children} +
+
+ ); + StickToBottom.displayName = "StickToBottomMock"; + + const StickContent = ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ); + StickContent.displayName = "StickToBottomContentMock"; + + StickToBottom.Content = StickContent; + + return { + __esModule: true, + StickToBottom, + useStickToBottomContext: () => React.useContext(Context), + }; +}); + +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "./conversation"; + +describe("Conversation", () => { + beforeEach(() => { + mockStickContext.isAtBottom = true; + mockStickContext.scrollToBottom = jest.fn(); + }); + + it("renders scrollable log area and content", () => { + render( + + Message body + + ); + + expect(screen.getByRole("log")).toHaveClass("custom-log"); + expect(screen.getByText("Message body")).toBeInTheDocument(); + }); + + it("renders a friendly empty state with optional description", () => { + const { rerender } = render(); + + expect(screen.getByText("No messages yet")).toBeInTheDocument(); + expect( + screen.getByText("Start a conversation to see messages here") + ).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect( + screen.queryByText("Start a conversation to see messages here") + ).toBeNull(); + }); + + it("exposes a scroll-to-bottom control when not at the bottom", async () => { + mockStickContext.isAtBottom = false; + + render( + + + + ); + + const button = screen.getByRole("button"); + await userEvent.click(button); + + expect(mockStickContext.scrollToBottom).toHaveBeenCalled(); + }); +}); diff --git a/web/components/ai-elements/conversation.stories.tsx b/web/components/ai-elements/conversation.stories.tsx new file mode 100644 index 0000000..6900e18 --- /dev/null +++ b/web/components/ai-elements/conversation.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "./conversation"; +import { + Message, + MessageAction, + MessageActions, + MessageAttachment, + MessageAttachments, + MessageBranch, + MessageBranchContent, + MessageBranchNext, + MessageBranchPage, + MessageBranchPrevious, + MessageBranchSelector, + MessageContent, + MessageResponse, + MessageToolbar, +} from "./message"; +import { + PromptInput, + PromptInputBody, + PromptInputButton, + PromptInputFooter, + PromptInputHeader, + PromptInputSubmit, + PromptInputTextarea, +} from "./prompt-input"; +import { Suggestion, Suggestions } from "./suggestion"; + +const meta = { + title: "AI Elements/Conversation", + component: Conversation, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithMessages: Story = { + args: { + children: null, + }, + render: () => ( +
+ + + + + + {"**Welcome!** This assistant can summarize links and draft messages."} + + + + + + + + + + + + + + + Can you draft a follow-up note? + + + + + + + + {"Here is a concise version:\n\n- Thank them for the update\n- Ask for next steps\n- Offer to help"} + + + + + {"Here is a detailed version with extra context and a closing paragraph."} + + + + + + + + + + + + + + + + + + + + + Promise.resolve()}> + + + Add context + + + + + + + + +
+ ), +}; diff --git a/web/components/ai-elements/conversation.tsx b/web/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..57e0e6b --- /dev/null +++ b/web/components/ai-elements/conversation.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + + {props.children} + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/web/components/ai-elements/developer-tools.stories.tsx b/web/components/ai-elements/developer-tools.stories.tsx new file mode 100644 index 0000000..3a67104 --- /dev/null +++ b/web/components/ai-elements/developer-tools.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; + +import { Context, ContextContent, ContextContentBody, ContextContentFooter, ContextContentHeader, ContextInputUsage, ContextOutputUsage, ContextTrigger } from "./context"; +import { ModelSelector, ModelSelectorContent, ModelSelectorGroup, ModelSelectorInput, ModelSelectorItem, ModelSelectorList, ModelSelectorLogo, ModelSelectorLogoGroup, ModelSelectorName, ModelSelectorShortcut, ModelSelectorTrigger } from "./model-selector"; +import { OpenIn, OpenInChatGPT, OpenInClaude, OpenInContent, OpenInCursor, OpenInScira, OpenInTrigger, OpenInv0 } from "./open-in-chat"; +import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "./tool"; + +const meta = { + title: "AI Elements/Developer Tools", + component: Context, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Overview: Story = { + args: { + maxTokens: 120000, + modelId: "gpt-4o-mini", + usage: { + inputTokens: 3400, + outputTokens: 1200, + cachedInputTokens: 800, + totalTokens: 4600, + }, + usedTokens: 4600, + }, + render: args => { + const [modelSelectorOpen, setModelSelectorOpen] = useState(false); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + gpt-4o-mini + ⌘K +
+
+ +
+ + claude-3.5-sonnet + ⌘C +
+
+
+
+
+
+
+ ); + }, +}; diff --git a/web/components/ai-elements/edge.spec.tsx b/web/components/ai-elements/edge.spec.tsx new file mode 100644 index 0000000..96d769d --- /dev/null +++ b/web/components/ai-elements/edge.spec.tsx @@ -0,0 +1,171 @@ +import { render, screen } from "@testing-library/react"; + +type Handle = { + position: "left" | "right" | "top" | "bottom"; + x: number; + y: number; + width: number; + height: number; +}; + +type TestNode = { + internals: { + positionAbsolute: { x: number; y: number }; + handleBounds: { + target?: Handle[]; + source?: Handle[]; + }; + }; +}; + +const mockSimpleBezierPath = jest + .fn() + .mockReturnValue(["M0,0 C 1 2 3 4 5 6"]); +const mockBezierPath = jest + .fn() + .mockReturnValue(["M1,1 C 2 3 4 5 6 7"]); +const mockNodes: Record = {}; + +jest.mock("@xyflow/react", () => ({ + __esModule: true, + Position: { + Left: "left", + Right: "right", + Top: "top", + Bottom: "bottom", + }, + BaseEdge: ({ + id, + path, + markerEnd, + style, + className, + }: { + id: string; + path: string; + markerEnd?: string; + className?: string; + style?: Record; + }) => ( + + ), + getSimpleBezierPath: (...args: unknown[]) => mockSimpleBezierPath(...args), + getBezierPath: (...args: unknown[]) => mockBezierPath(...args), + useInternalNode: (id: string) => mockNodes[id], +})); + +import { Edge } from "./edge"; + +const createNode = ({ + position, + handles, +}: { + position: { x: number; y: number }; + handles: { + target?: { x: number; y: number; width: number; height: number }[]; + source?: { x: number; y: number; width: number; height: number }[]; + }; +}) => ({ + internals: { + positionAbsolute: position, + handleBounds: handles, + }, +}); + +describe("Edge", () => { + beforeEach(() => { + mockSimpleBezierPath.mockClear(); + mockBezierPath.mockClear(); + Object.keys(mockNodes).forEach(key => delete mockNodes[key]); + }); + + it("renders a temporary edge with animated stroke", () => { + render( + + + + ); + + expect(mockSimpleBezierPath).toHaveBeenCalled(); + const edge = screen.getByTestId("base-edge"); + expect(edge).toHaveClass("stroke-1 stroke-ring"); + expect(edge).toHaveAttribute("d", "M0,0 C 1 2 3 4 5 6"); + }); + + it("returns null when nodes are missing", () => { + const { container } = render( + + + + ); + + expect(container.querySelector("[data-testid='base-edge']")).toBeNull(); + }); + + it("calculates handle positions and draws animated edge path", () => { + mockNodes.source = createNode({ + position: { x: 10, y: 20 }, + handles: { + source: [{ position: "right", x: 5, y: 6, width: 7, height: 8 }], + }, + }); + mockNodes.target = createNode({ + position: { x: 30, y: 40 }, + handles: { + target: [{ position: "left", x: 1, y: 2, width: 3, height: 4 }], + }, + }); + + const { container } = render( + + + + ); + + expect(mockBezierPath).toHaveBeenCalledWith( + expect.objectContaining({ + sourceX: 22, + sourceY: 30, + targetX: 31, + targetY: 44, + }) + ); + + const edge = screen.getByTestId("base-edge"); + expect(edge).toHaveAttribute("data-marker", "url(#arrow)"); + expect(edge).toHaveAttribute( + "data-style", + JSON.stringify({ strokeWidth: 2 }) + ); + expect(container.querySelector("animateMotion")).toHaveAttribute( + "path", + "M1,1 C 2 3 4 5 6 7" + ); + }); +}); diff --git a/web/components/ai-elements/edge.tsx b/web/components/ai-elements/edge.tsx new file mode 100644 index 0000000..84044a5 --- /dev/null +++ b/web/components/ai-elements/edge.tsx @@ -0,0 +1,140 @@ +import { + BaseEdge, + type EdgeProps, + getBezierPath, + getSimpleBezierPath, + type InternalNode, + type Node, + Position, + useInternalNode, +} from "@xyflow/react"; + +const Temporary = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: EdgeProps) => { + const [edgePath] = getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +const getHandleCoordsByPosition = ( + node: InternalNode, + handlePosition: Position +) => { + // Choose the handle type based on position - Left is for target, Right is for source + const handleType = handlePosition === Position.Left ? "target" : "source"; + + const handle = node.internals.handleBounds?.[handleType]?.find( + h => h.position === handlePosition + ); + + if (!handle) { + return [0, 0] as const; + } + + let offsetX = handle.width / 2; + let offsetY = handle.height / 2; + + // this is a tiny detail to make the markerEnd of an edge visible. + // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset + // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position + switch (handlePosition) { + case Position.Left: + offsetX = 0; + break; + case Position.Right: + offsetX = handle.width; + break; + case Position.Top: + offsetY = 0; + break; + case Position.Bottom: + offsetY = handle.height; + break; + default: + throw new Error(`Invalid handle position: ${handlePosition}`); + } + + const x = node.internals.positionAbsolute.x + handle.x + offsetX; + const y = node.internals.positionAbsolute.y + handle.y + offsetY; + + return [x, y] as const; +}; + +const getEdgeParams = ( + source: InternalNode, + target: InternalNode +) => { + const sourcePos = Position.Right; + const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); + const targetPos = Position.Left; + const [tx, ty] = getHandleCoordsByPosition(target, targetPos); + + return { + sx, + sy, + tx, + ty, + sourcePos, + targetPos, + }; +}; + +const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { + const sourceNode = useInternalNode(source); + const targetNode = useInternalNode(target); + + if (!(sourceNode && targetNode)) { + return null; + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode + ); + + const [edgePath] = getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetX: tx, + targetY: ty, + targetPosition: targetPos, + }); + + return ( + <> + + + + + + ); +}; + +export const Edge = { + Temporary, + Animated, +}; diff --git a/web/components/ai-elements/flow.stories.tsx b/web/components/ai-elements/flow.stories.tsx new file mode 100644 index 0000000..295faee --- /dev/null +++ b/web/components/ai-elements/flow.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMemo } from "react"; + +import { Canvas } from "./canvas"; +import { Connection } from "./connection"; +import { Controls } from "./controls"; +import { Edge as FlowEdgeComponents } from "./edge"; +import { Node, NodeContent, NodeHeader, NodeTitle } from "./node"; +import { Panel } from "./panel"; +import { Toolbar } from "./toolbar"; + +import type { Edge, Node as ReactFlowNode } from "@xyflow/react"; + +type FlowNodeData = { + title: string; + description: string; +}; + +const meta = { + title: "AI Elements/Flow Canvas", + component: Canvas, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const FlowNodeCard = ({ data }: { data: FlowNodeData }) => ( + + + {data.title} + + +

{data.description}

+
+ + Toolbar + +
+); + +export const SimpleFlow: Story = { + render: () => { + const nodes = useMemo[]>( + () => [ + { + id: "1", + data: { + title: "Intake", + description: "Receives prompts and files", + }, + position: { x: 0, y: 80 }, + type: "aiNode", + }, + { + id: "2", + data: { + title: "Model selection", + description: "Chooses the best model", + }, + position: { x: 240, y: 80 }, + type: "aiNode", + }, + { + id: "3", + data: { + title: "Response", + description: "Streams the final answer", + }, + position: { x: 480, y: 80 }, + type: "aiNode", + }, + ], + [] + ); + + const edges = useMemo( + () => [ + { id: "e1-2", source: "1", target: "2", type: "animated" }, + { id: "e2-3", source: "2", target: "3", type: "animated" }, + ], + [] + ); + + return ( +
+ + + +
+

Routing overview

+

+ Nodes stay interactive with custom styles. +

+
+
+
+
+ ); + }, +}; diff --git a/web/components/ai-elements/image.spec.tsx b/web/components/ai-elements/image.spec.tsx new file mode 100644 index 0000000..c273251 --- /dev/null +++ b/web/components/ai-elements/image.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; + +import { Image } from "./image"; + +describe("Image", () => { + it("renders a data URI with provided metadata", () => { + render( + Generated + ); + + const img = screen.getByAltText("Generated"); + expect(img).toHaveAttribute("src", "data:image/png;base64,Zm9vYmFy"); + expect(img).toHaveClass("custom-image"); + }); +}); diff --git a/web/components/ai-elements/image.tsx b/web/components/ai-elements/image.tsx new file mode 100644 index 0000000..46df770 --- /dev/null +++ b/web/components/ai-elements/image.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/utils"; +import type { Experimental_GeneratedImage } from "ai"; + +export type ImageProps = Experimental_GeneratedImage & { + className?: string; + alt?: string; +}; + +export const Image = ({ + base64, + uint8Array: _uint8Array, + mediaType, + ...props +}: ImageProps) => ( + {props.alt} +); diff --git a/web/components/ai-elements/inline-citation.spec.tsx b/web/components/ai-elements/inline-citation.spec.tsx new file mode 100644 index 0000000..1ab091c --- /dev/null +++ b/web/components/ai-elements/inline-citation.spec.tsx @@ -0,0 +1,171 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +type MockCarouselApi = { + scrollPrev: jest.Mock; + scrollNext: jest.Mock; + scrollSnapList: jest.Mock; + selectedScrollSnap: jest.Mock; + on: jest.Mock; + off: jest.Mock; +}; + +const mockCarouselApi: MockCarouselApi = { + scrollPrev: jest.fn(), + scrollNext: jest.fn(), + scrollSnapList: jest.fn(() => [0, 1, 2]), + selectedScrollSnap: jest.fn(() => 0), + on: jest.fn(), + off: jest.fn(), +}; + +jest.mock("@/components/ui/carousel", () => { + return { + __esModule: true, + Carousel: ({ + children, + className, + setApi, + ...props + }: React.ComponentProps<"div"> & { setApi?: (api: MockCarouselApi) => void }) => { + const initializedRef = React.useRef(false); + + React.useEffect(() => { + if (setApi && !initializedRef.current) { + setApi(mockCarouselApi); + initializedRef.current = true; + } + }, [setApi]); + return ( +
+ {children} +
+ ); + }, + CarouselContent: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CarouselItem: ({ children, ...props }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + }; +}); + +jest.mock("@/components/ui/hover-card", () => ({ + __esModule: true, + HoverCard: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + HoverCardTrigger: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => + _asChild && React.isValidElement(children) ? ( + React.cloneElement(children, props) + ) : ( +
{children}
+ ), + HoverCardContent: ({ + children, + ...props + }: React.ComponentProps<"div">) =>
{children}
, +})); + +import { + InlineCitation, + InlineCitationCard, + InlineCitationCardBody, + InlineCitationCardTrigger, + InlineCitationCarousel, + InlineCitationCarouselContent, + InlineCitationCarouselHeader, + InlineCitationCarouselIndex, + InlineCitationCarouselItem, + InlineCitationCarouselNext, + InlineCitationCarouselPrev, + InlineCitationQuote, + InlineCitationSource, + InlineCitationText, +} from "./inline-citation"; + +describe("InlineCitation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("summarizes sources on the trigger badge", () => { + render( + + Statement + + + content + + + ); + + expect(screen.getByText("example.com +1")).toBeInTheDocument(); + + render( + + + + ); + expect(screen.getByText("unknown")).toBeInTheDocument(); + }); + + it("connects carousel controls to the shared API", async () => { + render( + + + + + + + + First source + + + ); + + await waitFor(() => { + expect(screen.getByText("1/3")).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Previous" })); + await userEvent.click(screen.getByRole("button", { name: "Next" })); + + expect(mockCarouselApi.scrollPrev).toHaveBeenCalled(); + expect(mockCarouselApi.scrollNext).toHaveBeenCalled(); + }); + + it("renders individual source details and quotes", () => { + render( + + Quoted text + + ); + + expect(screen.getByText("Source title")).toBeInTheDocument(); + expect(screen.getByText("https://example.com")).toBeInTheDocument(); + expect(screen.getByText("Quoted text")).toBeInTheDocument(); + }); +}); diff --git a/web/components/ai-elements/inline-citation.tsx b/web/components/ai-elements/inline-citation.tsx new file mode 100644 index 0000000..b63e65d --- /dev/null +++ b/web/components/ai-elements/inline-citation.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + useCallback, + useContext, + useRef, + useSyncExternalStore, + useState, +} from "react"; + +export type InlineCitationProps = ComponentProps<"span">; + +export const InlineCitation = ({ + className, + ...props +}: InlineCitationProps) => ( + +); + +export type InlineCitationTextProps = ComponentProps<"span">; + +export const InlineCitationText = ({ + className, + ...props +}: InlineCitationTextProps) => ( + +); + +export type InlineCitationCardProps = ComponentProps; + +export const InlineCitationCard = (props: InlineCitationCardProps) => ( + +); + +export type InlineCitationCardTriggerProps = ComponentProps & { + sources: string[]; +}; + +export const InlineCitationCardTrigger = ({ + sources, + className, + ...props +}: InlineCitationCardTriggerProps) => ( + + + {sources[0] ? ( + <> + {new URL(sources[0]).hostname}{" "} + {sources.length > 1 && `+${sources.length - 1}`} + + ) : ( + "unknown" + )} + + +); + +export type InlineCitationCardBodyProps = ComponentProps<"div">; + +export const InlineCitationCardBody = ({ + className, + ...props +}: InlineCitationCardBodyProps) => ( + +); + +const CarouselApiContext = createContext(undefined); + +const useCarouselApi = () => { + const context = useContext(CarouselApiContext); + return context; +}; + +export type InlineCitationCarouselProps = ComponentProps; + +export const InlineCitationCarousel = ({ + className, + children, + ...props +}: InlineCitationCarouselProps) => { + const [api, setApi] = useState(); + + return ( + + + {children} + + + ); +}; + +export type InlineCitationCarouselContentProps = ComponentProps<"div">; + +export const InlineCitationCarouselContent = ( + props: InlineCitationCarouselContentProps +) => ; + +export type InlineCitationCarouselItemProps = ComponentProps<"div">; + +export const InlineCitationCarouselItem = ({ + className, + ...props +}: InlineCitationCarouselItemProps) => ( + +); + +export type InlineCitationCarouselHeaderProps = ComponentProps<"div">; + +export const InlineCitationCarouselHeader = ({ + className, + ...props +}: InlineCitationCarouselHeaderProps) => ( +
+); + +export type InlineCitationCarouselIndexProps = ComponentProps<"div">; + +export const InlineCitationCarouselIndex = ({ + children, + className, + ...props +}: InlineCitationCarouselIndexProps) => { + const api = useCarouselApi(); + const { current, count } = useCarouselMetrics(api); + + return ( +
+ {children ?? `${current}/${count}`} +
+ ); +}; + +function useCarouselMetrics(api: CarouselApi | undefined) { + const snapshotRef = useRef({ current: 0, count: 0 }); + + const subscribe = useCallback( + (callback: () => void) => { + if (!api) { + return () => {}; + } + + const handleSelect = () => { + callback(); + }; + + api.on("select", handleSelect); + return () => api.off("select", handleSelect); + }, + [api] + ); + + const getSnapshot = useCallback(() => { + if (!api) { + return snapshotRef.current; + } + + const nextCurrent = api.selectedScrollSnap() + 1; + const nextCount = api.scrollSnapList().length; + const previous = snapshotRef.current; + + if (previous.current !== nextCurrent || previous.count !== nextCount) { + snapshotRef.current = { + current: nextCurrent, + count: nextCount, + }; + } + + return snapshotRef.current; + }, [api]); + + return useSyncExternalStore(subscribe, getSnapshot, () => snapshotRef.current); +} + +export type InlineCitationCarouselPrevProps = ComponentProps<"button">; + +export const InlineCitationCarouselPrev = ({ + className, + ...props +}: InlineCitationCarouselPrevProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollPrev(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationCarouselNextProps = ComponentProps<"button">; + +export const InlineCitationCarouselNext = ({ + className, + ...props +}: InlineCitationCarouselNextProps) => { + const api = useCarouselApi(); + + const handleClick = useCallback(() => { + if (api) { + api.scrollNext(); + } + }, [api]); + + return ( + + ); +}; + +export type InlineCitationSourceProps = ComponentProps<"div"> & { + title?: string; + url?: string; + description?: string; +}; + +export const InlineCitationSource = ({ + title, + url, + description, + className, + children, + ...props +}: InlineCitationSourceProps) => ( +
+ {title && ( +

{title}

+ )} + {url && ( +

{url}

+ )} + {description && ( +

+ {description} +

+ )} + {children} +
+); + +export type InlineCitationQuoteProps = ComponentProps<"blockquote">; + +export const InlineCitationQuote = ({ + children, + className, + ...props +}: InlineCitationQuoteProps) => ( +
+ {children} +
+); diff --git a/web/components/ai-elements/loader.spec.tsx b/web/components/ai-elements/loader.spec.tsx new file mode 100644 index 0000000..829bccc --- /dev/null +++ b/web/components/ai-elements/loader.spec.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; + +import { Loader } from "./loader"; + +describe("Loader", () => { + it("spins with configurable size", () => { + render(); + + const loader = screen.getByLabelText("loading"); + expect(loader).toHaveClass("animate-spin"); + const svg = loader.querySelector("svg"); + expect(svg).toHaveAttribute("height", "24"); + expect(svg).toHaveAttribute("width", "24"); + }); +}); diff --git a/web/components/ai-elements/loader.tsx b/web/components/ai-elements/loader.tsx new file mode 100644 index 0000000..5f0cfce --- /dev/null +++ b/web/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; +import type { HTMLAttributes } from "react"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/web/components/ai-elements/message.spec.tsx b/web/components/ai-elements/message.spec.tsx new file mode 100644 index 0000000..1e64b91 --- /dev/null +++ b/web/components/ai-elements/message.spec.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +jest.mock("streamdown", () => ({ + Streamdown: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +jest.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + Tooltip: ({ + children, + content, + }: { + children: React.ReactNode; + content: React.ReactNode; + }) => ( +
+ {children} +
{content}
+
+ ), + TooltipTrigger: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => ( +
{children}
+ ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { + MessageAttachment, + MessageBranch, + MessageBranchContent, + MessageBranchNext, + MessageBranchPage, + MessageBranchPrevious, + MessageBranchSelector, + MessageContent, +} from "./message"; + +describe("Message branches", () => { + it("cycles between branches with navigation buttons", async () => { + const onBranchChange = jest.fn(); + render( + + + Branch one + Branch two + + + + + + + + ); + + expect(screen.getByText("Branch one")).toBeInTheDocument(); + expect(screen.getByText("Branch two").parentElement).toHaveClass("hidden"); + + await userEvent.click(screen.getByLabelText("Next branch")); + expect(onBranchChange).toHaveBeenCalledWith(1); + expect(screen.getByText("Branch two").parentElement).not.toHaveClass( + "hidden" + ); + expect(screen.getByText("Branch one").parentElement).toHaveClass("hidden"); + + await userEvent.click(screen.getByLabelText("Previous branch")); + expect(onBranchChange).toHaveBeenCalledWith(0); + }); +}); + +describe("Message attachments", () => { + it("renders and removes attachments", async () => { + const onRemove = jest.fn(); + render( + + ); + + expect(screen.getByText("file.txt")).toBeInTheDocument(); + await userEvent.click(screen.getByLabelText("Remove attachment")); + expect(onRemove).toHaveBeenCalled(); + }); +}); diff --git a/web/components/ai-elements/message.tsx b/web/components/ai-elements/message.tsx new file mode 100644 index 0000000..29909aa --- /dev/null +++ b/web/components/ai-elements/message.tsx @@ -0,0 +1,442 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@/components/ui/button-group"; +import { Tooltip } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, UIMessage } from "ai"; +import { + ChevronLeftIcon, + ChevronRightIcon, + PaperclipIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { createContext, memo, useContext, useEffect, useMemo, useState } from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + {button} + ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = useMemo( + () => (Array.isArray(children) ? children : [children]), + [children] + ); + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md", + className + )} + data-from={from} + orientation="horizontal" + {...props} + /> + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + className, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ + data, + className, + onRemove, + ...props +}: MessageAttachmentProps) { + const filename = data.filename || ""; + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( +
+ {isImage ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + {attachmentLabel}

} + > +
+ +
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<"div">; + +export function MessageAttachments({ + children, + className, + ...props +}: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/web/components/ai-elements/model-selector.spec.tsx b/web/components/ai-elements/model-selector.spec.tsx new file mode 100644 index 0000000..50fecee --- /dev/null +++ b/web/components/ai-elements/model-selector.spec.tsx @@ -0,0 +1,128 @@ +import { render, screen } from "@testing-library/react"; + +jest.mock("@/components/ui/dialog", () => ({ + __esModule: true, + Dialog: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTrigger: ({ + children, + ...props + }: React.ComponentProps<"button">) => ( + + ), +})); + +jest.mock("@/components/ui/command", () => ({ + __esModule: true, + Command: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CommandDialog: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CommandEmpty: (props: React.ComponentProps<"div">) => ( +
+ ), + CommandGroup: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CommandInput: (props: React.ComponentProps<"input">) => ( + + ), + CommandItem: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CommandList: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CommandSeparator: (props: React.ComponentProps<"div">) => ( +
+ ), + CommandShortcut: ({ + children, + ...props + }: React.ComponentProps<"span">) => ( + + {children} + + ), +})); + +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorLogo, + ModelSelectorTrigger, +} from "./model-selector"; + +describe("ModelSelector", () => { + it("renders dialog trigger and content scaffolding", () => { + render( + + Open models + +
content
+
+
+ ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-trigger")).toHaveTextContent( + "Open models" + ); + expect(screen.getByTestId("dialog-title")).toHaveTextContent( + "Model Selector" + ); + expect(screen.getByTestId("command")).toBeInTheDocument(); + }); + + it("renders a provider logo with the correct src and alt text", () => { + render(); + + const logo = screen.getByAltText("openai logo"); + expect(logo).toHaveAttribute( + "src", + "https://models.dev/logos/openai.svg" + ); + expect(logo).toHaveClass("size-3"); + }); +}); diff --git a/web/components/ai-elements/model-selector.tsx b/web/components/ai-elements/model-selector.tsx new file mode 100644 index 0000000..ef6ebd7 --- /dev/null +++ b/web/components/ai-elements/model-selector.tsx @@ -0,0 +1,205 @@ +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { ComponentProps, ReactNode } from "react"; + +export type ModelSelectorProps = ComponentProps; + +export const ModelSelector = (props: ModelSelectorProps) => ( + +); + +export type ModelSelectorTriggerProps = ComponentProps; + +export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( + +); + +export type ModelSelectorContentProps = ComponentProps & { + title?: ReactNode; +}; + +export const ModelSelectorContent = ({ + className, + children, + title = "Model Selector", + ...props +}: ModelSelectorContentProps) => ( + + {title} + + {children} + + +); + +export type ModelSelectorDialogProps = ComponentProps; + +export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( + +); + +export type ModelSelectorInputProps = ComponentProps; + +export const ModelSelectorInput = ({ + className, + ...props +}: ModelSelectorInputProps) => ( + +); + +export type ModelSelectorListProps = ComponentProps; + +export const ModelSelectorList = (props: ModelSelectorListProps) => ( + +); + +export type ModelSelectorEmptyProps = ComponentProps; + +export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( + +); + +export type ModelSelectorGroupProps = ComponentProps; + +export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( + +); + +export type ModelSelectorItemProps = ComponentProps; + +export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( + +); + +export type ModelSelectorShortcutProps = ComponentProps; + +export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( + +); + +export type ModelSelectorSeparatorProps = ComponentProps< + typeof CommandSeparator +>; + +export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( + +); + +export type ModelSelectorLogoProps = Omit< + ComponentProps<"img">, + "src" | "alt" +> & { + provider: + | "moonshotai-cn" + | "lucidquery" + | "moonshotai" + | "zai-coding-plan" + | "alibaba" + | "xai" + | "vultr" + | "nvidia" + | "upstage" + | "groq" + | "github-copilot" + | "mistral" + | "vercel" + | "nebius" + | "deepseek" + | "alibaba-cn" + | "google-vertex-anthropic" + | "venice" + | "chutes" + | "cortecs" + | "github-models" + | "togetherai" + | "azure" + | "baseten" + | "huggingface" + | "opencode" + | "fastrouter" + | "google" + | "google-vertex" + | "cloudflare-workers-ai" + | "inception" + | "wandb" + | "openai" + | "zhipuai-coding-plan" + | "perplexity" + | "openrouter" + | "zenmux" + | "v0" + | "iflowcn" + | "synthetic" + | "deepinfra" + | "zhipuai" + | "submodel" + | "zai" + | "inference" + | "requesty" + | "morph" + | "lmstudio" + | "anthropic" + | "aihubmix" + | "fireworks-ai" + | "modelscope" + | "llama" + | "scaleway" + | "amazon-bedrock" + | "cerebras" + | (string & {}); +}; + +export const ModelSelectorLogo = ({ + provider, + className, + ...props +}: ModelSelectorLogoProps) => ( + {`${provider} +); + +export type ModelSelectorLogoGroupProps = ComponentProps<"div">; + +export const ModelSelectorLogoGroup = ({ + className, + ...props +}: ModelSelectorLogoGroupProps) => ( +
img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground", + className + )} + {...props} + /> +); + +export type ModelSelectorNameProps = ComponentProps<"span">; + +export const ModelSelectorName = ({ + className, + ...props +}: ModelSelectorNameProps) => ( + +); diff --git a/web/components/ai-elements/node.spec.tsx b/web/components/ai-elements/node.spec.tsx new file mode 100644 index 0000000..f1ddacf --- /dev/null +++ b/web/components/ai-elements/node.spec.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; + +const mockHandle = jest.fn( + ({ type, ...props }: { type: string } & ComponentProps<"div">) => ( +
+ ) +); + +jest.mock("@xyflow/react", () => ({ + __esModule: true, + Handle: (props: ComponentProps<"div"> & { type: string }) => + mockHandle(props), + Position: { + Left: "left", + Right: "right", + }, +})); + +import { + Node, + NodeContent, + NodeFooter, + NodeHeader, + NodeTitle, +} from "./node"; + +describe("Node", () => { + beforeEach(() => { + mockHandle.mockClear(); + }); + + it("renders handles when enabled and exposes card slots", () => { + render( + + + Title + + Body + Footer + + ); + + expect(screen.getByTestId("node")).toBeInTheDocument(); + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Body")).toBeInTheDocument(); + expect(screen.getByText("Footer")).toBeInTheDocument(); + expect(screen.getAllByTestId(/handle$/)).toHaveLength(2); + }); + + it("omits handles that are disabled", () => { + render( + + content + + ); + + expect(screen.getAllByTestId(/handle$/)).toHaveLength(1); + }); +}); diff --git a/web/components/ai-elements/node.tsx b/web/components/ai-elements/node.tsx new file mode 100644 index 0000000..75ac59a --- /dev/null +++ b/web/components/ai-elements/node.tsx @@ -0,0 +1,71 @@ +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Handle, Position } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +export type NodeProps = ComponentProps & { + handles: { + target: boolean; + source: boolean; + }; +}; + +export const Node = ({ handles, className, ...props }: NodeProps) => ( + + {handles.target && } + {handles.source && } + {props.children} + +); + +export type NodeHeaderProps = ComponentProps; + +export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( + +); + +export type NodeTitleProps = ComponentProps; + +export const NodeTitle = (props: NodeTitleProps) => ; + +export type NodeDescriptionProps = ComponentProps; + +export const NodeDescription = (props: NodeDescriptionProps) => ( + +); + +export type NodeActionProps = ComponentProps; + +export const NodeAction = (props: NodeActionProps) => ; + +export type NodeContentProps = ComponentProps; + +export const NodeContent = ({ className, ...props }: NodeContentProps) => ( + +); + +export type NodeFooterProps = ComponentProps; + +export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( + +); diff --git a/web/components/ai-elements/open-in-chat.spec.tsx b/web/components/ai-elements/open-in-chat.spec.tsx new file mode 100644 index 0000000..b22856c --- /dev/null +++ b/web/components/ai-elements/open-in-chat.spec.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; + +jest.mock("@/components/ui/dropdown-menu", () => ({ + __esModule: true, + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ + children, + ...props + }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + DropdownMenuItem: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => ( +
+ {children} +
+ ), + DropdownMenuLabel: ({ + children, + ...props + }: React.ComponentProps<"div">) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => ( +
+ {children} +
+ ), +})); + +import { + OpenIn, + OpenInChatGPT, + OpenInClaude, + OpenInContent, + OpenInCursor, + OpenInLabel, + OpenInScira, + OpenInSeparator, + OpenInT3, + OpenInTrigger, + OpenInv0, +} from "./open-in-chat"; + +describe("OpenIn", () => { + it("builds provider links from the shared query", () => { + render( + + + + Send to + + + + + + + + + + ); + + expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-separator")).toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: /Open in ChatGPT/ }) + ).toHaveAttribute("href", "https://chatgpt.com/?hints=search&prompt=search+term"); + expect( + screen.getByRole("link", { name: /Open in Claude/ }) + ).toHaveAttribute("href", "https://claude.ai/new?q=search+term"); + expect( + screen.getByRole("link", { name: /Open in T3 Chat/ }) + ).toHaveAttribute("href", "https://t3.chat/new?q=search+term"); + expect( + screen.getByRole("link", { name: /Open in Scira/ }) + ).toHaveAttribute("href", "https://scira.ai/?q=search+term"); + expect( + screen.getByRole("link", { name: /Open in v0/ }) + ).toHaveAttribute("href", "https://v0.app?q=search+term"); + expect( + screen.getByRole("link", { name: /Open in Cursor/ }) + ).toHaveAttribute("href", "https://cursor.com/link/prompt?text=search+term"); + }); + + it("enforces provider usage within the OpenIn context", () => { + expect(() => render()).toThrow( + "OpenIn components must be used within an OpenIn provider" + ); + }); +}); diff --git a/web/components/ai-elements/open-in-chat.tsx b/web/components/ai-elements/open-in-chat.tsx new file mode 100644 index 0000000..bca3dfe --- /dev/null +++ b/web/components/ai-elements/open-in-chat.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + ChevronDownIcon, + ExternalLinkIcon, + MessageCircleIcon, +} from "lucide-react"; +import { type ComponentProps, createContext, useContext } from "react"; + +const providers = { + github: { + title: "Open in GitHub", + createUrl: (url: string) => url, + icon: ( + + GitHub + + + ), + }, + scira: { + title: "Open in Scira", + createUrl: (q: string) => + `https://scira.ai/?${new URLSearchParams({ + q, + })}`, + icon: ( + + Scira AI + + + + + + + + + ), + }, + chatgpt: { + title: "Open in ChatGPT", + createUrl: (prompt: string) => + `https://chatgpt.com/?${new URLSearchParams({ + hints: "search", + prompt, + })}`, + icon: ( + + OpenAI + + + ), + }, + claude: { + title: "Open in Claude", + createUrl: (q: string) => + `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Claude + + + ), + }, + t3: { + title: "Open in T3 Chat", + createUrl: (q: string) => + `https://t3.chat/new?${new URLSearchParams({ + q, + })}`, + icon: , + }, + v0: { + title: "Open in v0", + createUrl: (q: string) => + `https://v0.app?${new URLSearchParams({ + q, + })}`, + icon: ( + + v0 + + + + ), + }, + cursor: { + title: "Open in Cursor", + createUrl: (text: string) => { + const url = new URL("https://cursor.com/link/prompt"); + url.searchParams.set("text", text); + return url.toString(); + }, + icon: ( + + Cursor + + + ), + }, +}; + +const OpenInContext = createContext<{ query: string } | undefined>(undefined); + +const useOpenInContext = () => { + const context = useContext(OpenInContext); + if (!context) { + throw new Error("OpenIn components must be used within an OpenIn provider"); + } + return context; +}; + +export type OpenInProps = ComponentProps & { + query: string; +}; + +export const OpenIn = ({ query, ...props }: OpenInProps) => ( + + + +); + +export type OpenInContentProps = ComponentProps; + +export const OpenInContent = ({ className, ...props }: OpenInContentProps) => ( + +); + +export type OpenInItemProps = ComponentProps; + +export const OpenInItem = (props: OpenInItemProps) => ( + +); + +export type OpenInLabelProps = ComponentProps; + +export const OpenInLabel = (props: OpenInLabelProps) => ( + +); + +export type OpenInSeparatorProps = ComponentProps; + +export const OpenInSeparator = (props: OpenInSeparatorProps) => ( + +); + +export type OpenInTriggerProps = ComponentProps; + +export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => ( + + {children ?? ( + + )} + +); + +export type OpenInChatGPTProps = ComponentProps; + +export const OpenInChatGPT = (props: OpenInChatGPTProps) => { + const { query } = useOpenInContext(); + return ( + + + + {providers.chatgpt.icon} + + {providers.chatgpt.title} + + + + ); +}; + +export type OpenInClaudeProps = ComponentProps; + +export const OpenInClaude = (props: OpenInClaudeProps) => { + const { query } = useOpenInContext(); + return ( + + + + {providers.claude.icon} + + {providers.claude.title} + + + + ); +}; + +export type OpenInT3Props = ComponentProps; + +export const OpenInT3 = (props: OpenInT3Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.t3.icon} + {providers.t3.title} + + + + ); +}; + +export type OpenInSciraProps = ComponentProps; + +export const OpenInScira = (props: OpenInSciraProps) => { + const { query } = useOpenInContext(); + return ( + + + + {providers.scira.icon} + + {providers.scira.title} + + + + ); +}; + +export type OpenInv0Props = ComponentProps; + +export const OpenInv0 = (props: OpenInv0Props) => { + const { query } = useOpenInContext(); + return ( + + + {providers.v0.icon} + {providers.v0.title} + + + + ); +}; + +export type OpenInCursorProps = ComponentProps; + +export const OpenInCursor = (props: OpenInCursorProps) => { + const { query } = useOpenInContext(); + return ( + + + + {providers.cursor.icon} + + {providers.cursor.title} + + + + ); +}; diff --git a/web/components/ai-elements/panel.spec.tsx b/web/components/ai-elements/panel.spec.tsx new file mode 100644 index 0000000..d45c662 --- /dev/null +++ b/web/components/ai-elements/panel.spec.tsx @@ -0,0 +1,40 @@ +import { render } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import type { PanelProps } from "@xyflow/react"; + +const mockPanel = jest.fn( + ({ children, ...props }: PanelProps & ComponentProps<"div">) => ( +
+ {children} +
+ ) +); + +jest.mock("@xyflow/react", () => ({ + __esModule: true, + Panel: (props: PanelProps & ComponentProps<"div">) => mockPanel(props), +})); + +import { Panel } from "./panel"; + +describe("Panel", () => { + beforeEach(() => mockPanel.mockClear()); + + it("applies default styling while forwarding props", () => { + render( + + content + + ); + + expect(mockPanel).toHaveBeenCalledWith( + expect.objectContaining({ + className: expect.stringContaining("rounded-md"), + position: "top-right", + }) + ); + expect(mockPanel).toHaveBeenCalledWith( + expect.objectContaining({ className: expect.stringContaining("custom") }) + ); + }); +}); diff --git a/web/components/ai-elements/panel.tsx b/web/components/ai-elements/panel.tsx new file mode 100644 index 0000000..059cb7a --- /dev/null +++ b/web/components/ai-elements/panel.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; +import { Panel as PanelPrimitive } from "@xyflow/react"; +import type { ComponentProps } from "react"; + +type PanelProps = ComponentProps; + +export const Panel = ({ className, ...props }: PanelProps) => ( + +); diff --git a/web/components/ai-elements/plan.spec.tsx b/web/components/ai-elements/plan.spec.tsx new file mode 100644 index 0000000..f346f1f --- /dev/null +++ b/web/components/ai-elements/plan.spec.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +jest.mock("@/components/ui/collapsible", () => ({ + __esModule: true, + Collapsible: ({ + children, + asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => + asChild && React.isValidElement(children) ? ( + React.cloneElement(children, { ...props, "data-testid": "collapsible" }) + ) : ( +
+ {children} +
+ ), + CollapsibleContent: ({ + children, + asChild, + ...props + }: React.ComponentProps<"div"> & { asChild?: boolean }) => + asChild && React.isValidElement(children) ? ( + React.cloneElement(children, { + ...props, + "data-testid": "collapsible-content", + }) + ) : ( +
+ {children} +
+ ), + CollapsibleTrigger: ({ + children, + asChild: _asChild, + ...props + }: React.ComponentProps<"button"> & { asChild?: boolean }) => + _asChild && React.isValidElement(children) ? ( + React.cloneElement(children, { + ...props, + "data-testid": "collapsible-trigger", + }) + ) : ( + + ), +})); + +jest.mock("@/components/ui/card", () => ({ + __esModule: true, + Card: ({ children, ...props }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CardHeader: ({ children, ...props }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CardTitle: ({ children, ...props }: React.ComponentProps<"h3">) => ( +

+ {children} +

+ ), + CardDescription: ({ children, ...props }: React.ComponentProps<"p">) => ( +

+ {children} +

+ ), + CardAction: ({ children, ...props }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CardContent: ({ children, ...props }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), + CardFooter: ({ children, ...props }: React.ComponentProps<"div">) => ( +
+ {children} +
+ ), +})); + +jest.mock("./shimmer", () => ({ + __esModule: true, + Shimmer: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +import { + Plan, + PlanAction, + PlanContent, + PlanDescription, + PlanFooter, + PlanHeader, + PlanTitle, + PlanTrigger, +} from "./plan"; + +describe("Plan", () => { + it("renders plan content and trigger", () => { + render( + + + Execution plan + Action + + + Steps to follow + +
Content
+
+ Footer +
+ ); + + expect(screen.getByText("Execution plan")).toBeInTheDocument(); + expect(screen.getByText("Steps to follow")).toBeInTheDocument(); + expect(screen.getByTestId("collapsible-trigger")).toHaveTextContent( + "Toggle plan" + ); + expect(screen.getByText("Content")).toBeInTheDocument(); + expect(screen.getByText("Footer")).toBeInTheDocument(); + }); + + it("wraps title and description in a shimmer when streaming", () => { + render( + + + Streaming title + + Streaming description + + ); + + const shimmers = screen.getAllByTestId("shimmer"); + expect(shimmers.map(node => node.textContent)).toEqual([ + "Streaming title", + "Streaming description", + ]); + }); +}); diff --git a/web/components/ai-elements/plan.tsx b/web/components/ai-elements/plan.tsx new file mode 100644 index 0000000..be04d88 --- /dev/null +++ b/web/components/ai-elements/plan.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ChevronsUpDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { createContext, useContext } from "react"; +import { Shimmer } from "./shimmer"; + +type PlanContextValue = { + isStreaming: boolean; +}; + +const PlanContext = createContext(null); + +const usePlan = () => { + const context = useContext(PlanContext); + if (!context) { + throw new Error("Plan components must be used within Plan"); + } + return context; +}; + +export type PlanProps = ComponentProps & { + isStreaming?: boolean; +}; + +export const Plan = ({ + className, + isStreaming = false, + children, + ...props +}: PlanProps) => ( + + + {children} + + +); + +export type PlanHeaderProps = ComponentProps; + +export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => ( + +); + +export type PlanTitleProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanDescriptionProps = Omit< + ComponentProps, + "children" +> & { + children: string; +}; + +export const PlanDescription = ({ + className, + children, + ...props +}: PlanDescriptionProps) => { + const { isStreaming } = usePlan(); + + return ( + + {isStreaming ? {children} : children} + + ); +}; + +export type PlanActionProps = ComponentProps; + +export const PlanAction = (props: PlanActionProps) => ( + +); + +export type PlanContentProps = ComponentProps; + +export const PlanContent = (props: PlanContentProps) => ( + + + +); + +export type PlanFooterProps = ComponentProps<"div">; + +export const PlanFooter = (props: PlanFooterProps) => ( + +); + +export type PlanTriggerProps = ComponentProps; + +export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( + + + +); diff --git a/web/components/ai-elements/prompt-input.spec.tsx b/web/components/ai-elements/prompt-input.spec.tsx new file mode 100644 index 0000000..876cebb --- /dev/null +++ b/web/components/ai-elements/prompt-input.spec.tsx @@ -0,0 +1,102 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +jest.mock("nanoid", () => ({ nanoid: () => "file-id" })); + +import { + PromptInput, + PromptInputAttachments, + PromptInputBody, + PromptInputFooter, + PromptInputHeader, + PromptInputSubmit, + PromptInputTextarea, +} from "./prompt-input"; + +const createObjectURL = jest.fn(() => "blob:mock-url"); +const revokeObjectURL = jest.fn(); + +beforeAll(() => { + global.URL.createObjectURL = createObjectURL; + global.URL.revokeObjectURL = revokeObjectURL; +}); + +describe("PromptInput", () => { + it("collects text and attachments on submit", async () => { + const onSubmit = jest.fn(); + const file = new File(["content"], "hello.txt", { type: "text/plain" }); + + global.fetch = jest.fn(async () => new Response(new Blob(["content"]))); + + render( + + + + {attachment => ( + {attachment.filename} + )} + + + + + + + + + + ); + + const uploader = screen.getByLabelText("Upload files") as HTMLInputElement; + await userEvent.upload(uploader, file); + expect(await screen.findByTestId("attachment")).toHaveTextContent( + "hello.txt" + ); + + await userEvent.type( + screen.getByPlaceholderText("What would you like to know?"), + "Hello world" + ); + await userEvent.click(screen.getByLabelText("Submit")); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + + const [payload] = onSubmit.mock.calls[0]; + expect(payload.text).toBe("Hello world"); + expect(payload.files).toHaveLength(1); + expect(payload.files[0]?.filename).toBe("hello.txt"); + }); + + it("removes the last attachment with backspace when the input is empty", async () => { + const file = new File(["content"], "remove.txt", { type: "text/plain" }); + + render( + + + + {attachment => ( + {attachment.filename} + )} + + + + + + + + + + ); + + const uploader = screen.getByLabelText("Upload files") as HTMLInputElement; + await userEvent.upload(uploader, file); + expect(await screen.findByTestId("attachment")).toBeInTheDocument(); + + const textarea = screen.getByPlaceholderText("What would you like to know?"); + await userEvent.click(textarea); + await userEvent.keyboard("{Backspace}"); + + await waitFor(() => { + expect(screen.queryByTestId("attachment")).toBeNull(); + }); + }); +}); diff --git a/web/components/ai-elements/prompt-input.tsx b/web/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..6033100 --- /dev/null +++ b/web/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1415 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +}; + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController()." + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments()." + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) { + return; + } + + setAttachmentFiles(prev => + prev.concat( + incoming.map(file => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles(prev => { + const found = prev.find(f => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter(f => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles(prev => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + + useEffect(() => { + attachmentsRef.current = attachmentFiles; + }, [attachmentFiles]); + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }; + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachmentFiles, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachmentFiles, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, + className, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( +
+ {attachments.files.map(file => ( + {children(file)} + ))} +
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text: string; + files: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + + useEffect(() => { + filesRef.current = files; + }, [files]); + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + if (accept.includes("image/*")) { + return f.type.startsWith("image/"); + } + // NOTE: keep simple; expand as needed + return true; + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter(f => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems(prev => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems(prev => { + const found = prev.find(file => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter(file => file.id !== id); + }), + [] + ); + + const clearLocal = useCallback( + () => + setItems(prev => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [] + ); + + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + + [usingProvider] + ); + + const handleChange: ChangeEventHandler = event => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }; + + const convertBlobUrlToDataUrl = async ( + url: string + ): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise(resolve => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }; + + const ctx = useMemo( + () => ({ + files: files.map(item => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog] + ); + + const handleSubmit: FormEventHandler = event => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id: _id, ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + return usingProvider ? ( + inner + ) : ( + + {inner} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = e => { + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; + + const handlePaste: ClipboardEventHandler = event => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; + + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + } + : { + onChange, + }; + + return ( + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonProps = ComponentProps; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + return ( + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + children, + ...props +}: PromptInputSubmitProps) => { + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + return ( + + {children ?? Icon} + + ); +}; + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) + | null; +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +type SpeechRecognitionResultList = { + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +}; + +type SpeechRecognitionResult = { + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +}; + +type SpeechRecognitionAlternative = { + transcript: string; + confidence: number; +}; + +interface SpeechRecognitionErrorEvent extends Event { + error: string; +} + +declare global { + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } +} + +export type PromptInputSpeechButtonProps = ComponentProps< + typeof PromptInputButton +> & { + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; +}; + +export const PromptInputSpeechButton = ({ + className, + textareaRef, + onTranscriptionChange, + ...props +}: PromptInputSpeechButtonProps) => { + const [isListening, setIsListening] = useState(false); + const recognitionRef = useRef(null); + const canListen = useMemo( + () => + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window), + [] + ); + + const ensureRecognition = useCallback(() => { + if (!canListen) { + return null; + } + + if (recognitionRef.current) { + return recognitionRef.current; + } + + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const instance = new SpeechRecognition(); + + instance.continuous = true; + instance.interimResults = true; + instance.lang = "en-US"; + + instance.onstart = () => { + setIsListening(true); + }; + + instance.onend = () => { + setIsListening(false); + }; + + instance.onresult = (event: SpeechRecognitionEvent) => { + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } + + if (finalTranscript) { + const currentValue = textareaRef?.current?.value ?? ""; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; + + onTranscriptionChange?.(newValue); + } + }; + + instance.onerror = (event: SpeechRecognitionErrorEvent) => { + console.error("Speech recognition error:", event.error); + setIsListening(false); + }; + + recognitionRef.current = instance; + return instance; + }, [canListen, onTranscriptionChange, textareaRef]); + + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + }; + }, []); + + const toggleListening = useCallback(() => { + const recognition = ensureRecognition(); + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [ensureRecognition, isListening]); + + return ( + + + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + + ); +}; + +export type WebPreviewBodyProps = ComponentProps<"iframe"> & { + loading?: ReactNode; +}; + +export const WebPreviewBody = ({ + className, + loading, + src, + ...props +}: WebPreviewBodyProps) => { + const { url } = useWebPreview(); + + return ( +
+