Build temetro app: dark theme, clinical sidebar, AI chat, settings page

- Linear-inspired dark theme (app/globals.css), forced dark in layout
- Clinical sidebar (components/sidebar-02) with temetro brand
- AI chat UI (components/chat): Cursor-style input, UI-only mock replies
- Settings page (components/settings): Polar-style tabs/sections, UI only
- Route-group app shell: app/(app)/{layout,page,settings}
- shadcn (Base UI) + ai-elements component libraries
- CLAUDE.md: architecture notes + per-folder commit policy

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-05-31 20:25:19 +03:00
parent db23d694b8
commit 97bf9ed8cc
101 changed files with 24604 additions and 194 deletions
+67
View File
@@ -1 +1,68 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
> The import above is load-bearing: this is a **customized Next.js 16** with breaking changes vs.
> public docs. Before writing any Next.js code (routing, metadata, `next/image`, `next/font`,
> route handlers, config), read the relevant guide under `node_modules/next/dist/docs/01-app/`.
## What this is
`temetro` — an open-source AI chat that lets clinicians retrieve patient information in natural
language. The product surface is currently **UI-only**: the chat appends a mock assistant reply
(`components/chat/chat-panel.tsx`); there is no backend, model call, or patient data yet.
## Commands
```bash
npm run dev # Next dev server (Turbopack) on http://localhost:3000
npm run build # production build (runs a full TypeScript typecheck — see caveat below)
npm run start # serve the production build
npm run lint # eslint (eslint-config-next: core-web-vitals + typescript)
```
There is **no test runner** configured (no `test` script, no vitest/jest/playwright). Don't invent
test commands; verify changes by running the dev server.
**Version control:** this folder is its own git repo. Commit after every change
(`git -C . add -A && git commit`), with the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit policy.
## Architecture
- **Stack:** Next.js 16.2.6 (App Router) · React 19 · TypeScript · Tailwind CSS **v4**. Path alias
`@/*` → repo root (`tsconfig.json`). `cn()` (clsx + tailwind-merge) lives in `lib/utils.ts`.
- **`app/`** — App Router. `app/page.tsx` is the product chat page (sidebar + chat panel);
`app/layout.tsx` **forces dark mode** by putting `dark` on `<html>` and loads the fonts.
- **`components/ui/`** — shadcn components, but built on **Base UI (`@base-ui/react`), not Radix.**
APIs differ: composition uses the `render` prop and `useRender`/`mergeProps`, not `asChild`.
Match existing files when adding/editing primitives. Config in `components.json` (style
`base-luma`, baseColor `neutral`).
- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`,
`Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`,
`ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state
is managed with local React state.
- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` /
`SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav/teams config.
- **`components/chat/`** — the product chat UI. `chat-panel.tsx` owns message state + empty/active
layouts; `chat-input.tsx` is a bespoke (nonai-elements) input matching a specific design.
## Theming
Tailwind v4 with `@theme inline` and **oklch CSS variables** in `app/globals.css` (a
Linear-inspired dark palette; `--primary` is indigo `#5e6ad2`). The app is dark-only. The radius
scale (`rounded-2xl``rounded-4xl`) is derived from `--radius`, so those utilities are larger than
stock Tailwind.
## Gotchas
- `npm run build` currently **fails its typecheck** on a pre-existing error in
`components/ai-elements/attachments.tsx` (`openDelay` prop). `npm run dev` (Turbopack) is
unaffected because it only compiles the imported route and doesn't typecheck the whole tree.
- **`lucide-react@1.17` dropped brand glyphs** (e.g. `Github`, `Discord`) — import them and you get
a build error. Use inline SVGs instead.
- A sibling **`../landing-page/`** directory is a copy of this app with a marketing landing page
(`components/landing/`); it is a separate project, not part of this git repo.
- Multiple lockfiles in the tree produce a harmless Turbopack "inferred workspace root" warning.
+17
View File
@@ -0,0 +1,17 @@
import { SidebarProvider } from "@/components/ui/sidebar";
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<div className="relative flex h-dvh w-full">
<DashboardSidebar />
{children}
</div>
</SidebarProvider>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { SidebarInset } from "@/components/ui/sidebar";
import { ChatPanel } from "@/components/chat/chat-panel";
export default function Home() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
<ChatPanel />
</SidebarInset>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { SidebarInset } from "@/components/ui/sidebar";
import { SettingsView } from "@/components/settings/settings-view";
export default function SettingsPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<SettingsView />
</SidebarInset>
);
}
+121 -16
View File
@@ -1,26 +1,131 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.875rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
/* Linear-inspired dark palette: near-black canvas, #5e6ad2 indigo accent, hairline borders. */
.dark {
--background: oklch(0.146 0.005 277);
--foreground: oklch(0.98 0.002 277);
--card: oklch(0.193 0.005 277);
--card-foreground: oklch(0.98 0.002 277);
--popover: oklch(0.193 0.005 277);
--popover-foreground: oklch(0.98 0.002 277);
--primary: oklch(0.556 0.155 277);
--primary-foreground: oklch(0.985 0.001 277);
--secondary: oklch(0.225 0.006 277);
--secondary-foreground: oklch(0.98 0.002 277);
--muted: oklch(0.225 0.006 277);
--muted-foreground: oklch(0.652 0.012 268);
--accent: oklch(0.245 0.008 277);
--accent-foreground: oklch(0.98 0.002 277);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0.155 277);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.165 0.005 277);
--sidebar-foreground: oklch(0.98 0.002 277);
--sidebar-primary: oklch(0.556 0.155 277);
--sidebar-primary-foreground: oklch(0.985 0.001 277);
--sidebar-accent: oklch(0.245 0.008 277);
--sidebar-accent-foreground: oklch(0.98 0.002 277);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.556 0.155 277);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+8 -4
View File
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +16,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "temetro — AI assistant for clinicians",
description:
"Retrieve patient information by simply asking. The open-source AI assistant for clinicians.",
};
export default function RootLayout({
@@ -25,7 +29,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={cn("dark", "h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
-65
View File
@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-luma",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@blocks-so": "https://blocks.so/r/{name}.json"
}
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { Tool } from "ai";
import { BotIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { memo } from "react";
import { CodeBlock } from "./code-block";
export type AgentProps = ComponentProps<"div">;
export const Agent = memo(({ className, ...props }: AgentProps) => (
<div
className={cn("not-prose w-full rounded-md border", className)}
{...props}
/>
));
export type AgentHeaderProps = ComponentProps<"div"> & {
name: string;
model?: string;
};
export const AgentHeader = memo(
({ className, name, model, ...props }: AgentHeaderProps) => (
<div
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<BotIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{name}</span>
{model && (
<Badge className="font-mono text-xs" variant="secondary">
{model}
</Badge>
)}
</div>
</div>
)
);
export type AgentContentProps = ComponentProps<"div">;
export const AgentContent = memo(
({ className, ...props }: AgentContentProps) => (
<div className={cn("space-y-4 p-4 pt-0", className)} {...props} />
)
);
export type AgentInstructionsProps = ComponentProps<"div"> & {
children: string;
};
export const AgentInstructions = memo(
({ className, children, ...props }: AgentInstructionsProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">
Instructions
</span>
<div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
<p>{children}</p>
</div>
</div>
)
);
export type AgentToolsProps = ComponentProps<typeof Accordion>;
export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => (
<div className={cn("space-y-2", className)}>
<span className="font-medium text-muted-foreground text-sm">Tools</span>
<Accordion className="rounded-md border" {...props} />
</div>
));
export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
tool: Tool;
};
export const AgentTool = memo(
({ className, tool, value, ...props }: AgentToolProps) => {
const schema =
"jsonSchema" in tool && tool.jsonSchema
? tool.jsonSchema
: tool.inputSchema;
return (
<AccordionItem
className={cn("border-b last:border-b-0", className)}
value={value}
{...props}
>
<AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
{tool.description ?? "No description"}
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(schema, null, 2)} language="json" />
</div>
</AccordionContent>
</AccordionItem>
);
}
);
export type AgentOutputProps = ComponentProps<"div"> & {
schema: string;
};
export const AgentOutput = memo(
({ className, schema, ...props }: AgentOutputProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-sm">
Output Schema
</span>
<div className="rounded-md bg-muted/50">
<CodeBlock code={schema} language="typescript" />
</div>
</div>
)
);
Agent.displayName = "Agent";
AgentHeader.displayName = "AgentHeader";
AgentContent.displayName = "AgentContent";
AgentInstructions.displayName = "AgentInstructions";
AgentTools.displayName = "AgentTools";
AgentTool.displayName = "AgentTool";
AgentOutput.displayName = "AgentOutput";
@@ -0,0 +1,148 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className
)}
{...props}
/>
);
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
className
)}
{...props}
/>
);
export type ArtifactCloseProps = ComponentProps<typeof Button>;
export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
};
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon className="size-4" /> : children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
);
@@ -0,0 +1,426 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import type { FileUIPart, SourceDocumentUIPart } from "ai";
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData
): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source";
}
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) {
return "image";
}
if (mediaType.startsWith("video/")) {
return "video";
}
if (mediaType.startsWith("audio/")) {
return "audio";
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document";
}
return "unknown";
};
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source";
}
const category = getMediaCategory(data);
return data.filename || (category === "image" ? "Image" : "Attachment");
};
const renderAttachmentImage = (
url: string,
filename: string | undefined,
isGrid: boolean
) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext);
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant]
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext();
const iconSize = variant === "inline" ? "size-3" : "size-4";
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderAttachmentImage(data.url, data.filename, variant === "grid");
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove]
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
@@ -0,0 +1,183 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import { cn } from "@/lib/utils";
import type { Experimental_SpeechResult as SpeechResult } from "ai";
import {
MediaControlBar,
MediaController,
MediaDurationDisplay,
MediaMuteButton,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaVolumeRange,
} from "media-chrome/react";
import type { ComponentProps, CSSProperties } from "react";
export type AudioPlayerProps = Omit<
ComponentProps<typeof MediaController>,
"audio"
>;
export const AudioPlayer = ({
children,
style,
...props
}: AudioPlayerProps) => (
<MediaController
audio
data-slot="audio-player"
style={
{
"--media-background-color": "transparent",
"--media-button-icon-height": "1rem",
"--media-button-icon-width": "1rem",
"--media-control-background": "transparent",
"--media-control-hover-background": "var(--color-accent)",
"--media-control-padding": "0",
"--media-font": "var(--font-sans)",
"--media-font-size": "10px",
"--media-icon-color": "currentColor",
"--media-preview-time-background": "var(--color-background)",
"--media-preview-time-border-radius": "var(--radius-md)",
"--media-preview-time-text-shadow": "none",
"--media-primary-color": "var(--color-primary)",
"--media-range-bar-color": "var(--color-primary)",
"--media-range-track-background": "var(--color-secondary)",
"--media-secondary-color": "var(--color-secondary)",
"--media-text-color": "var(--color-foreground)",
"--media-tooltip-arrow-display": "none",
"--media-tooltip-background": "var(--color-background)",
"--media-tooltip-border-radius": "var(--radius-md)",
...style,
} as CSSProperties
}
{...props}
>
{children}
</MediaController>
);
export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
(
| {
data: SpeechResult["audio"];
}
| {
src: string;
}
);
export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
// oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer
<audio
data-slot="audio-player-element"
slot="media"
src={
"src" in props
? props.src
: `data:${props.data.mediaType};base64,${props.data.base64}`
}
{...props}
/>
);
export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
export const AudioPlayerControlBar = ({
children,
...props
}: AudioPlayerControlBarProps) => (
<MediaControlBar data-slot="audio-player-control-bar" {...props}>
<ButtonGroup orientation="horizontal">{children}</ButtonGroup>
</MediaControlBar>
);
export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
export const AudioPlayerPlayButton = ({
className,
...props
}: AudioPlayerPlayButtonProps) => (
<Button size="icon-sm" variant="outline" render={<MediaPlayButton className={cn("bg-transparent", className)} data-slot="audio-player-play-button" {...props} />}></Button>
);
export type AudioPlayerSeekBackwardButtonProps = ComponentProps<
typeof MediaSeekBackwardButton
>;
export const AudioPlayerSeekBackwardButton = ({
seekOffset = 10,
...props
}: AudioPlayerSeekBackwardButtonProps) => (
<Button size="icon-sm" variant="outline" render={<MediaSeekBackwardButton data-slot="audio-player-seek-backward-button" seekOffset={seekOffset} {...props} />}></Button>
);
export type AudioPlayerSeekForwardButtonProps = ComponentProps<
typeof MediaSeekForwardButton
>;
export const AudioPlayerSeekForwardButton = ({
seekOffset = 10,
...props
}: AudioPlayerSeekForwardButtonProps) => (
<Button size="icon-sm" variant="outline" render={<MediaSeekForwardButton data-slot="audio-player-seek-forward-button" seekOffset={seekOffset} {...props} />}></Button>
);
export type AudioPlayerTimeDisplayProps = ComponentProps<
typeof MediaTimeDisplay
>;
export const AudioPlayerTimeDisplay = ({
className,
...props
}: AudioPlayerTimeDisplayProps) => (
<ButtonGroupText className="bg-transparent" render={<MediaTimeDisplay className={cn("tabular-nums", className)} data-slot="audio-player-time-display" {...props} />}></ButtonGroupText>
);
export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
export const AudioPlayerTimeRange = ({
className,
...props
}: AudioPlayerTimeRangeProps) => (
<ButtonGroupText className="bg-transparent" render={<MediaTimeRange className={cn("", className)} data-slot="audio-player-time-range" {...props} />}></ButtonGroupText>
);
export type AudioPlayerDurationDisplayProps = ComponentProps<
typeof MediaDurationDisplay
>;
export const AudioPlayerDurationDisplay = ({
className,
...props
}: AudioPlayerDurationDisplayProps) => (
<ButtonGroupText className="bg-transparent" render={<MediaDurationDisplay className={cn("tabular-nums", className)} data-slot="audio-player-duration-display" {...props} />}></ButtonGroupText>
);
export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
export const AudioPlayerMuteButton = ({
className,
...props
}: AudioPlayerMuteButtonProps) => (
<ButtonGroupText className="bg-transparent" render={<MediaMuteButton className={cn("", className)} data-slot="audio-player-mute-button" {...props} />}></ButtonGroupText>
);
export type AudioPlayerVolumeRangeProps = ComponentProps<
typeof MediaVolumeRange
>;
export const AudioPlayerVolumeRange = ({
className,
...props
}: AudioPlayerVolumeRangeProps) => (
<ButtonGroupText className="bg-transparent" render={<MediaVolumeRange className={cn("", className)} data-slot="audio-player-volume-range" {...props} />}></ButtonGroupText>
);
@@ -0,0 +1,26 @@
import type { ReactFlowProps } from "@xyflow/react";
import { Background, ReactFlow } from "@xyflow/react";
import type { ReactNode } from "react";
import "@xyflow/react/dist/style.css";
type CanvasProps = ReactFlowProps & {
children?: ReactNode;
};
const deleteKeyCode = ["Backspace", "Delete"];
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={deleteKeyCode}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor="var(--sidebar)" />
{children}
</ReactFlow>
);
@@ -0,0 +1,222 @@
"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 type { LucideIcon } from "lucide-react";
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
interface ChainOfThoughtContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
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({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen]
);
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
{children}
</div>
</ChainOfThoughtContext.Provider>
);
}
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
}
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
};
const stepStatusStyles = {
active: "text-foreground",
complete: "text-muted-foreground",
pending: "text-muted-foreground/50",
};
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => (
<div
className={cn(
"flex gap-2 text-sm",
stepStatusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
)
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
)
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
)
);
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
}
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
)
);
ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
@@ -0,0 +1,67 @@
"use client";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { LucideProps } from "lucide-react";
import { BookmarkIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
export const Checkpoint = ({
className,
children,
...props
}: CheckpointProps) => (
<div
className={cn(
"flex items-center gap-0.5 overflow-hidden text-muted-foreground",
className
)}
{...props}
>
{children}
<Separator />
</div>
);
export type CheckpointIconProps = LucideProps;
export const CheckpointIcon = ({
className,
children,
...props
}: CheckpointIconProps) =>
children ?? (
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
);
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const CheckpointTrigger = ({
children,
variant = "ghost",
size = "sm",
tooltip,
...props
}: CheckpointTriggerProps) =>
tooltip ? (
<Tooltip>
<TooltipTrigger render={<Button size={size} type="button" variant={variant} {...props} />}>{children}</TooltipTrigger>
<TooltipContent align="start" side="bottom">
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<Button size={size} type="button" variant={variant} {...props}>
{children}
</Button>
);
@@ -0,0 +1,562 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// oxlint-disable-next-line eslint(no-bitwise)
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Synchronous cache lookup — avoids setState in effect for cached results
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
);
// Async highlighting result (populated after shiki loads)
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
const asyncKeyRef = useRef({ code, language });
// Invalidate stale async tokens synchronously during render
if (
asyncKeyRef.current.code !== code ||
asyncKeyRef.current.language !== language
) {
asyncKeyRef.current = { code, language };
setAsyncTokens(null);
}
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncTokens(result);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
const tokenized = asyncTokens ?? syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
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 timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;
+452
View File
@@ -0,0 +1,452 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckIcon,
CopyIcon,
FileIcon,
GitCommitIcon,
MinusIcon,
PlusIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
export type CommitProps = ComponentProps<typeof Collapsible>;
export const Commit = ({ className, children, ...props }: CommitProps) => (
<Collapsible
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children}
</Collapsible>
);
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
export const CommitHeader = ({
className,
children,
...props
}: CommitHeaderProps) => (
<CollapsibleTrigger {...props} render={<div className={cn(
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
className
)} />}>{children}</CollapsibleTrigger>
);
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
export const CommitHash = ({
className,
children,
...props
}: CommitHashProps) => (
<span className={cn("font-mono text-xs", className)} {...props}>
<GitCommitIcon className="mr-1 inline-block size-3" />
{children}
</span>
);
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
export const CommitMessage = ({
className,
children,
...props
}: CommitMessageProps) => (
<span className={cn("font-medium text-sm", className)} {...props}>
{children}
</span>
);
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
export const CommitMetadata = ({
className,
children,
...props
}: CommitMetadataProps) => (
<div
className={cn(
"flex items-center gap-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>;
export const CommitSeparator = ({
className,
children,
...props
}: CommitSeparatorProps) => (
<span className={className} {...props}>
{children ?? "•"}
</span>
);
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>;
export const CommitInfo = ({
className,
children,
...props
}: CommitInfoProps) => (
<div className={cn("flex flex-1 flex-col", className)} {...props}>
{children}
</div>
);
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>;
export const CommitAuthor = ({
className,
children,
...props
}: CommitAuthorProps) => (
<div className={cn("flex items-center", className)} {...props}>
{children}
</div>
);
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
initials: string;
};
export const CommitAuthorAvatar = ({
initials,
className,
...props
}: CommitAuthorAvatarProps) => (
<Avatar className={cn("size-8", className)} {...props}>
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
);
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
date: Date;
};
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", {
numeric: "auto",
});
const formatRelativeDate = (date: Date) => {
const days = Math.round(
(date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return relativeTimeFormat.format(days, "day");
};
export const CommitTimestamp = ({
date,
className,
children,
...props
}: CommitTimestampProps) => {
const [formatted, setFormatted] = useState("");
const updateFormatted = useCallback(() => {
setFormatted(formatRelativeDate(date));
}, [date]);
useEffect(() => {
updateFormatted();
}, [updateFormatted]);
return (
<time
className={cn("text-xs", className)}
dateTime={date.toISOString()}
{...props}
>
{children ?? formatted}
</time>
);
};
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>;
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
const handleActionsKeyDown = (e: React.KeyboardEvent) => e.stopPropagation();
export const CommitActions = ({
className,
children,
...props
}: CommitActionsProps) => (
<div
className={cn("flex items-center gap-1", className)}
onClick={handleActionsClick}
onKeyDown={handleActionsKeyDown}
role="group"
{...props}
>
{children}
</div>
);
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
hash: string;
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CommitCopyButton = ({
hash,
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CommitCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(hash);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [hash, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("size-7 shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>;
export const CommitContent = ({
className,
children,
...props
}: CommitContentProps) => (
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
{children}
</CollapsibleContent>
);
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>;
export const CommitFiles = ({
className,
children,
...props
}: CommitFilesProps) => (
<div className={cn("space-y-1", className)} {...props}>
{children}
</div>
);
export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
export const CommitFile = ({
className,
children,
...props
}: CommitFileProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
className
)}
{...props}
>
{children}
</div>
);
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>;
export const CommitFileInfo = ({
className,
children,
...props
}: CommitFileInfoProps) => (
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
{children}
</div>
);
const fileStatusStyles = {
added: "text-green-600 dark:text-green-400",
deleted: "text-red-600 dark:text-red-400",
modified: "text-yellow-600 dark:text-yellow-400",
renamed: "text-blue-600 dark:text-blue-400",
};
const fileStatusLabels = {
added: "A",
deleted: "D",
modified: "M",
renamed: "R",
};
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: "added" | "modified" | "deleted" | "renamed";
};
export const CommitFileStatus = ({
status,
className,
children,
...props
}: CommitFileStatusProps) => (
<span
className={cn(
"font-medium font-mono text-xs",
fileStatusStyles[status],
className
)}
{...props}
>
{children ?? fileStatusLabels[status]}
</span>
);
export type CommitFileIconProps = ComponentProps<typeof FileIcon>;
export const CommitFileIcon = ({
className,
...props
}: CommitFileIconProps) => (
<FileIcon
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
{...props}
/>
);
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
export const CommitFilePath = ({
className,
children,
...props
}: CommitFilePathProps) => (
<span className={cn("truncate font-mono text-xs", className)} {...props}>
{children}
</span>
);
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
export const CommitFileChanges = ({
className,
children,
...props
}: CommitFileChangesProps) => (
<div
className={cn(
"flex shrink-0 items-center gap-1 font-mono text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number;
};
export const CommitFileAdditions = ({
count,
className,
children,
...props
}: CommitFileAdditionsProps) => {
if (count <= 0) {
return null;
}
return (
<span
className={cn("text-green-600 dark:text-green-400", className)}
{...props}
>
{children ?? (
<>
<PlusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
);
};
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
count: number;
};
export const CommitFileDeletions = ({
count,
className,
children,
...props
}: CommitFileDeletionsProps) => {
if (count <= 0) {
return null;
}
return (
<span
className={cn("text-red-600 dark:text-red-400", className)}
{...props}
>
{children ?? (
<>
<MinusIcon className="inline-block size-3" />
{count}
</>
)}
</span>
);
};
@@ -0,0 +1,174 @@
"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, ReactNode } from "react";
import { createContext, useContext, useMemo } 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;
interface ConfirmationContextValue {
approval: ToolUIPartApproval;
state: ToolUIPart["state"];
}
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
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<typeof Alert> & {
approval?: ToolUIPartApproval;
state: ToolUIPart["state"];
};
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
if (!approval || state === "input-streaming" || state === "input-available") {
return null;
}
return (
<ConfirmationContext.Provider value={contextValue}>
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
</ConfirmationContext.Provider>
);
};
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn("inline", className)} {...props} />
);
export interface ConfirmationRequestProps {
children?: ReactNode;
}
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return children;
};
export interface ConfirmationAcceptedProps {
children?: ReactNode;
}
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation();
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export interface ConfirmationRejectedProps {
children?: ReactNode;
}
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation();
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== "approval-responded" &&
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
if (state !== "approval-requested") {
return null;
}
return (
<div
className={cn("flex items-center justify-end gap-2 self-end", className)}
{...props}
/>
);
};
export type ConfirmationActionProps = ComponentProps<typeof Button>;
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
);
@@ -0,0 +1,28 @@
import type { ConnectionLineComponent } from "@xyflow/react";
const HALF = 0.5;
export const Connection: ConnectionLineComponent = ({
fromX,
fromY,
toX,
toY,
}) => (
<g>
<path
className="animated"
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill="none"
stroke="var(--color-ring)"
strokeWidth={1}
/>
<circle
cx={toX}
cy={toY}
fill="#fff"
r={3}
stroke="var(--color-ring)"
strokeWidth={1}
/>
</g>
);
+409
View File
@@ -0,0 +1,409 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { LanguageModelUsage } from "ai";
import type { ComponentProps } from "react";
import { createContext, useContext, useMemo } from "react";
import { getUsage } from "tokenlens";
const PERCENT_MAX = 100;
const ICON_RADIUS = 10;
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_STROKE_WIDTH = 2;
type ModelId = string;
interface ContextSchema {
usedTokens: number;
maxTokens: number;
usage?: LanguageModelUsage;
modelId?: ModelId;
}
const ContextContext = createContext<ContextSchema | null>(null);
const useContextValue = () => {
const context = useContext(ContextContext);
if (!context) {
throw new Error("Context components must be used within Context");
}
return context;
};
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
export const Context = ({
usedTokens,
maxTokens,
usage,
modelId,
...props
}: ContextProps) => {
const contextValue = useMemo(
() => ({ maxTokens, modelId, usage, usedTokens }),
[maxTokens, modelId, usage, usedTokens]
);
return (
<ContextContext.Provider value={contextValue}>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
};
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue();
const circumference = 2 * Math.PI * ICON_RADIUS;
const usedPercent = usedTokens / maxTokens;
const dashOffset = circumference * (1 - usedPercent);
return (
<svg
aria-label="Model context usage"
height="20"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={ICON_RADIUS}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={ICON_RADIUS}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
/>
</svg>
);
};
export type ContextTriggerProps = ComponentProps<typeof Button>;
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const renderedPercent = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1,
style: "percent",
}).format(usedPercent);
return (
<HoverCardTrigger>
{children ?? (
<Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground">
{renderedPercent}
</span>
<ContextIcon />
</Button>
)}
</HoverCardTrigger>
);
};
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
export const ContextContent = ({
className,
...props
}: ContextContentProps) => (
<HoverCardContent
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
{...props}
/>
);
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", {
maximumFractionDigits: 1,
style: "percent",
}).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 (
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? (
<>
<div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p>
<p className="font-mono text-muted-foreground">
{used} / {total}
</p>
</div>
<div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
);
};
export type ContextContentBodyProps = ComponentProps<"div">;
export const ContextContentBody = ({
children,
className,
...props
}: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}>
{children}
</div>
);
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", {
currency: "USD",
style: "currency",
}).format(costUSD ?? 0);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
className
)}
{...props}
>
{children ?? (
<>
<span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span>
</>
)}
</div>
);
};
const TokensWithCost = ({
tokens,
costText,
}: {
tokens?: number;
costText?: string;
}) => (
<span>
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
{costText ? (
<span className="ml-2 text-muted-foreground"> {costText}</span>
) : null}
</span>
);
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", {
currency: "USD",
style: "currency",
}).format(inputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
);
};
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", {
currency: "USD",
style: "currency",
}).format(outputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
);
};
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", {
currency: "USD",
style: "currency",
}).format(reasoningCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
);
};
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", {
currency: "USD",
style: "currency",
}).format(cacheCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
);
};
@@ -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<typeof ControlsPrimitive>;
export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive
className={cn(
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
className
)}
{...props}
/>
);
@@ -0,0 +1,168 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
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) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
+143
View File
@@ -0,0 +1,143 @@
import type { EdgeProps, InternalNode, Node } from "@xyflow/react";
import {
BaseEdge,
getBezierPath,
getSimpleBezierPath,
Position,
useInternalNode,
} from "@xyflow/react";
const Temporary = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) => {
const [edgePath] = getSimpleBezierPath({
sourcePosition,
sourceX,
sourceY,
targetPosition,
targetX,
targetY,
});
return (
<BaseEdge
className="stroke-1 stroke-ring"
id={id}
path={edgePath}
style={{
strokeDasharray: "5, 5",
}}
/>
);
};
const getHandleCoordsByPosition = (
node: InternalNode<Node>,
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<Node>,
target: InternalNode<Node>
) => {
const sourcePos = Position.Right;
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
const targetPos = Position.Left;
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
return {
sourcePos,
sx,
sy,
targetPos,
tx,
ty,
};
};
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({
sourcePosition: sourcePos,
sourceX: sx,
sourceY: sy,
targetPosition: targetPos,
targetX: tx,
targetY: ty,
});
return (
<>
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
<circle fill="var(--primary)" r="4">
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
</circle>
</>
);
};
export const Edge = {
Animated,
Temporary,
};
@@ -0,0 +1,324 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface EnvironmentVariablesContextType {
showValues: boolean;
setShowValues: (show: boolean) => void;
}
// Default noop for context default value
// oxlint-disable-next-line eslint(no-empty-function)
const noop = () => {};
const EnvironmentVariablesContext =
createContext<EnvironmentVariablesContextType>({
setShowValues: noop,
showValues: false,
});
export type EnvironmentVariablesProps = HTMLAttributes<HTMLDivElement> & {
showValues?: boolean;
defaultShowValues?: boolean;
onShowValuesChange?: (show: boolean) => void;
};
export const EnvironmentVariables = ({
showValues: controlledShowValues,
defaultShowValues = false,
onShowValuesChange,
className,
children,
...props
}: EnvironmentVariablesProps) => {
const [internalShowValues, setInternalShowValues] =
useState(defaultShowValues);
const showValues = controlledShowValues ?? internalShowValues;
const setShowValues = useCallback(
(show: boolean) => {
setInternalShowValues(show);
onShowValuesChange?.(show);
},
[onShowValuesChange]
);
const contextValue = useMemo(
() => ({ setShowValues, showValues }),
[setShowValues, showValues]
);
return (
<EnvironmentVariablesContext.Provider value={contextValue}>
<div
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children}
</div>
</EnvironmentVariablesContext.Provider>
);
};
export type EnvironmentVariablesHeaderProps = HTMLAttributes<HTMLDivElement>;
export const EnvironmentVariablesHeader = ({
className,
children,
...props
}: EnvironmentVariablesHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b px-4 py-3",
className
)}
{...props}
>
{children}
</div>
);
export type EnvironmentVariablesTitleProps = HTMLAttributes<HTMLHeadingElement>;
export const EnvironmentVariablesTitle = ({
className,
children,
...props
}: EnvironmentVariablesTitleProps) => (
<h3 className={cn("font-medium text-sm", className)} {...props}>
{children ?? "Environment Variables"}
</h3>
);
export type EnvironmentVariablesToggleProps = ComponentProps<typeof Switch>;
export const EnvironmentVariablesToggle = ({
className,
...props
}: EnvironmentVariablesToggleProps) => {
const { showValues, setShowValues } = useContext(EnvironmentVariablesContext);
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground text-xs">
{showValues ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
</span>
<Switch
aria-label="Toggle value visibility"
checked={showValues}
onCheckedChange={setShowValues}
{...props}
/>
</div>
);
};
export type EnvironmentVariablesContentProps = HTMLAttributes<HTMLDivElement>;
export const EnvironmentVariablesContent = ({
className,
children,
...props
}: EnvironmentVariablesContentProps) => (
<div className={cn("divide-y", className)} {...props}>
{children}
</div>
);
interface EnvironmentVariableContextType {
name: string;
value: string;
}
const EnvironmentVariableContext =
createContext<EnvironmentVariableContextType>({
name: "",
value: "",
});
export type EnvironmentVariableGroupProps = HTMLAttributes<HTMLDivElement>;
export const EnvironmentVariableGroup = ({
className,
children,
...props
}: EnvironmentVariableGroupProps) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export type EnvironmentVariableNameProps = HTMLAttributes<HTMLSpanElement>;
export const EnvironmentVariableName = ({
className,
children,
...props
}: EnvironmentVariableNameProps) => {
const { name } = useContext(EnvironmentVariableContext);
return (
<span className={cn("font-mono text-sm", className)} {...props}>
{children ?? name}
</span>
);
};
export type EnvironmentVariableValueProps = HTMLAttributes<HTMLSpanElement>;
export const EnvironmentVariableValue = ({
className,
children,
...props
}: EnvironmentVariableValueProps) => {
const { value } = useContext(EnvironmentVariableContext);
const { showValues } = useContext(EnvironmentVariablesContext);
const displayValue = showValues
? value
: "•".repeat(Math.min(value.length, 20));
return (
<span
className={cn(
"font-mono text-muted-foreground text-sm",
!showValues && "select-none",
className
)}
{...props}
>
{children ?? displayValue}
</span>
);
};
export type EnvironmentVariableProps = HTMLAttributes<HTMLDivElement> & {
name: string;
value: string;
};
export const EnvironmentVariable = ({
name,
value,
className,
children,
...props
}: EnvironmentVariableProps) => {
const envVarContextValue = useMemo(() => ({ name, value }), [name, value]);
return (
<EnvironmentVariableContext.Provider value={envVarContextValue}>
<div
className={cn(
"flex items-center justify-between gap-4 px-4 py-3",
className
)}
{...props}
>
{children ?? (
<>
<div className="flex items-center gap-2">
<EnvironmentVariableName />
</div>
<EnvironmentVariableValue />
</>
)}
</div>
</EnvironmentVariableContext.Provider>
);
};
export type EnvironmentVariableCopyButtonProps = ComponentProps<
typeof Button
> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
copyFormat?: "name" | "value" | "export";
};
export const EnvironmentVariableCopyButton = ({
onCopy,
onError,
timeout = 2000,
copyFormat = "value",
children,
className,
...props
}: EnvironmentVariableCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { name, value } = useContext(EnvironmentVariableContext);
const getTextToCopy = useCallback((): string => {
const formatMap = {
export: () => `export ${name}="${value}"`,
name: () => name,
value: () => value,
};
return formatMap[copyFormat]();
}, [name, value, copyFormat]);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(getTextToCopy());
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
}, [getTextToCopy, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("size-6 shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={12} />}
</Button>
);
};
export type EnvironmentVariableRequiredProps = ComponentProps<typeof Badge>;
export const EnvironmentVariableRequired = ({
className,
children,
...props
}: EnvironmentVariableRequiredProps) => (
<Badge className={cn("text-xs", className)} variant="secondary" {...props}>
{children ?? "Required"}
</Badge>
);
@@ -0,0 +1,297 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
ChevronRightIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
} from "lucide-react";
import type { HTMLAttributes, ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
interface FileTreeContextType {
expandedPaths: Set<string>;
togglePath: (path: string) => void;
selectedPath?: string;
onSelect?: (path: string) => void;
}
// Default noop for context default value
// oxlint-disable-next-line eslint(no-empty-function)
const noop = () => {};
const FileTreeContext = createContext<FileTreeContextType>({
// oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
expandedPaths: new Set(),
togglePath: noop,
});
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
expanded?: Set<string>;
defaultExpanded?: Set<string>;
selectedPath?: string;
onSelect?: (path: string) => void;
onExpandedChange?: (expanded: Set<string>) => void;
};
export const FileTree = ({
expanded: controlledExpanded,
defaultExpanded = new Set(),
selectedPath,
onSelect,
onExpandedChange,
className,
children,
...props
}: FileTreeProps) => {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const expandedPaths = controlledExpanded ?? internalExpanded;
const togglePath = useCallback(
(path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setInternalExpanded(newExpanded);
onExpandedChange?.(newExpanded);
},
[expandedPaths, onExpandedChange]
);
const contextValue = useMemo(
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
[expandedPaths, onSelect, selectedPath, togglePath]
);
return (
<FileTreeContext.Provider value={contextValue}>
<div
className={cn(
"rounded-lg border bg-background font-mono text-sm",
className
)}
role="tree"
{...props}
>
<div className="p-2">{children}</div>
</div>
</FileTreeContext.Provider>
);
};
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
export const FileTreeIcon = ({
className,
children,
...props
}: FileTreeIconProps) => (
<span className={cn("shrink-0", className)} {...props}>
{children}
</span>
);
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
export const FileTreeName = ({
className,
children,
...props
}: FileTreeNameProps) => (
<span className={cn("truncate", className)} {...props}>
{children}
</span>
);
interface FileTreeFolderContextType {
path: string;
name: string;
isExpanded: boolean;
}
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
isExpanded: false,
name: "",
path: "",
});
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
path: string;
name: string;
};
export const FileTreeFolder = ({
path,
name,
className,
children,
...props
}: FileTreeFolderProps) => {
const { expandedPaths, togglePath, selectedPath, onSelect } =
useContext(FileTreeContext);
const isExpanded = expandedPaths.has(path);
const isSelected = selectedPath === path;
const handleOpenChange = useCallback(() => {
togglePath(path);
}, [togglePath, path]);
const handleSelect = useCallback(() => {
onSelect?.(path);
}, [onSelect, path]);
const folderContextValue = useMemo(
() => ({ isExpanded, name, path }),
[isExpanded, name, path]
);
return (
<FileTreeFolderContext.Provider value={folderContextValue}>
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
<div
className={cn("", className)}
role="treeitem"
tabIndex={0}
{...props}
>
<div
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted"
)}
>
<CollapsibleTrigger render={<button className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0" type="button" />}><ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90"
)}
/></CollapsibleTrigger>
<button
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
onClick={handleSelect}
type="button"
>
<FileTreeIcon>
{isExpanded ? (
<FolderOpenIcon className="size-4 text-blue-500" />
) : (
<FolderIcon className="size-4 text-blue-500" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</button>
</div>
<CollapsibleContent>
<div className="ml-4 border-l pl-2">{children}</div>
</CollapsibleContent>
</div>
</Collapsible>
</FileTreeFolderContext.Provider>
);
};
interface FileTreeFileContextType {
path: string;
name: string;
}
const FileTreeFileContext = createContext<FileTreeFileContextType>({
name: "",
path: "",
});
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
path: string;
name: string;
icon?: ReactNode;
};
export const FileTreeFile = ({
path,
name,
icon,
className,
children,
...props
}: FileTreeFileProps) => {
const { selectedPath, onSelect } = useContext(FileTreeContext);
const isSelected = selectedPath === path;
const handleClick = useCallback(() => {
onSelect?.(path);
}, [onSelect, path]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
onSelect?.(path);
}
},
[onSelect, path]
);
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
return (
<FileTreeFileContext.Provider value={fileContextValue}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="treeitem"
tabIndex={0}
{...props}
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4 shrink-0" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</>
)}
</div>
</FileTreeFileContext.Provider>
);
};
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
export const FileTreeActions = ({
className,
children,
...props
}: FileTreeActionsProps) => (
<div
className={cn("ml-auto flex items-center gap-1", className)}
onClick={stopPropagation}
onKeyDown={stopPropagation}
role="group"
{...props}
>
{children}
</div>
);
+24
View File
@@ -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) => (
<img
{...props}
alt={props.alt}
className={cn(
"h-auto max-w-full overflow-hidden rounded-md",
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);
@@ -0,0 +1,288 @@
"use client";
import { Badge } from "@/components/ui/badge";
import type { CarouselApi } from "@/components/ui/carousel";
import {
Carousel,
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 } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
export type InlineCitationProps = ComponentProps<"span">;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger render={<Badge className={cn("ml-1 rounded-full", className)} variant="secondary" {...props} />}>{sources[0] ? (
<>
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const syncState = useCallback(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
}, [api]);
useEffect(() => {
if (!api) {
return;
}
syncState();
api.on("select", syncState);
return () => {
api.off("select", syncState);
};
}, [api, syncState]);
return (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn("space-y-1", className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>
{children}
</blockquote>
);
@@ -0,0 +1,310 @@
"use client";
import { cn } from "@/lib/utils";
import { AlertCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { TProps as JsxParserProps } from "react-jsx-parser";
import JsxParser from "react-jsx-parser";
interface JSXPreviewContextValue {
jsx: string;
processedJsx: string;
isStreaming: boolean;
error: Error | null;
setError: (error: Error | null) => void;
setLastGoodJsx: (jsx: string) => void;
components: JsxParserProps["components"];
bindings: JsxParserProps["bindings"];
onErrorProp?: (error: Error) => void;
}
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
export const useJSXPreview = () => {
const context = useContext(JSXPreviewContext);
if (!context) {
throw new Error("JSXPreview components must be used within JSXPreview");
}
return context;
};
const matchJsxTag = (code: string) => {
if (code.trim() === "") {
return null;
}
const match = code.match(TAG_REGEX);
if (!match || match.index === undefined) {
return null;
}
const [fullMatch, tagName, attributes, selfClosing] = match;
let type: "self-closing" | "closing" | "opening";
if (selfClosing) {
type = "self-closing";
} else if (fullMatch.startsWith("</")) {
type = "closing";
} else {
type = "opening";
}
return {
attributes: attributes.trim(),
endIndex: match.index + fullMatch.length,
startIndex: match.index,
tag: fullMatch,
tagName,
type,
};
};
const stripIncompleteTag = (text: string) => {
// Find the last '<' that isn't part of a complete tag
const lastOpen = text.lastIndexOf("<");
if (lastOpen === -1) {
return text;
}
const afterOpen = text.slice(lastOpen);
// If there's no closing '>' after the last '<', it's an incomplete tag
if (!afterOpen.includes(">")) {
return text.slice(0, lastOpen);
}
return text;
};
const completeJsxTag = (code: string) => {
const stack: string[] = [];
let result = "";
let currentPosition = 0;
while (currentPosition < code.length) {
const match = matchJsxTag(code.slice(currentPosition));
if (!match) {
// No more tags found, strip any trailing incomplete tag
result += stripIncompleteTag(code.slice(currentPosition));
break;
}
const { tagName, type, endIndex } = match;
// Include any text content before this tag
result += code.slice(currentPosition, currentPosition + endIndex);
if (type === "opening") {
stack.push(tagName);
} else if (type === "closing") {
stack.pop();
}
currentPosition += endIndex;
}
return (
result +
stack
.toReversed()
.map((tag) => `</${tag}>`)
.join("")
);
};
export type JSXPreviewProps = ComponentProps<"div"> & {
jsx: string;
isStreaming?: boolean;
components?: JsxParserProps["components"];
bindings?: JsxParserProps["bindings"];
onError?: (error: Error) => void;
};
export const JSXPreview = memo(
({
jsx,
isStreaming = false,
components,
bindings,
onError,
className,
children,
...props
}: JSXPreviewProps) => {
const [prevJsx, setPrevJsx] = useState(jsx);
const [error, setError] = useState<Error | null>(null);
const [_lastGoodJsx, setLastGoodJsx] = useState("");
// Clear error when jsx changes (derived state pattern)
if (jsx !== prevJsx) {
setPrevJsx(jsx);
setError(null);
}
const processedJsx = useMemo(
() => (isStreaming ? completeJsxTag(jsx) : jsx),
[jsx, isStreaming]
);
const contextValue = useMemo(
() => ({
bindings,
components,
error,
isStreaming,
jsx,
onErrorProp: onError,
processedJsx,
setError,
setLastGoodJsx,
}),
[
bindings,
components,
error,
isStreaming,
jsx,
onError,
processedJsx,
setError,
]
);
return (
<JSXPreviewContext.Provider value={contextValue}>
<div className={cn("relative", className)} {...props}>
{children}
</div>
</JSXPreviewContext.Provider>
);
}
);
JSXPreview.displayName = "JSXPreview";
export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
export const JSXPreviewContent = memo(
({ className, ...props }: JSXPreviewContentProps) => {
const {
processedJsx,
isStreaming,
components,
bindings,
setError,
setLastGoodJsx,
onErrorProp,
} = useJSXPreview();
const errorReportedRef = useRef<string | null>(null);
const lastGoodJsxRef = useRef("");
const [hadError, setHadError] = useState(false);
// Reset error tracking when jsx changes
useEffect(() => {
errorReportedRef.current = null;
setHadError(false);
}, [processedJsx]);
const handleError = useCallback(
(err: Error) => {
// Prevent duplicate error reports for the same jsx
if (errorReportedRef.current === processedJsx) {
return;
}
errorReportedRef.current = processedJsx;
// During streaming, suppress errors and fall back to last good JSX
if (isStreaming) {
setHadError(true);
return;
}
setError(err);
onErrorProp?.(err);
},
[processedJsx, isStreaming, onErrorProp, setError]
);
// Track the last JSX that rendered without error
useEffect(() => {
if (!errorReportedRef.current) {
lastGoodJsxRef.current = processedJsx;
setLastGoodJsx(processedJsx);
}
}, [processedJsx, setLastGoodJsx]);
// During streaming, if the current JSX errored, re-render with last good version
const displayJsx =
isStreaming && hadError ? lastGoodJsxRef.current : processedJsx;
return (
<div className={cn("jsx-preview-content", className)} {...props}>
<JsxParser
bindings={bindings}
components={components}
jsx={displayJsx}
onError={handleError}
renderInWrapper={false}
/>
</div>
);
}
);
JSXPreviewContent.displayName = "JSXPreviewContent";
export type JSXPreviewErrorProps = ComponentProps<"div"> & {
children?: ReactNode | ((error: Error) => ReactNode);
};
const renderChildren = (
children: ReactNode | ((error: Error) => ReactNode),
error: Error
): ReactNode => {
if (typeof children === "function") {
return children(error);
}
return children;
};
export const JSXPreviewError = memo(
({ className, children, ...props }: JSXPreviewErrorProps) => {
const { error } = useJSXPreview();
if (!error) {
return null;
}
return (
<div
className={cn(
"flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm",
className
)}
{...props}
>
{children ? (
renderChildren(children, error)
) : (
<>
<AlertCircle className="size-4 shrink-0" />
<span>{error.message}</span>
</>
)}
</div>
);
}
);
JSXPreviewError.displayName = "JSXPreviewError";
+360
View File
@@ -0,0 +1,360 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
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<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
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
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
@@ -0,0 +1,370 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
interface MicSelectorContextType {
data: MediaDeviceInfo[];
value: string | undefined;
onValueChange?: (value: string) => void;
open: boolean;
onOpenChange?: (open: boolean) => void;
width: number;
setWidth?: (width: number) => void;
}
const MicSelectorContext = createContext<MicSelectorContextType>({
data: [],
onOpenChange: undefined,
onValueChange: undefined,
open: false,
setWidth: undefined,
value: undefined,
width: 200,
});
export const useAudioDevices = () => {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState(false);
const loadDevicesWithoutPermission = useCallback(async () => {
try {
setLoading(true);
setError(null);
const deviceList = await navigator.mediaDevices.enumerateDevices();
const audioInputs = deviceList.filter(
(device) => device.kind === "audioinput"
);
setDevices(audioInputs);
} catch (caughtError) {
const message =
caughtError instanceof Error
? caughtError.message
: "Failed to get audio devices";
setError(message);
console.error("Error getting audio devices:", message);
} finally {
setLoading(false);
}
}, []);
const loadDevicesWithPermission = useCallback(async () => {
if (loading) {
return;
}
try {
setLoading(true);
setError(null);
const tempStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
for (const track of tempStream.getTracks()) {
track.stop();
}
const deviceList = await navigator.mediaDevices.enumerateDevices();
const audioInputs = deviceList.filter(
(device) => device.kind === "audioinput"
);
setDevices(audioInputs);
setHasPermission(true);
} catch (caughtError) {
const message =
caughtError instanceof Error
? caughtError.message
: "Failed to get audio devices";
setError(message);
console.error("Error getting audio devices:", message);
} finally {
setLoading(false);
}
}, [loading]);
useEffect(() => {
loadDevicesWithoutPermission();
}, [loadDevicesWithoutPermission]);
useEffect(() => {
const handleDeviceChange = () => {
if (hasPermission) {
loadDevicesWithPermission();
} else {
loadDevicesWithoutPermission();
}
};
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
handleDeviceChange
);
};
}, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
return {
devices,
error,
hasPermission,
loadDevices: loadDevicesWithPermission,
loading,
};
};
export type MicSelectorProps = ComponentProps<typeof Popover> & {
defaultValue?: string;
value?: string | undefined;
onValueChange?: (value: string | undefined) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const MicSelector = ({
defaultValue,
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultOpen = false,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
...props
}: MicSelectorProps) => {
const [value, onValueChange] = useControllableState<string | undefined>({
defaultProp: defaultValue,
onChange: controlledOnValueChange,
prop: controlledValue,
});
const [open, onOpenChange] = useControllableState({
defaultProp: defaultOpen,
onChange: controlledOnOpenChange,
prop: controlledOpen,
});
const [width, setWidth] = useState(200);
const { devices, loading, hasPermission, loadDevices } = useAudioDevices();
useEffect(() => {
if (open && !hasPermission && !loading) {
loadDevices();
}
}, [open, hasPermission, loading, loadDevices]);
const contextValue = useMemo(
() => ({
data: devices,
onOpenChange,
onValueChange,
open,
setWidth,
value,
width,
}),
[devices, onOpenChange, onValueChange, open, setWidth, value, width]
);
return (
<MicSelectorContext.Provider value={contextValue}>
<Popover {...props} onOpenChange={onOpenChange} open={open} />
</MicSelectorContext.Provider>
);
};
export type MicSelectorTriggerProps = ComponentProps<typeof Button>;
export const MicSelectorTrigger = ({
children,
...props
}: MicSelectorTriggerProps) => {
const { setWidth } = useContext(MicSelectorContext);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Create a ResizeObserver to detect width changes
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = (entry.target as HTMLElement).offsetWidth;
if (newWidth) {
setWidth?.(newWidth);
}
}
});
if (ref.current) {
resizeObserver.observe(ref.current);
}
// Clean up the observer when component unmounts
return () => {
resizeObserver.disconnect();
};
}, [setWidth]);
return (
<PopoverTrigger render={<Button variant="outline" {...props} ref={ref} />}>{children}<ChevronsUpDownIcon
className="shrink-0 text-muted-foreground"
size={16}
/></PopoverTrigger>
);
};
export type MicSelectorContentProps = ComponentProps<typeof Command> & {
popoverOptions?: ComponentProps<typeof PopoverContent>;
};
export const MicSelectorContent = ({
className,
popoverOptions,
...props
}: MicSelectorContentProps) => {
const { width, onValueChange, value } = useContext(MicSelectorContext);
return (
<PopoverContent
className={cn("p-0", className)}
style={{ width }}
{...popoverOptions}
>
<Command onValueChange={onValueChange} value={value} {...props} />
</PopoverContent>
);
};
export type MicSelectorInputProps = ComponentProps<typeof CommandInput> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
};
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => (
<CommandInput placeholder="Search microphones..." {...props} />
);
export type MicSelectorListProps = Omit<
ComponentProps<typeof CommandList>,
"children"
> & {
children: (devices: MediaDeviceInfo[]) => ReactNode;
};
export const MicSelectorList = ({
children,
...props
}: MicSelectorListProps) => {
const { data } = useContext(MicSelectorContext);
return <CommandList {...props}>{children(data)}</CommandList>;
};
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const MicSelectorEmpty = ({
children = "No microphone found.",
...props
}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
export const MicSelectorItem = (props: MicSelectorItemProps) => {
const { onValueChange, onOpenChange } = useContext(MicSelectorContext);
const handleSelect = useCallback(
(currentValue: string) => {
onValueChange?.(currentValue);
onOpenChange?.(false);
},
[onValueChange, onOpenChange]
);
return <CommandItem onSelect={handleSelect} {...props} />;
};
export type MicSelectorLabelProps = ComponentProps<"span"> & {
device: MediaDeviceInfo;
};
export const MicSelectorLabel = ({
device,
className,
...props
}: MicSelectorLabelProps) => {
const matches = device.label.match(deviceIdRegex);
if (!matches) {
return (
<span className={className} {...props}>
{device.label}
</span>
);
}
const [, deviceId] = matches;
const name = device.label.replace(deviceIdRegex, "");
return (
<span className={className} {...props}>
<span>{name}</span>
<span className="text-muted-foreground"> ({deviceId})</span>
</span>
);
};
export type MicSelectorValueProps = ComponentProps<"span">;
export const MicSelectorValue = ({
className,
...props
}: MicSelectorValueProps) => {
const { data, value } = useContext(MicSelectorContext);
const currentDevice = data.find((d) => d.deviceId === value);
if (!currentDevice) {
return (
<span className={cn("flex-1 text-left", className)} {...props}>
Select microphone...
</span>
);
}
return (
<MicSelectorLabel
className={cn("flex-1 text-left", className)}
device={currentDevice}
{...props}
/>
);
};
@@ -0,0 +1,213 @@
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<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent
aria-describedby={undefined}
className={cn(
"outline! border-none! p-0 outline-border! outline-solid!",
className
)}
{...props}
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
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"
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-3 dark:invert", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={12}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"flex shrink-0 items-center -space-x-1 [&>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) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
);
+71
View File
@@ -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<typeof Card> & {
handles: {
target: boolean;
source: boolean;
};
};
export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card
className={cn(
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
className
)}
{...props}
>
{handles.target && <Handle position={Position.Left} type="target" />}
{handles.source && <Handle position={Position.Right} type="source" />}
{props.children}
</Card>
);
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
{...props}
/>
);
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
export const NodeDescription = (props: NodeDescriptionProps) => (
<CardDescription {...props} />
);
export type NodeActionProps = ComponentProps<typeof CardAction>;
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
export type NodeContentProps = ComponentProps<typeof CardContent>;
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
<CardContent className={cn("p-3", className)} {...props} />
);
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
{...props}
/>
);
@@ -0,0 +1,304 @@
"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 } from "react";
import { createContext, useContext, useMemo } from "react";
const providers = {
chatgpt: {
createUrl: (prompt: string) =>
`https://chatgpt.com/?${new URLSearchParams({
hints: "search",
prompt,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
title: "Open in ChatGPT",
},
claude: {
createUrl: (q: string) =>
`https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg"
>
<title>Claude</title>
<path
clipRule="evenodd"
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
fillRule="evenodd"
/>
</svg>
),
title: "Open in Claude",
},
cursor: {
createUrl: (text: string) => {
const url = new URL("https://cursor.com/link/prompt");
url.searchParams.set("text", text);
return url.toString();
},
icon: (
<svg
version="1.1"
viewBox="0 0 466.73 532.09"
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
fill="currentColor"
/>
</svg>
),
title: "Open in Cursor",
},
github: {
createUrl: (url: string) => url,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
title: "Open in GitHub",
},
scira: {
createUrl: (q: string) =>
`https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="none"
height="934"
viewBox="0 0 910 934"
width="910"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="30"
/>
</svg>
),
title: "Open in Scira",
},
t3: {
createUrl: (q: string) =>
`https://t3.chat/new?${new URLSearchParams({
q,
})}`,
icon: <MessageCircleIcon />,
title: "Open in T3 Chat",
},
v0: {
createUrl: (q: string) =>
`https://v0.app?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
viewBox="0 0 147 70"
xmlns="http://www.w3.org/2000/svg"
>
<title>v0</title>
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
</svg>
),
title: "Open in v0",
},
};
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<typeof DropdownMenu> & {
query: string;
};
export const OpenIn = ({ query, ...props }: OpenInProps) => {
const contextValue = useMemo(() => ({ query }), [query]);
return (
<OpenInContext.Provider value={contextValue}>
<DropdownMenu {...props} />
</OpenInContext.Provider>
);
};
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
<DropdownMenuContent
align="start"
className={cn("w-[240px]", className)}
{...props}
/>
);
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInItem = (props: OpenInItemProps) => (
<DropdownMenuItem {...props} />
);
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
export const OpenInLabel = (props: OpenInLabelProps) => (
<DropdownMenuLabel {...props} />
);
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
<DropdownMenuSeparator {...props} />
);
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
<DropdownMenuTrigger {...props}>
{children ?? (
<Button type="button" variant="outline">
Open in chat
<ChevronDownIcon className="size-4" />
</Button>
)}
</DropdownMenuTrigger>
);
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.chatgpt.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.chatgpt.icon}</span><span className="flex-1">{providers.chatgpt.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
);
};
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.claude.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.claude.icon}</span><span className="flex-1">{providers.claude.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
);
};
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.t3.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.t3.icon}</span><span className="flex-1">{providers.t3.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
);
};
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.scira.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.scira.icon}</span><span className="flex-1">{providers.scira.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
);
};
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.v0.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.v0.icon}</span><span className="flex-1">{providers.v0.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
);
};
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.cursor.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.cursor.icon}</span><span className="flex-1">{providers.cursor.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
);
};
@@ -0,0 +1,239 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { ArrowRightIcon, MinusIcon, PackageIcon, PlusIcon } from "lucide-react";
import type { HTMLAttributes } from "react";
import { createContext, useContext, useMemo } from "react";
type ChangeType = "major" | "minor" | "patch" | "added" | "removed";
interface PackageInfoContextType {
name: string;
currentVersion?: string;
newVersion?: string;
changeType?: ChangeType;
}
const PackageInfoContext = createContext<PackageInfoContextType>({
name: "",
});
export type PackageInfoHeaderProps = HTMLAttributes<HTMLDivElement>;
export const PackageInfoHeader = ({
className,
children,
...props
}: PackageInfoHeaderProps) => (
<div
className={cn("flex items-center justify-between gap-2", className)}
{...props}
>
{children}
</div>
);
export type PackageInfoNameProps = HTMLAttributes<HTMLDivElement>;
export const PackageInfoName = ({
className,
children,
...props
}: PackageInfoNameProps) => {
const { name } = useContext(PackageInfoContext);
return (
<div className={cn("flex items-center gap-2", className)} {...props}>
<PackageIcon className="size-4 text-muted-foreground" />
<span className="font-medium font-mono text-sm">{children ?? name}</span>
</div>
);
};
const changeTypeStyles: Record<ChangeType, string> = {
added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
minor:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
};
const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
added: <PlusIcon className="size-3" />,
major: <ArrowRightIcon className="size-3" />,
minor: <ArrowRightIcon className="size-3" />,
patch: <ArrowRightIcon className="size-3" />,
removed: <MinusIcon className="size-3" />,
};
export type PackageInfoChangeTypeProps = HTMLAttributes<HTMLDivElement>;
export const PackageInfoChangeType = ({
className,
children,
...props
}: PackageInfoChangeTypeProps) => {
const { changeType } = useContext(PackageInfoContext);
if (!changeType) {
return null;
}
return (
<Badge
className={cn(
"gap-1 text-xs capitalize",
changeTypeStyles[changeType],
className
)}
variant="secondary"
{...props}
>
{changeTypeIcons[changeType]}
{children ?? changeType}
</Badge>
);
};
export type PackageInfoVersionProps = HTMLAttributes<HTMLDivElement>;
export const PackageInfoVersion = ({
className,
children,
...props
}: PackageInfoVersionProps) => {
const { currentVersion, newVersion } = useContext(PackageInfoContext);
if (!(currentVersion || newVersion)) {
return null;
}
return (
<div
className={cn(
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? (
<>
{currentVersion && <span>{currentVersion}</span>}
{currentVersion && newVersion && (
<ArrowRightIcon className="size-3" />
)}
{newVersion && (
<span className="font-medium text-foreground">{newVersion}</span>
)}
</>
)}
</div>
);
};
export type PackageInfoProps = HTMLAttributes<HTMLDivElement> & {
name: string;
currentVersion?: string;
newVersion?: string;
changeType?: ChangeType;
};
export const PackageInfo = ({
name,
currentVersion,
newVersion,
changeType,
className,
children,
...props
}: PackageInfoProps) => {
const contextValue = useMemo(
() => ({ changeType, currentVersion, name, newVersion }),
[changeType, currentVersion, name, newVersion]
);
return (
<PackageInfoContext.Provider value={contextValue}>
<div
className={cn("rounded-lg border bg-background p-4", className)}
{...props}
>
{children ?? (
<>
<PackageInfoHeader>
<PackageInfoName />
{changeType && <PackageInfoChangeType />}
</PackageInfoHeader>
{(currentVersion || newVersion) && <PackageInfoVersion />}
</>
)}
</div>
</PackageInfoContext.Provider>
);
};
export type PackageInfoDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const PackageInfoDescription = ({
className,
children,
...props
}: PackageInfoDescriptionProps) => (
<p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
{children}
</p>
);
export type PackageInfoContentProps = HTMLAttributes<HTMLDivElement>;
export const PackageInfoContent = ({
className,
children,
...props
}: PackageInfoContentProps) => (
<div className={cn("mt-3 border-t pt-3", className)} {...props}>
{children}
</div>
);
export type PackageInfoDependenciesProps = HTMLAttributes<HTMLDivElement>;
export const PackageInfoDependencies = ({
className,
children,
...props
}: PackageInfoDependenciesProps) => (
<div className={cn("space-y-2", className)} {...props}>
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Dependencies
</span>
<div className="space-y-1">{children}</div>
</div>
);
export type PackageInfoDependencyProps = HTMLAttributes<HTMLDivElement> & {
name: string;
version?: string;
};
export const PackageInfoDependency = ({
name,
version,
className,
children,
...props
}: PackageInfoDependencyProps) => (
<div
className={cn("flex items-center justify-between text-sm", className)}
{...props}
>
{children ?? (
<>
<span className="font-mono text-muted-foreground">{name}</span>
{version && <span className="font-mono text-xs">{version}</span>}
</>
)}
</div>
);
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
import { Panel as PanelPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react";
type PanelProps = ComponentProps<typeof PanelPrimitive>;
export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive
className={cn(
"m-4 overflow-hidden rounded-md border bg-card p-1",
className
)}
{...props}
/>
);
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { cn } from "@/lib/utils";
import type { RiveParameters } from "@rive-app/react-webgl2";
import {
useRive,
useStateMachineInput,
useViewModel,
useViewModelInstance,
useViewModelInstanceColor,
} from "@rive-app/react-webgl2";
import type { FC, ReactNode } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
// Delays Rive initialization by one frame so that React Strict Mode's
// immediate unmount cycle never creates a WebGL2 context. Only the
// second (real) mount will initialise, avoiding context exhaustion.
const useStrictModeSafeInit = () => {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setReady(true));
return () => {
cancelAnimationFrame(id);
setReady(false);
};
}, []);
return ready;
};
export type PersonaState =
| "idle"
| "listening"
| "thinking"
| "speaking"
| "asleep";
interface PersonaProps {
state: PersonaState;
onLoad?: RiveParameters["onLoad"];
onLoadError?: RiveParameters["onLoadError"];
onReady?: () => void;
onPause?: RiveParameters["onPause"];
onPlay?: RiveParameters["onPlay"];
onStop?: RiveParameters["onStop"];
className?: string;
variant?: keyof typeof sources;
}
// The state machine name is always 'default' for Elements AI visuals
const stateMachine = "default";
const sources = {
command: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
},
glint: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
},
halo: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
},
mana: {
dynamicColor: false,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
},
obsidian: {
dynamicColor: true,
hasModel: true,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
},
opal: {
dynamicColor: false,
hasModel: false,
source:
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
},
};
const getCurrentTheme = (): "light" | "dark" => {
if (typeof window !== "undefined") {
if (document.documentElement.classList.contains("dark")) {
return "dark";
}
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
}
}
return "light";
};
const useTheme = (enabled: boolean) => {
const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme);
useEffect(() => {
// Skip if not enabled (avoids unnecessary observers for non-dynamic-color variants)
if (!enabled) {
return;
}
// Watch for classList changes
const observer = new MutationObserver(() => {
setTheme(getCurrentTheme());
});
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true,
});
// Watch for OS-level theme changes
let mql: MediaQueryList | null = null;
const handleMediaChange = () => {
setTheme(getCurrentTheme());
};
if (window.matchMedia) {
mql = window.matchMedia("(prefers-color-scheme: dark)");
mql.addEventListener("change", handleMediaChange);
}
return () => {
observer.disconnect();
if (mql) {
mql.removeEventListener("change", handleMediaChange);
}
};
}, [enabled]);
return theme;
};
interface PersonaWithModelProps {
rive: ReturnType<typeof useRive>["rive"];
source: (typeof sources)[keyof typeof sources];
children: React.ReactNode;
}
const PersonaWithModel = memo(
({ rive, source, children }: PersonaWithModelProps) => {
const theme = useTheme(source.dynamicColor);
const viewModel = useViewModel(rive, { useDefault: true });
const viewModelInstance = useViewModelInstance(viewModel, {
rive,
useDefault: true,
});
const viewModelInstanceColor = useViewModelInstanceColor(
"color",
viewModelInstance
);
useEffect(() => {
if (!(viewModelInstanceColor && source.dynamicColor)) {
return;
}
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
viewModelInstanceColor.setRgb(r, g, b);
}, [viewModelInstanceColor, theme, source.dynamicColor]);
return children;
}
);
PersonaWithModel.displayName = "PersonaWithModel";
interface PersonaWithoutModelProps {
children: ReactNode;
}
const PersonaWithoutModel = memo(
({ children }: PersonaWithoutModelProps) => children
);
PersonaWithoutModel.displayName = "PersonaWithoutModel";
export const Persona: FC<PersonaProps> = memo(
({
variant = "obsidian",
state = "idle",
onLoad,
onLoadError,
onReady,
onPause,
onPlay,
onStop,
className,
}) => {
const source = sources[variant];
if (!source) {
throw new Error(`Invalid variant: ${variant}`);
}
// Stabilize callbacks to prevent useRive from reinitializing
const callbacksRef = useRef({
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop,
});
useEffect(() => {
callbacksRef.current = {
onLoad,
onLoadError,
onPause,
onPlay,
onReady,
onStop,
};
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
const stableCallbacks = useMemo(
() => ({
onLoad: ((loadedRive) =>
callbacksRef.current.onLoad?.(
loadedRive
)) as RiveParameters["onLoad"],
onLoadError: ((err) =>
callbacksRef.current.onLoadError?.(
err
)) as RiveParameters["onLoadError"],
onPause: ((event) =>
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
onPlay: ((event) =>
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
onReady: () => callbacksRef.current.onReady?.(),
onStop: ((event) =>
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
}),
[]
);
// Delay initialisation by one frame to avoid creating (and leaking)
// a WebGL2 context during React Strict Mode's first throw-away mount.
const ready = useStrictModeSafeInit();
const { rive, RiveComponent } = useRive(
ready
? {
autoplay: true,
onLoad: stableCallbacks.onLoad,
onLoadError: stableCallbacks.onLoadError,
onPause: stableCallbacks.onPause,
onPlay: stableCallbacks.onPlay,
onRiveReady: stableCallbacks.onReady,
onStop: stableCallbacks.onStop,
src: source.source,
stateMachines: stateMachine,
}
: null
);
const listeningInput = useStateMachineInput(
rive,
stateMachine,
"listening"
);
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
// Rive state machine inputs are mutable objects that must be set via direct
// property assignment — this is the intended Rive API, not a React anti-pattern.
useEffect(() => {
if (listeningInput) {
listeningInput.value = state === "listening";
}
if (thinkingInput) {
thinkingInput.value = state === "thinking";
}
if (speakingInput) {
speakingInput.value = state === "speaking";
}
if (asleepInput) {
asleepInput.value = state === "asleep";
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
const Component = source.hasModel ? PersonaWithModel : PersonaWithoutModel;
return (
<Component rive={rive} source={source}>
<RiveComponent className={cn("size-16 shrink-0", className)} />
</Component>
);
}
);
Persona.displayName = "Persona";
+132
View File
@@ -0,0 +1,132 @@
"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, useMemo } from "react";
import { Shimmer } from "./shimmer";
interface PlanContextValue {
isStreaming: boolean;
}
const PlanContext = createContext<PlanContextValue | null>(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<typeof Collapsible> & {
isStreaming?: boolean;
};
export const Plan = ({
className,
isStreaming = false,
children,
...props
}: PlanProps) => {
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
return (
<PlanContext.Provider value={contextValue}>
<Collapsible data-slot="plan" {...props} render={<Card className={cn("shadow-none", className)} />}>{children}</Collapsible>
</PlanContext.Provider>
);
};
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn("flex items-start justify-between", className)}
data-slot="plan-header"
{...props}
/>
);
export type PlanTitleProps = Omit<
ComponentProps<typeof CardTitle>,
"children"
> & {
children: string;
};
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan();
return (
<CardTitle data-slot="plan-title" {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle>
);
};
export type PlanDescriptionProps = Omit<
ComponentProps<typeof CardDescription>,
"children"
> & {
children: string;
};
export const PlanDescription = ({
className,
children,
...props
}: PlanDescriptionProps) => {
const { isStreaming } = usePlan();
return (
<CardDescription
className={cn("text-balance", className)}
data-slot="plan-description"
{...props}
>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription>
);
};
export type PlanActionProps = ComponentProps<typeof CardAction>;
export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} />
);
export type PlanContentProps = ComponentProps<typeof CardContent>;
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent render={<CardContent data-slot="plan-content" {...props} />}></CollapsibleContent>
);
export type PlanFooterProps = ComponentProps<"div">;
export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} />
);
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger>
);
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
import type { ComponentProps } from "react";
export interface QueueMessagePart {
type: string;
text?: string;
url?: string;
filename?: string;
mediaType?: string;
}
export interface QueueMessage {
id: string;
parts: QueueMessagePart[];
}
export interface QueueTodo {
id: string;
title: string;
description?: string;
status?: "pending" | "completed";
}
export type QueueItemProps = ComponentProps<"li">;
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
<li
className={cn(
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
className
)}
{...props}
/>
);
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
completed?: boolean;
};
export const QueueItemIndicator = ({
completed = false,
className,
...props
}: QueueItemIndicatorProps) => (
<span
className={cn(
"mt-0.5 inline-block size-2.5 rounded-full border",
completed
? "border-muted-foreground/20 bg-muted-foreground/10"
: "border-muted-foreground/50",
className
)}
{...props}
/>
);
export type QueueItemContentProps = ComponentProps<"span"> & {
completed?: boolean;
};
export const QueueItemContent = ({
completed = false,
className,
...props
}: QueueItemContentProps) => (
<span
className={cn(
"line-clamp-1 grow break-words",
completed
? "text-muted-foreground/50 line-through"
: "text-muted-foreground",
className
)}
{...props}
/>
);
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
completed?: boolean;
};
export const QueueItemDescription = ({
completed = false,
className,
...props
}: QueueItemDescriptionProps) => (
<div
className={cn(
"ml-6 text-xs",
completed
? "text-muted-foreground/40 line-through"
: "text-muted-foreground",
className
)}
{...props}
/>
);
export type QueueItemActionsProps = ComponentProps<"div">;
export const QueueItemActions = ({
className,
...props
}: QueueItemActionsProps) => (
<div className={cn("flex gap-1", className)} {...props} />
);
export type QueueItemActionProps = Omit<
ComponentProps<typeof Button>,
"variant" | "size"
>;
export const QueueItemAction = ({
className,
...props
}: QueueItemActionProps) => (
<Button
className={cn(
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
className
)}
size="icon"
type="button"
variant="ghost"
{...props}
/>
);
export type QueueItemAttachmentProps = ComponentProps<"div">;
export const QueueItemAttachment = ({
className,
...props
}: QueueItemAttachmentProps) => (
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
);
export type QueueItemImageProps = ComponentProps<"img">;
export const QueueItemImage = ({
className,
...props
}: QueueItemImageProps) => (
<img
alt=""
className={cn("h-8 w-8 rounded border object-cover", className)}
height={32}
width={32}
{...props}
/>
);
export type QueueItemFileProps = ComponentProps<"span">;
export const QueueItemFile = ({
children,
className,
...props
}: QueueItemFileProps) => (
<span
className={cn(
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
className
)}
{...props}
>
<PaperclipIcon size={12} />
<span className="max-w-[100px] truncate">{children}</span>
</span>
);
export type QueueListProps = ComponentProps<typeof ScrollArea>;
export const QueueList = ({
children,
className,
...props
}: QueueListProps) => (
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
<div className="max-h-40 pr-4">
<ul>{children}</ul>
</div>
</ScrollArea>
);
// QueueSection - collapsible section container
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
export const QueueSection = ({
className,
defaultOpen = true,
...props
}: QueueSectionProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
// QueueSectionTrigger - section header/trigger
export type QueueSectionTriggerProps = ComponentProps<"button">;
export const QueueSectionTrigger = ({
children,
className,
...props
}: QueueSectionTriggerProps) => (
<CollapsibleTrigger render={<button className={cn(
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
className
)} type="button" {...props} />}>{children}</CollapsibleTrigger>
);
// QueueSectionLabel - label content with icon and count
export type QueueSectionLabelProps = ComponentProps<"span"> & {
count?: number;
label: string;
icon?: React.ReactNode;
};
export const QueueSectionLabel = ({
count,
label,
icon,
className,
...props
}: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
{icon}
<span>
{count} {label}
</span>
</span>
);
// QueueSectionContent - collapsible content area
export type QueueSectionContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const QueueSectionContent = ({
className,
...props
}: QueueSectionContentProps) => (
<CollapsibleContent className={cn(className)} {...props} />
);
export type QueueProps = ComponentProps<"div">;
export const Queue = ({ className, ...props }: QueueProps) => (
<div
className={cn(
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
className
)}
{...props}
/>
);
@@ -0,0 +1,226 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
+132
View File
@@ -0,0 +1,132 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai";
import { ChevronDownIcon, Code } from "lucide-react";
import type { ComponentProps } from "react";
import { getStatusBadge } from "./tool";
export type SandboxRootProps = ComponentProps<typeof Collapsible>;
export const Sandbox = ({ className, ...props }: SandboxRootProps) => (
<Collapsible
className={cn(
"not-prose group mb-4 w-full overflow-hidden rounded-md border",
className
)}
defaultOpen
{...props}
/>
);
export interface SandboxHeaderProps {
title?: string;
state: ToolUIPart["state"];
className?: string;
}
export const SandboxHeader = ({
className,
title,
state,
...props
}: SandboxHeaderProps) => (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<Code className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type SandboxContentProps = ComponentProps<typeof CollapsibleContent>;
export const SandboxContent = ({
className,
...props
}: SandboxContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SandboxTabsProps = ComponentProps<typeof Tabs>;
export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => (
<Tabs className={cn("w-full gap-0", className)} {...props} />
);
export type SandboxTabsBarProps = ComponentProps<"div">;
export const SandboxTabsBar = ({
className,
...props
}: SandboxTabsBarProps) => (
<div
className={cn(
"flex w-full items-center border-border border-t border-b",
className
)}
{...props}
/>
);
export type SandboxTabsListProps = ComponentProps<typeof TabsList>;
export const SandboxTabsList = ({
className,
...props
}: SandboxTabsListProps) => (
<TabsList
className={cn("h-auto rounded-none border-0 bg-transparent p-0", className)}
{...props}
/>
);
export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>;
export const SandboxTabsTrigger = ({
className,
...props
}: SandboxTabsTriggerProps) => (
<TabsTrigger
className={cn(
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
className
)}
{...props}
/>
);
export type SandboxTabContentProps = ComponentProps<typeof TabsContent>;
export const SandboxTabContent = ({
className,
...props
}: SandboxTabContentProps) => (
<TabsContent className={cn("mt-0 text-sm", className)} {...props} />
);
@@ -0,0 +1,471 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { createContext, useContext, useMemo } from "react";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface SchemaParameter {
name: string;
type: string;
required?: boolean;
description?: string;
location?: "path" | "query" | "header";
}
interface SchemaProperty {
name: string;
type: string;
required?: boolean;
description?: string;
properties?: SchemaProperty[];
items?: SchemaProperty;
}
interface SchemaDisplayContextType {
method: HttpMethod;
path: string;
description?: string;
parameters?: SchemaParameter[];
requestBody?: SchemaProperty[];
responseBody?: SchemaProperty[];
}
const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
method: "GET",
path: "",
});
const methodStyles: Record<HttpMethod, string> = {
DELETE: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
PATCH:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
};
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
export const SchemaDisplayHeader = ({
className,
children,
...props
}: SchemaDisplayHeaderProps) => (
<div
className={cn("flex items-center gap-3 border-b px-4 py-3", className)}
{...props}
>
{children}
</div>
);
export type SchemaDisplayMethodProps = ComponentProps<typeof Badge>;
export const SchemaDisplayMethod = ({
className,
children,
...props
}: SchemaDisplayMethodProps) => {
const { method } = useContext(SchemaDisplayContext);
return (
<Badge
className={cn("font-mono text-xs", methodStyles[method], className)}
variant="secondary"
{...props}
>
{children ?? method}
</Badge>
);
};
export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
export const SchemaDisplayPath = ({
className,
children,
...props
}: SchemaDisplayPathProps) => {
const { path } = useContext(SchemaDisplayContext);
// Highlight path parameters
const highlightedPath = path.replaceAll(
/\{([^}]+)\}/g,
'<span class="text-blue-600 dark:text-blue-400">{$1}</span>'
);
return (
<span
className={cn("font-mono text-sm", className)}
// oxlint-disable-next-line eslint-plugin-react(no-danger)
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
{...props}
/>
);
};
export type SchemaDisplayDescriptionProps =
HTMLAttributes<HTMLParagraphElement>;
export const SchemaDisplayDescription = ({
className,
children,
...props
}: SchemaDisplayDescriptionProps) => {
const { description } = useContext(SchemaDisplayContext);
return (
<p
className={cn(
"border-b px-4 py-3 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? description}
</p>
);
};
export type SchemaDisplayContentProps = HTMLAttributes<HTMLDivElement>;
export const SchemaDisplayContent = ({
className,
children,
...props
}: SchemaDisplayContentProps) => (
<div className={cn("divide-y", className)} {...props}>
{children}
</div>
);
export type SchemaDisplayParameterProps = HTMLAttributes<HTMLDivElement> &
SchemaParameter;
export const SchemaDisplayParameter = ({
name,
type,
required,
description,
location,
className,
...props
}: SchemaDisplayParameterProps) => (
<div className={cn("px-4 py-3 pl-10", className)} {...props}>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{name}</span>
<Badge className="text-xs" variant="outline">
{type}
</Badge>
{location && (
<Badge className="text-xs" variant="secondary">
{location}
</Badge>
)}
{required && (
<Badge
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
required
</Badge>
)}
</div>
{description && (
<p className="mt-1 text-muted-foreground text-sm">{description}</p>
)}
</div>
);
export type SchemaDisplayParametersProps = ComponentProps<typeof Collapsible>;
export const SchemaDisplayParameters = ({
className,
children,
...props
}: SchemaDisplayParametersProps) => {
const { parameters } = useContext(SchemaDisplayContext);
return (
<Collapsible className={cn(className)} defaultOpen {...props}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Parameters</span>
<Badge className="ml-auto text-xs" variant="secondary">
{parameters?.length}
</Badge>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y border-t">
{children ??
parameters?.map((param) => (
<SchemaDisplayParameter key={param.name} {...param} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
};
export type SchemaDisplayPropertyProps = HTMLAttributes<HTMLDivElement> &
SchemaProperty & {
depth?: number;
};
export const SchemaDisplayProperty = ({
name,
type,
required,
description,
properties,
items,
depth = 0,
className,
...props
}: SchemaDisplayPropertyProps) => {
const hasChildren = properties || items;
const paddingLeft = 40 + depth * 16;
if (hasChildren) {
return (
<Collapsible defaultOpen={depth < 2}>
<CollapsibleTrigger
className={cn(
"group flex w-full items-center gap-2 py-3 text-left transition-colors hover:bg-muted/50",
className
)}
style={{ paddingLeft }}
>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-mono text-sm">{name}</span>
<Badge className="text-xs" variant="outline">
{type}
</Badge>
{required && (
<Badge
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
required
</Badge>
)}
</CollapsibleTrigger>
{description && (
<p
className="pb-2 text-muted-foreground text-sm"
style={{ paddingLeft: paddingLeft + 24 }}
>
{description}
</p>
)}
<CollapsibleContent>
<div className="divide-y border-t">
{properties?.map((prop) => (
<SchemaDisplayProperty
key={prop.name}
{...prop}
depth={depth + 1}
/>
))}
{items && (
<SchemaDisplayProperty
{...items}
depth={depth + 1}
name={`${name}[]`}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
return (
<div
className={cn("py-3 pr-4", className)}
style={{ paddingLeft }}
{...props}
>
<div className="flex items-center gap-2">
{/* Spacer for alignment */}
<span className="size-4" />
<span className="font-mono text-sm">{name}</span>
<Badge className="text-xs" variant="outline">
{type}
</Badge>
{required && (
<Badge
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
required
</Badge>
)}
</div>
{description && (
<p className="mt-1 pl-6 text-muted-foreground text-sm">{description}</p>
)}
</div>
);
};
export type SchemaDisplayRequestProps = ComponentProps<typeof Collapsible>;
export const SchemaDisplayRequest = ({
className,
children,
...props
}: SchemaDisplayRequestProps) => {
const { requestBody } = useContext(SchemaDisplayContext);
return (
<Collapsible className={cn(className)} defaultOpen {...props}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Request Body</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
{children ??
requestBody?.map((prop) => (
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
};
export type SchemaDisplayResponseProps = ComponentProps<typeof Collapsible>;
export const SchemaDisplayResponse = ({
className,
children,
...props
}: SchemaDisplayResponseProps) => {
const { responseBody } = useContext(SchemaDisplayContext);
return (
<Collapsible className={cn(className)} defaultOpen {...props}>
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<span className="font-medium text-sm">Response</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
{children ??
responseBody?.map((prop) => (
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
};
export type SchemaDisplayProps = HTMLAttributes<HTMLDivElement> & {
method: HttpMethod;
path: string;
description?: string;
parameters?: SchemaParameter[];
requestBody?: SchemaProperty[];
responseBody?: SchemaProperty[];
};
export const SchemaDisplay = ({
method,
path,
description,
parameters,
requestBody,
responseBody,
className,
children,
...props
}: SchemaDisplayProps) => {
const contextValue = useMemo(
() => ({
description,
method,
parameters,
path,
requestBody,
responseBody,
}),
[description, method, parameters, path, requestBody, responseBody]
);
return (
<SchemaDisplayContext.Provider value={contextValue}>
<div
className={cn(
"overflow-hidden rounded-lg border bg-background",
className
)}
{...props}
>
{children ?? (
<>
<SchemaDisplayHeader>
<div className="flex items-center gap-3">
<SchemaDisplayMethod />
<SchemaDisplayPath />
</div>
</SchemaDisplayHeader>
{description && <SchemaDisplayDescription />}
<SchemaDisplayContent>
{parameters && parameters.length > 0 && (
<SchemaDisplayParameters />
)}
{requestBody && requestBody.length > 0 && (
<SchemaDisplayRequest />
)}
{responseBody && responseBody.length > 0 && (
<SchemaDisplayResponse />
)}
</SchemaDisplayContent>
</>
)}
</div>
</SchemaDisplayContext.Provider>
);
};
export type SchemaDisplayBodyProps = HTMLAttributes<HTMLDivElement>;
export const SchemaDisplayBody = ({
className,
children,
...props
}: SchemaDisplayBodyProps) => (
<div className={cn("divide-y", className)} {...props}>
{children}
</div>
);
export type SchemaDisplayExampleProps = HTMLAttributes<HTMLPreElement>;
export const SchemaDisplayExample = ({
className,
children,
...props
}: SchemaDisplayExampleProps) => (
<pre
className={cn(
"mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-sm",
className
)}
{...props}
>
{children}
</pre>
);
@@ -0,0 +1,77 @@
"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+145
View File
@@ -0,0 +1,145 @@
"use client";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface SnippetContextType {
code: string;
}
const SnippetContext = createContext<SnippetContextType>({
code: "",
});
export type SnippetProps = ComponentProps<typeof InputGroup> & {
code: string;
};
export const Snippet = ({
code,
className,
children,
...props
}: SnippetProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<SnippetContext.Provider value={contextValue}>
<InputGroup className={cn("font-mono", className)} {...props}>
{children}
</InputGroup>
</SnippetContext.Provider>
);
};
export type SnippetAddonProps = ComponentProps<typeof InputGroupAddon>;
export const SnippetAddon = (props: SnippetAddonProps) => (
<InputGroupAddon {...props} />
);
export type SnippetTextProps = ComponentProps<typeof InputGroupText>;
export const SnippetText = ({ className, ...props }: SnippetTextProps) => (
<InputGroupText
className={cn("pl-2 font-normal text-muted-foreground", className)}
{...props}
/>
);
export type SnippetInputProps = Omit<
ComponentProps<typeof InputGroupInput>,
"readOnly" | "value"
>;
export const SnippetInput = ({ className, ...props }: SnippetInputProps) => {
const { code } = useContext(SnippetContext);
return (
<InputGroupInput
className={cn("text-foreground", className)}
readOnly
value={code}
{...props}
/>
);
};
export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const SnippetCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: SnippetCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(SnippetContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<InputGroupButton
aria-label="Copy"
className={className}
onClick={copyToClipboard}
size="icon-sm"
title="Copy"
{...props}
>
{children ?? <Icon className="size-3.5" size={14} />}
</InputGroupButton>
);
};
@@ -0,0 +1,77 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger
className={cn("flex items-center gap-2", className)}
{...props}
>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);
@@ -0,0 +1,323 @@
"use client";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { MicIcon, SquareIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
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;
}
interface SpeechRecognitionResultList {
readonly length: number;
item(index: number): SpeechRecognitionResult;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
readonly length: number;
item(index: number): SpeechRecognitionAlternative;
[index: number]: SpeechRecognitionAlternative;
isFinal: boolean;
}
interface SpeechRecognitionAlternative {
transcript: string;
confidence: number;
}
interface SpeechRecognitionErrorEvent extends Event {
error: string;
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition;
webkitSpeechRecognition: new () => SpeechRecognition;
}
}
type SpeechInputMode = "speech-recognition" | "media-recorder" | "none";
export type SpeechInputProps = ComponentProps<typeof Button> & {
onTranscriptionChange?: (text: string) => void;
/**
* Callback for when audio is recorded using MediaRecorder fallback.
* This is called in browsers that don't support the Web Speech API (Firefox, Safari).
* The callback receives an audio Blob that should be sent to a transcription service.
* Return the transcribed text, which will be passed to onTranscriptionChange.
*/
onAudioRecorded?: (audioBlob: Blob) => Promise<string>;
lang?: string;
};
const detectSpeechInputMode = (): SpeechInputMode => {
if (typeof window === "undefined") {
return "none";
}
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
return "speech-recognition";
}
if ("MediaRecorder" in window && "mediaDevices" in navigator) {
return "media-recorder";
}
return "none";
};
export const SpeechInput = ({
className,
onTranscriptionChange,
onAudioRecorded,
lang = "en-US",
...props
}: SpeechInputProps) => {
const [isListening, setIsListening] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [mode] = useState<SpeechInputMode>(detectSpeechInputMode);
const [isRecognitionReady, setIsRecognitionReady] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const onTranscriptionChangeRef = useRef<
SpeechInputProps["onTranscriptionChange"]
>(onTranscriptionChange);
const onAudioRecordedRef =
useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
// Keep refs in sync
onTranscriptionChangeRef.current = onTranscriptionChange;
onAudioRecordedRef.current = onAudioRecorded;
// Initialize Speech Recognition when mode is speech-recognition
useEffect(() => {
if (mode !== "speech-recognition") {
return;
}
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
const speechRecognition = new SpeechRecognition();
speechRecognition.continuous = true;
speechRecognition.interimResults = true;
speechRecognition.lang = lang;
const handleStart = () => {
setIsListening(true);
};
const handleEnd = () => {
setIsListening(false);
};
const handleResult = (event: Event) => {
const speechEvent = event as SpeechRecognitionEvent;
let finalTranscript = "";
for (
let i = speechEvent.resultIndex;
i < speechEvent.results.length;
i += 1
) {
const result = speechEvent.results[i];
if (result.isFinal) {
finalTranscript += result[0]?.transcript ?? "";
}
}
if (finalTranscript) {
onTranscriptionChangeRef.current?.(finalTranscript);
}
};
const handleError = () => {
setIsListening(false);
};
speechRecognition.addEventListener("start", handleStart);
speechRecognition.addEventListener("end", handleEnd);
speechRecognition.addEventListener("result", handleResult);
speechRecognition.addEventListener("error", handleError);
recognitionRef.current = speechRecognition;
setIsRecognitionReady(true);
return () => {
speechRecognition.removeEventListener("start", handleStart);
speechRecognition.removeEventListener("end", handleEnd);
speechRecognition.removeEventListener("result", handleResult);
speechRecognition.removeEventListener("error", handleError);
speechRecognition.stop();
recognitionRef.current = null;
setIsRecognitionReady(false);
};
}, [mode, lang]);
// Cleanup MediaRecorder and stream on unmount
useEffect(
() => () => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) {
track.stop();
}
}
},
[]
);
// Start MediaRecorder recording
const startMediaRecorder = useCallback(async () => {
if (!onAudioRecordedRef.current) {
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mediaRecorder = new MediaRecorder(stream);
audioChunksRef.current = [];
const handleDataAvailable = (event: BlobEvent) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
const handleStop = async () => {
for (const track of stream.getTracks()) {
track.stop();
}
streamRef.current = null;
const audioBlob = new Blob(audioChunksRef.current, {
type: "audio/webm",
});
if (audioBlob.size > 0 && onAudioRecordedRef.current) {
setIsProcessing(true);
try {
const transcript = await onAudioRecordedRef.current(audioBlob);
if (transcript) {
onTranscriptionChangeRef.current?.(transcript);
}
} catch {
// Error handling delegated to the onAudioRecorded caller
} finally {
setIsProcessing(false);
}
}
};
const handleError = () => {
setIsListening(false);
for (const track of stream.getTracks()) {
track.stop();
}
streamRef.current = null;
};
mediaRecorder.addEventListener("dataavailable", handleDataAvailable);
mediaRecorder.addEventListener("stop", handleStop);
mediaRecorder.addEventListener("error", handleError);
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.start();
setIsListening(true);
} catch {
setIsListening(false);
}
}, []);
// Stop MediaRecorder recording
const stopMediaRecorder = useCallback(() => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
setIsListening(false);
}, []);
const toggleListening = useCallback(() => {
if (mode === "speech-recognition" && recognitionRef.current) {
if (isListening) {
recognitionRef.current.stop();
} else {
recognitionRef.current.start();
}
} else if (mode === "media-recorder") {
if (isListening) {
stopMediaRecorder();
} else {
startMediaRecorder();
}
}
}, [mode, isListening, startMediaRecorder, stopMediaRecorder]);
// Determine if button should be disabled
const isDisabled =
mode === "none" ||
(mode === "speech-recognition" && !isRecognitionReady) ||
(mode === "media-recorder" && !onAudioRecorded) ||
isProcessing;
return (
<div className="relative inline-flex items-center justify-center">
{/* Animated pulse rings */}
{isListening &&
[0, 1, 2].map((index) => (
<div
className="absolute inset-0 animate-ping rounded-full border-2 border-red-400/30"
key={index}
style={{
animationDelay: `${index * 0.3}s`,
animationDuration: "2s",
}}
/>
))}
{/* Main record button */}
<Button
className={cn(
"relative z-10 rounded-full transition-all duration-300",
isListening
? "bg-destructive text-white hover:bg-destructive/80 hover:text-white"
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
className
)}
disabled={isDisabled}
onClick={toggleListening}
{...props}
>
{isProcessing && <Spinner />}
{!isProcessing && isListening && <SquareIcon className="size-4" />}
{!(isProcessing || isListening) && <MicIcon className="size-4" />}
</Button>
</div>
);
};
@@ -0,0 +1,522 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
AlertTriangleIcon,
CheckIcon,
ChevronDownIcon,
CopyIcon,
} from "lucide-react";
import type { ComponentProps } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
// Regex patterns for parsing stack traces
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
const AT_PREFIX_REGEX = /^at\s+/;
interface StackFrame {
raw: string;
functionName: string | null;
filePath: string | null;
lineNumber: number | null;
columnNumber: number | null;
isInternal: boolean;
}
interface ParsedStackTrace {
errorType: string | null;
errorMessage: string;
frames: StackFrame[];
raw: string;
}
interface StackTraceContextValue {
trace: ParsedStackTrace;
raw: string;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
}
const StackTraceContext = createContext<StackTraceContextValue | null>(null);
const useStackTrace = () => {
const context = useContext(StackTraceContext);
if (!context) {
throw new Error("StackTrace components must be used within StackTrace");
}
return context;
};
const parseStackFrame = (line: string): StackFrame => {
const trimmed = line.trim();
// Pattern: at functionName (filePath:line:column)
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
if (withParensMatch) {
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
const isInternal =
filePath.includes("node_modules") ||
filePath.startsWith("node:") ||
filePath.includes("internal/");
return {
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
filePath: filePath ?? null,
functionName: functionName ?? null,
isInternal,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
raw: trimmed,
};
}
// Pattern: at filePath:line:column (no function name)
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
if (withoutFnMatch) {
const [, filePath, lineNum, colNum] = withoutFnMatch;
const isInternal =
(filePath?.includes("node_modules") ?? false) ||
(filePath?.startsWith("node:") ?? false) ||
(filePath?.includes("internal/") ?? false);
return {
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
filePath: filePath ?? null,
functionName: null,
isInternal,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
raw: trimmed,
};
}
// Fallback: unparseable line
return {
columnNumber: null,
filePath: null,
functionName: null,
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
lineNumber: null,
raw: trimmed,
};
};
const parseStackTrace = (trace: string): ParsedStackTrace => {
const lines = trace.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
return {
errorMessage: trace,
errorType: null,
frames: [],
raw: trace,
};
}
const firstLine = lines[0].trim();
let errorType: string | null = null;
let errorMessage = firstLine;
// Try to extract error type from "ErrorType: message" format
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
if (errorMatch) {
const [, type, msg] = errorMatch;
errorType = type;
errorMessage = msg || "";
}
// Parse stack frames (lines starting with "at")
const frames = lines
.slice(1)
.filter((line) => line.trim().startsWith("at "))
.map(parseStackFrame);
return {
errorMessage,
errorType,
frames,
raw: trace,
};
};
export type StackTraceProps = ComponentProps<"div"> & {
trace: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
};
export const StackTrace = memo(
({
trace,
className,
open,
defaultOpen = false,
onOpenChange,
onFilePathClick,
children,
...props
}: StackTraceProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace]);
const contextValue = useMemo(
() => ({
isOpen,
onFilePathClick,
raw: trace,
setIsOpen,
trace: parsedTrace,
}),
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick]
);
return (
<StackTraceContext.Provider value={contextValue}>
<div
className={cn(
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-sm",
className
)}
{...props}
>
{children}
</div>
</StackTraceContext.Provider>
);
}
);
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
export const StackTraceHeader = memo(
({ className, children, ...props }: StackTraceHeaderProps) => {
const { isOpen, setIsOpen } = useStackTrace();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger {...props} render={<div className={cn(
"flex w-full cursor-pointer items-center gap-3 p-3 text-left transition-colors hover:bg-muted/50",
className
)} />}>{children}</CollapsibleTrigger>
</Collapsible>
);
}
);
export type StackTraceErrorProps = ComponentProps<"div">;
export const StackTraceError = memo(
({ className, children, ...props }: StackTraceErrorProps) => (
<div
className={cn(
"flex flex-1 items-center gap-2 overflow-hidden",
className
)}
{...props}
>
<AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
{children}
</div>
)
);
export type StackTraceErrorTypeProps = ComponentProps<"span">;
export const StackTraceErrorType = memo(
({ className, children, ...props }: StackTraceErrorTypeProps) => {
const { trace } = useStackTrace();
return (
<span
className={cn("shrink-0 font-semibold text-destructive", className)}
{...props}
>
{children ?? trace.errorType}
</span>
);
}
);
export type StackTraceErrorMessageProps = ComponentProps<"span">;
export const StackTraceErrorMessage = memo(
({ className, children, ...props }: StackTraceErrorMessageProps) => {
const { trace } = useStackTrace();
return (
<span className={cn("truncate text-foreground", className)} {...props}>
{children ?? trace.errorMessage}
</span>
);
}
);
export type StackTraceActionsProps = ComponentProps<"div">;
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
const handleActionsKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
};
export const StackTraceActions = memo(
({ className, children, ...props }: StackTraceActionsProps) => (
<div
className={cn("flex shrink-0 items-center gap-1", className)}
onClick={handleActionsClick}
onKeyDown={handleActionsKeyDown}
role="group"
{...props}
>
{children}
</div>
)
);
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const StackTraceCopyButton = memo(
({
onCopy,
onError,
timeout = 2000,
className,
children,
...props
}: StackTraceCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { raw } = useStackTrace();
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(raw);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
} catch (error) {
onError?.(error as Error);
}
}, [raw, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("size-7", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
}
);
export type StackTraceExpandButtonProps = ComponentProps<"div">;
export const StackTraceExpandButton = memo(
({ className, ...props }: StackTraceExpandButtonProps) => {
const { isOpen } = useStackTrace();
return (
<div
className={cn("flex size-7 items-center justify-center", className)}
{...props}
>
<ChevronDownIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</div>
);
}
);
export type StackTraceContentProps = ComponentProps<
typeof CollapsibleContent
> & {
maxHeight?: number;
};
export const StackTraceContent = memo(
({
className,
maxHeight = 400,
children,
...props
}: StackTraceContentProps) => {
const { isOpen } = useStackTrace();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"overflow-auto border-t bg-muted/30",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
style={{ maxHeight }}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
}
);
export type StackTraceFramesProps = ComponentProps<"div"> & {
showInternalFrames?: boolean;
};
interface FilePathButtonProps {
frame: StackFrame;
onFilePathClick?: (
filePath: string,
lineNumber?: number,
columnNumber?: number
) => void;
}
const FilePathButton = memo(
({ frame, onFilePathClick }: FilePathButtonProps) => {
const handleClick = useCallback(() => {
if (frame.filePath) {
onFilePathClick?.(
frame.filePath,
frame.lineNumber ?? undefined,
frame.columnNumber ?? undefined
);
}
}, [frame, onFilePathClick]);
return (
<button
className={cn(
"underline decoration-dotted hover:text-primary",
onFilePathClick && "cursor-pointer"
)}
disabled={!onFilePathClick}
onClick={handleClick}
type="button"
>
{frame.filePath}
{frame.lineNumber !== null && `:${frame.lineNumber}`}
{frame.columnNumber !== null && `:${frame.columnNumber}`}
</button>
);
}
);
FilePathButton.displayName = "FilePathButton";
export const StackTraceFrames = memo(
({
className,
showInternalFrames = true,
...props
}: StackTraceFramesProps) => {
const { trace, onFilePathClick } = useStackTrace();
const framesToShow = showInternalFrames
? trace.frames
: trace.frames.filter((f) => !f.isInternal);
return (
<div className={cn("space-y-1 p-3", className)} {...props}>
{framesToShow.map((frame) => (
<div
className={cn(
"text-xs",
frame.isInternal
? "text-muted-foreground/50"
: "text-foreground/90"
)}
key={frame.raw}
>
<span className="text-muted-foreground">at </span>
{frame.functionName && (
<span className={frame.isInternal ? "" : "text-foreground"}>
{frame.functionName}{" "}
</span>
)}
{frame.filePath && (
<>
<span className="text-muted-foreground">(</span>
<FilePathButton
frame={frame}
onFilePathClick={onFilePathClick}
/>
<span className="text-muted-foreground">)</span>
</>
)}
{!(frame.filePath || frame.functionName) && (
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
)}
</div>
))}
{framesToShow.length === 0 && (
<div className="text-muted-foreground text-xs">No stack frames</div>
)}
</div>
);
}
);
StackTrace.displayName = "StackTrace";
StackTraceHeader.displayName = "StackTraceHeader";
StackTraceError.displayName = "StackTraceError";
StackTraceErrorType.displayName = "StackTraceErrorType";
StackTraceErrorMessage.displayName = "StackTraceErrorMessage";
StackTraceActions.displayName = "StackTraceActions";
StackTraceCopyButton.displayName = "StackTraceCopyButton";
StackTraceExpandButton.displayName = "StackTraceExpandButton";
StackTraceContent.displayName = "StackTraceContent";
StackTraceFrames.displayName = "StackTraceFrames";
@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
+87
View File
@@ -0,0 +1,87 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger className={cn("group", className)} {...props}>
{children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);
@@ -0,0 +1,273 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import Ansi from "ansi-to-react";
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface TerminalContextType {
output: string;
isStreaming: boolean;
autoScroll: boolean;
onClear?: () => void;
}
const TerminalContext = createContext<TerminalContextType>({
autoScroll: true,
isStreaming: false,
output: "",
});
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
export const TerminalHeader = ({
className,
children,
...props
}: TerminalHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-zinc-800 border-b px-4 py-2",
className
)}
{...props}
>
{children}
</div>
);
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>;
export const TerminalTitle = ({
className,
children,
...props
}: TerminalTitleProps) => (
<div
className={cn("flex items-center gap-2 text-sm text-zinc-400", className)}
{...props}
>
<TerminalIcon className="size-4" />
{children ?? "Terminal"}
</div>
);
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>;
export const TerminalStatus = ({
className,
children,
...props
}: TerminalStatusProps) => {
const { isStreaming } = useContext(TerminalContext);
if (!isStreaming) {
return null;
}
return (
<div
className={cn("flex items-center gap-2 text-xs text-zinc-400", className)}
{...props}
>
{children}
</div>
);
};
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>;
export const TerminalActions = ({
className,
children,
...props
}: TerminalActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const TerminalCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: TerminalCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { output } = useContext(TerminalContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(output);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
}, [output, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn(
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
className
)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type TerminalClearButtonProps = ComponentProps<typeof Button>;
export const TerminalClearButton = ({
children,
className,
...props
}: TerminalClearButtonProps) => {
const { onClear } = useContext(TerminalContext);
if (!onClear) {
return null;
}
return (
<Button
className={cn(
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
className
)}
onClick={onClear}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Trash2Icon size={14} />}
</Button>
);
};
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>;
export const TerminalContent = ({
className,
children,
...props
}: TerminalContentProps) => {
const { output, isStreaming, autoScroll } = useContext(TerminalContext);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [output, autoScroll]);
return (
<div
className={cn(
"max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed",
className
)}
ref={containerRef}
{...props}
>
{children ?? (
<pre className="whitespace-pre-wrap break-words">
<Ansi>{output}</Ansi>
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100" />
)}
</pre>
)}
</div>
);
};
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
output: string;
isStreaming?: boolean;
autoScroll?: boolean;
onClear?: () => void;
};
export const Terminal = ({
output,
isStreaming = false,
autoScroll = true,
onClear,
className,
children,
...props
}: TerminalProps) => {
const contextValue = useMemo(
() => ({ autoScroll, isStreaming, onClear, output }),
[autoScroll, isStreaming, onClear, output]
);
return (
<TerminalContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
className
)}
{...props}
>
{children ?? (
<>
<TerminalHeader>
<TerminalTitle />
<div className="flex items-center gap-1">
<TerminalStatus />
<TerminalActions>
<TerminalCopyButton />
{onClear && <TerminalClearButton />}
</TerminalActions>
</div>
</TerminalHeader>
<TerminalContent />
</>
)}
</div>
</TerminalContext.Provider>
);
};
@@ -0,0 +1,496 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckCircle2Icon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { createContext, useContext, useMemo } from "react";
type TestStatus = "passed" | "failed" | "skipped" | "running";
interface TestResultsSummary {
passed: number;
failed: number;
skipped: number;
total: number;
duration?: number;
}
interface TestResultsContextType {
summary?: TestResultsSummary;
}
const TestResultsContext = createContext<TestResultsContextType>({});
const formatDuration = (ms: number) => {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
};
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsHeader = ({
className,
children,
...props
}: TestResultsHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b px-4 py-3",
className
)}
{...props}
>
{children}
</div>
);
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
export const TestResultsDuration = ({
className,
children,
...props
}: TestResultsDurationProps) => {
const { summary } = useContext(TestResultsContext);
if (!summary?.duration) {
return null;
}
return (
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
{children ?? formatDuration(summary.duration)}
</span>
);
};
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsSummary = ({
className,
children,
...props
}: TestResultsSummaryProps) => {
const { summary } = useContext(TestResultsContext);
if (!summary) {
return null;
}
return (
<div className={cn("flex items-center gap-3", className)} {...props}>
{children ?? (
<>
<Badge
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
variant="secondary"
>
<CheckCircle2Icon className="size-3" />
{summary.passed} passed
</Badge>
{summary.failed > 0 && (
<Badge
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
<XCircleIcon className="size-3" />
{summary.failed} failed
</Badge>
)}
{summary.skipped > 0 && (
<Badge
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
variant="secondary"
>
<CircleIcon className="size-3" />
{summary.skipped} skipped
</Badge>
)}
</>
)}
</div>
);
};
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
summary?: TestResultsSummary;
};
export const TestResults = ({
summary,
className,
children,
...props
}: TestResultsProps) => {
const contextValue = useMemo(() => ({ summary }), [summary]);
return (
<TestResultsContext.Provider value={contextValue}>
<div
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children ??
(summary && (
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
))}
</div>
</TestResultsContext.Provider>
);
};
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsProgress = ({
className,
children,
...props
}: TestResultsProgressProps) => {
const { summary } = useContext(TestResultsContext);
if (!summary) {
return null;
}
const passedPercent = (summary.passed / summary.total) * 100;
const failedPercent = (summary.failed / summary.total) * 100;
return (
<div className={cn("space-y-2", className)} {...props}>
{children ?? (
<>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div
className="bg-green-500 transition-all"
style={{ width: `${passedPercent}%` }}
/>
<div
className="bg-red-500 transition-all"
style={{ width: `${failedPercent}%` }}
/>
</div>
<div className="flex justify-between text-muted-foreground text-xs">
<span>
{summary.passed}/{summary.total} tests passed
</span>
<span>{passedPercent.toFixed(0)}%</span>
</div>
</>
)}
</div>
);
};
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsContent = ({
className,
children,
...props
}: TestResultsContentProps) => (
<div className={cn("space-y-2 p-4", className)} {...props}>
{children}
</div>
);
interface TestSuiteContextType {
name: string;
status: TestStatus;
}
const TestSuiteContext = createContext<TestSuiteContextType>({
name: "",
status: "passed",
});
const statusStyles: Record<TestStatus, string> = {
failed: "text-red-600 dark:text-red-400",
passed: "text-green-600 dark:text-green-400",
running: "text-blue-600 dark:text-blue-400",
skipped: "text-yellow-600 dark:text-yellow-400",
};
const statusIcons: Record<TestStatus, React.ReactNode> = {
failed: <XCircleIcon className="size-4" />,
passed: <CheckCircle2Icon className="size-4" />,
running: <CircleDotIcon className="size-4 animate-pulse" />,
skipped: <CircleIcon className="size-4" />,
};
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
<span className={cn("shrink-0", statusStyles[status])}>
{statusIcons[status]}
</span>
);
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
name: string;
status: TestStatus;
};
export const TestSuite = ({
name,
status,
className,
children,
...props
}: TestSuiteProps) => {
const contextValue = useMemo(() => ({ name, status }), [name, status]);
return (
<TestSuiteContext.Provider value={contextValue}>
<Collapsible className={cn("rounded-lg border", className)} {...props}>
{children}
</Collapsible>
</TestSuiteContext.Provider>
);
};
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
export const TestSuiteName = ({
className,
children,
...props
}: TestSuiteNameProps) => {
const { name, status } = useContext(TestSuiteContext);
return (
<CollapsibleTrigger
className={cn(
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
className
)}
{...props}
>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<TestStatusIcon status={status} />
<span className="font-medium text-sm">{children ?? name}</span>
</CollapsibleTrigger>
);
};
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
passed?: number;
failed?: number;
skipped?: number;
};
export const TestSuiteStats = ({
passed = 0,
failed = 0,
skipped = 0,
className,
children,
...props
}: TestSuiteStatsProps) => (
<div
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
{...props}
>
{children ?? (
<>
{passed > 0 && (
<span className="text-green-600 dark:text-green-400">
{passed} passed
</span>
)}
{failed > 0 && (
<span className="text-red-600 dark:text-red-400">
{failed} failed
</span>
)}
{skipped > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">
{skipped} skipped
</span>
)}
</>
)}
</div>
);
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
export const TestSuiteContent = ({
className,
children,
...props
}: TestSuiteContentProps) => (
<CollapsibleContent className={cn("border-t", className)} {...props}>
<div className="divide-y">{children}</div>
</CollapsibleContent>
);
interface TestContextType {
name: string;
status: TestStatus;
duration?: number;
}
const TestContext = createContext<TestContextType>({
name: "",
status: "passed",
});
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
export const TestName = ({ className, children, ...props }: TestNameProps) => {
const { name } = useContext(TestContext);
return (
<span className={cn("flex-1", className)} {...props}>
{children ?? name}
</span>
);
};
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
export const TestDuration = ({
className,
children,
...props
}: TestDurationProps) => {
const { duration } = useContext(TestContext);
if (duration === undefined) {
return null;
}
return (
<span
className={cn("ml-auto text-muted-foreground text-xs", className)}
{...props}
>
{children ?? `${duration}ms`}
</span>
);
};
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
export const TestStatus = ({
className,
children,
...props
}: TestStatusProps) => {
const { status } = useContext(TestContext);
return (
<span
className={cn("shrink-0", statusStyles[status], className)}
{...props}
>
{children ?? statusIcons[status]}
</span>
);
};
export type TestProps = HTMLAttributes<HTMLDivElement> & {
name: string;
status: TestStatus;
duration?: number;
};
export const Test = ({
name,
status,
duration,
className,
children,
...props
}: TestProps) => {
const contextValue = useMemo(
() => ({ duration, name, status }),
[duration, name, status]
);
return (
<TestContext.Provider value={contextValue}>
<div
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
{...props}
>
{children ?? (
<>
<TestStatus />
<TestName />
{duration !== undefined && <TestDuration />}
</>
)}
</div>
</TestContext.Provider>
);
};
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
export const TestError = ({
className,
children,
...props
}: TestErrorProps) => (
<div
className={cn(
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
className
)}
{...props}
>
{children}
</div>
);
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
export const TestErrorMessage = ({
className,
children,
...props
}: TestErrorMessageProps) => (
<p
className={cn(
"font-medium text-red-700 text-sm dark:text-red-400",
className
)}
{...props}
>
{children}
</p>
);
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
export const TestErrorStack = ({
className,
children,
...props
}: TestErrorStackProps) => (
<pre
className={cn(
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
className
)}
{...props}
>
{children}
</pre>
);
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};
@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { NodeToolbar, Position } from "@xyflow/react";
import type { ComponentProps } from "react";
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar
className={cn(
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
className
)}
position={Position.Bottom}
{...props}
/>
);
@@ -0,0 +1,125 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { cn } from "@/lib/utils";
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
type TranscriptionSegment = TranscriptionResult["segments"][number];
interface TranscriptionContextValue {
segments: TranscriptionSegment[];
currentTime: number;
onTimeUpdate: (time: number) => void;
onSeek?: (time: number) => void;
}
const TranscriptionContext = createContext<TranscriptionContextValue | null>(
null
);
const useTranscription = () => {
const context = useContext(TranscriptionContext);
if (!context) {
throw new Error(
"Transcription components must be used within Transcription"
);
}
return context;
};
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
segments: TranscriptionSegment[];
currentTime?: number;
onSeek?: (time: number) => void;
children: (segment: TranscriptionSegment, index: number) => ReactNode;
};
export const Transcription = ({
segments,
currentTime: externalCurrentTime,
onSeek,
className,
children,
...props
}: TranscriptionProps) => {
const [currentTime, setCurrentTime] = useControllableState({
defaultProp: 0,
onChange: onSeek,
prop: externalCurrentTime,
});
const contextValue = useMemo(
() => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
[currentTime, onSeek, setCurrentTime, segments]
);
return (
<TranscriptionContext.Provider value={contextValue}>
<div
className={cn(
"flex flex-wrap gap-1 text-sm leading-relaxed",
className
)}
data-slot="transcription"
{...props}
>
{segments
.filter((segment) => segment.text.trim())
.map((segment, index) => children(segment, index))}
</div>
</TranscriptionContext.Provider>
);
};
export type TranscriptionSegmentProps = ComponentProps<"button"> & {
segment: TranscriptionSegment;
index: number;
};
export const TranscriptionSegment = ({
segment,
index,
className,
onClick,
...props
}: TranscriptionSegmentProps) => {
const { currentTime, onSeek } = useTranscription();
const isActive =
currentTime >= segment.startSecond && currentTime < segment.endSecond;
const isPast = currentTime >= segment.endSecond;
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (onSeek) {
onSeek(segment.startSecond);
}
onClick?.(event);
},
[onSeek, segment.startSecond, onClick]
);
return (
<button
className={cn(
"inline text-left",
isActive && "text-primary",
isPast && "text-muted-foreground",
!(isActive || isPast) && "text-muted-foreground/60",
onSeek && "cursor-pointer hover:text-foreground",
!onSeek && "cursor-default",
className
)}
data-active={isActive}
data-index={index}
data-slot="transcription-segment"
onClick={handleClick}
type="button"
{...props}
>
{segment.text}
</button>
);
};
@@ -0,0 +1,524 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Button } from "@/components/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import {
CircleSmallIcon,
MarsIcon,
MarsStrokeIcon,
NonBinaryIcon,
PauseIcon,
PlayIcon,
TransgenderIcon,
VenusAndMarsIcon,
VenusIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
interface VoiceSelectorContextValue {
value: string | undefined;
setValue: (value: string | undefined) => void;
open: boolean;
setOpen: (open: boolean) => void;
}
const VoiceSelectorContext = createContext<VoiceSelectorContextValue | null>(
null
);
export const useVoiceSelector = () => {
const context = useContext(VoiceSelectorContext);
if (!context) {
throw new Error(
"VoiceSelector components must be used within VoiceSelector"
);
}
return context;
};
export type VoiceSelectorProps = ComponentProps<typeof Dialog> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string | undefined) => void;
};
export const VoiceSelector = ({
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
...props
}: VoiceSelectorProps) => {
const [value, setValue] = useControllableState({
defaultProp: defaultValue,
onChange: onValueChange,
prop: valueProp,
});
const [open, setOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: openProp,
});
const voiceSelectorContext = useMemo(
() => ({ open, setOpen, setValue, value }),
[value, setValue, open, setOpen]
);
return (
<VoiceSelectorContext.Provider value={voiceSelectorContext}>
<Dialog onOpenChange={setOpen} open={open} {...props}>
{children}
</Dialog>
</VoiceSelectorContext.Provider>
);
};
export type VoiceSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const VoiceSelectorTrigger = (props: VoiceSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type VoiceSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const VoiceSelectorContent = ({
className,
children,
title = "Voice Selector",
...props
}: VoiceSelectorContentProps) => (
<DialogContent
aria-describedby={undefined}
className={cn("p-0", className)}
{...props}
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type VoiceSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const VoiceSelectorDialog = (props: VoiceSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type VoiceSelectorInputProps = ComponentProps<typeof CommandInput>;
export const VoiceSelectorInput = ({
className,
...props
}: VoiceSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type VoiceSelectorListProps = ComponentProps<typeof CommandList>;
export const VoiceSelectorList = (props: VoiceSelectorListProps) => (
<CommandList {...props} />
);
export type VoiceSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const VoiceSelectorEmpty = (props: VoiceSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type VoiceSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const VoiceSelectorGroup = (props: VoiceSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type VoiceSelectorItemProps = ComponentProps<typeof CommandItem>;
export const VoiceSelectorItem = ({
className,
...props
}: VoiceSelectorItemProps) => (
<CommandItem className={cn("px-4 py-2", className)} {...props} />
);
export type VoiceSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const VoiceSelectorShortcut = (props: VoiceSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type VoiceSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const VoiceSelectorSeparator = (props: VoiceSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type VoiceSelectorGenderProps = ComponentProps<"span"> & {
value?:
| "male"
| "female"
| "transgender"
| "androgyne"
| "non-binary"
| "intersex";
};
export const VoiceSelectorGender = ({
className,
value,
children,
...props
}: VoiceSelectorGenderProps) => {
let icon: ReactNode | null = null;
switch (value) {
case "male": {
icon = <MarsIcon className="size-4" />;
break;
}
case "female": {
icon = <VenusIcon className="size-4" />;
break;
}
case "transgender": {
icon = <TransgenderIcon className="size-4" />;
break;
}
case "androgyne": {
icon = <MarsStrokeIcon className="size-4" />;
break;
}
case "non-binary": {
icon = <NonBinaryIcon className="size-4" />;
break;
}
case "intersex": {
icon = <VenusAndMarsIcon className="size-4" />;
break;
}
default: {
icon = <CircleSmallIcon className="size-4" />;
}
}
return (
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
{children ?? icon}
</span>
);
};
export type VoiceSelectorAccentProps = ComponentProps<"span"> & {
value?:
| "american"
| "british"
| "australian"
| "canadian"
| "irish"
| "scottish"
| "indian"
| "south-african"
| "new-zealand"
| "spanish"
| "french"
| "german"
| "italian"
| "portuguese"
| "brazilian"
| "mexican"
| "argentinian"
| "japanese"
| "chinese"
| "korean"
| "russian"
| "arabic"
| "dutch"
| "swedish"
| "norwegian"
| "danish"
| "finnish"
| "polish"
| "turkish"
| "greek"
| string;
};
export const VoiceSelectorAccent = ({
className,
value,
children,
...props
}: VoiceSelectorAccentProps) => {
let emoji: string | null = null;
switch (value) {
case "american": {
emoji = "🇺🇸";
break;
}
case "british": {
emoji = "🇬🇧";
break;
}
case "australian": {
emoji = "🇦🇺";
break;
}
case "canadian": {
emoji = "🇨🇦";
break;
}
case "irish": {
emoji = "🇮🇪";
break;
}
case "scottish": {
emoji = "🏴󠁧󠁢󠁳󠁣󠁴󠁿";
break;
}
case "indian": {
emoji = "🇮🇳";
break;
}
case "south-african": {
emoji = "🇿🇦";
break;
}
case "new-zealand": {
emoji = "🇳🇿";
break;
}
case "spanish": {
emoji = "🇪🇸";
break;
}
case "french": {
emoji = "🇫🇷";
break;
}
case "german": {
emoji = "🇩🇪";
break;
}
case "italian": {
emoji = "🇮🇹";
break;
}
case "portuguese": {
emoji = "🇵🇹";
break;
}
case "brazilian": {
emoji = "🇧🇷";
break;
}
case "mexican": {
emoji = "🇲🇽";
break;
}
case "argentinian": {
emoji = "🇦🇷";
break;
}
case "japanese": {
emoji = "🇯🇵";
break;
}
case "chinese": {
emoji = "🇨🇳";
break;
}
case "korean": {
emoji = "🇰🇷";
break;
}
case "russian": {
emoji = "🇷🇺";
break;
}
case "arabic": {
emoji = "🇸🇦";
break;
}
case "dutch": {
emoji = "🇳🇱";
break;
}
case "swedish": {
emoji = "🇸🇪";
break;
}
case "norwegian": {
emoji = "🇳🇴";
break;
}
case "danish": {
emoji = "🇩🇰";
break;
}
case "finnish": {
emoji = "🇫🇮";
break;
}
case "polish": {
emoji = "🇵🇱";
break;
}
case "turkish": {
emoji = "🇹🇷";
break;
}
case "greek": {
emoji = "🇬🇷";
break;
}
default: {
emoji = null;
}
}
return (
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
{children ?? emoji}
</span>
);
};
export type VoiceSelectorAgeProps = ComponentProps<"span">;
export const VoiceSelectorAge = ({
className,
...props
}: VoiceSelectorAgeProps) => (
<span
className={cn("text-muted-foreground text-xs tabular-nums", className)}
{...props}
/>
);
export type VoiceSelectorNameProps = ComponentProps<"span">;
export const VoiceSelectorName = ({
className,
...props
}: VoiceSelectorNameProps) => (
<span
className={cn("flex-1 truncate text-left font-medium", className)}
{...props}
/>
);
export type VoiceSelectorDescriptionProps = ComponentProps<"span">;
export const VoiceSelectorDescription = ({
className,
...props
}: VoiceSelectorDescriptionProps) => (
<span className={cn("text-muted-foreground text-xs", className)} {...props} />
);
export type VoiceSelectorAttributesProps = ComponentProps<"div">;
export const VoiceSelectorAttributes = ({
className,
children,
...props
}: VoiceSelectorAttributesProps) => (
<div className={cn("flex items-center text-xs", className)} {...props}>
{children}
</div>
);
export type VoiceSelectorBulletProps = ComponentProps<"span">;
export const VoiceSelectorBullet = ({
className,
...props
}: VoiceSelectorBulletProps) => (
<span
aria-hidden="true"
className={cn("select-none text-border", className)}
{...props}
>
&bull;
</span>
);
export type VoiceSelectorPreviewProps = Omit<
ComponentProps<"button">,
"children"
> & {
playing?: boolean;
loading?: boolean;
onPlay?: () => void;
};
export const VoiceSelectorPreview = ({
className,
playing,
loading,
onPlay,
onClick,
...props
}: VoiceSelectorPreviewProps) => {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onClick?.(event);
onPlay?.();
},
[onClick, onPlay]
);
let icon = <PlayIcon className="size-3" />;
if (loading) {
icon = <Spinner className="size-3" />;
} else if (playing) {
icon = <PauseIcon className="size-3" />;
}
return (
<Button
aria-label={playing ? "Pause preview" : "Play preview"}
className={cn("size-6", className)}
disabled={loading}
onClick={handleClick}
size="icon-sm"
type="button"
variant="outline"
{...props}
>
{icon}
</Button>
);
};
@@ -0,0 +1,263 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
export interface WebPreviewContextValue {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
}
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error("WebPreview components must be used within a WebPreview");
}
return context;
};
export type WebPreviewProps = ComponentProps<"div"> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
export const WebPreview = ({
className,
children,
defaultUrl = "",
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = useCallback(
(newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
},
[onUrlChange]
);
const contextValue = useMemo<WebPreviewContextValue>(
() => ({
consoleOpen,
setConsoleOpen,
setUrl: handleUrlChange,
url,
}),
[consoleOpen, handleUrlChange, url]
);
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
"flex size-full flex-col rounded-lg border bg-card",
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn("flex items-center gap-1 border-b p-2", className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Button className="h-8 w-8 p-0 hover:text-foreground" disabled={disabled} onClick={onClick} size="sm" variant="ghost" {...props} />}>{children}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const [prevUrl, setPrevUrl] = useState(url);
const [inputValue, setInputValue] = useState(url);
// Sync input value with context URL when it changes externally (derived state pattern)
if (url !== prevUrl) {
setPrevUrl(url);
setInputValue(url);
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
onChange?.(event);
};
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
},
[setUrl, onKeyDown]
);
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? inputValue}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn("size-full", className)}
// oxlint-disable-next-line eslint-plugin-react(iframe-missing-sandbox)
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
logs?: {
level: "log" | "warn" | "error";
message: string;
timestamp: Date;
}[];
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger render={<Button className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50" variant="ghost" />}>Console
<ChevronDownIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
)}
/></CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log) => (
<div
className={cn(
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground"
)}
key={`${log.timestamp.getTime()}-${log.level}-${log.message}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{" "}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};
+143
View File
@@ -0,0 +1,143 @@
"use client";
import type { ChatStatus } from "ai";
import {
ArrowUp,
ChevronDown,
FolderGit2,
GitBranch,
Hand,
Laptop,
Mic,
Plus,
Square,
} from "lucide-react";
import { type KeyboardEvent, useCallback, useState } from "react";
import { cn } from "@/lib/utils";
type ChatInputProps = {
onSubmit: (text: string) => void;
status: ChatStatus;
onStop?: () => void;
};
const iconButton =
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
const pillButton =
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
const contextPill =
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
const [value, setValue] = useState("");
const isGenerating = status === "submitted" || status === "streaming";
const canSend = value.trim().length > 0 && !isGenerating;
const submit = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isGenerating) {
return;
}
onSubmit(trimmed);
setValue("");
}, [value, isGenerating, onSubmit]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
submit();
}
},
[submit]
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-[oklch(0.172_0.006_277)] shadow-sm"
>
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
<div className="rounded-b-[22px] bg-card">
<textarea
aria-label="Message"
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Do anything"
rows={1}
value={value}
/>
<div className="flex items-center justify-between gap-2 px-3 pb-3">
<div className="flex min-w-0 items-center gap-1">
<button aria-label="Add context" className={iconButton} type="button">
<Plus className="size-[18px]" />
</button>
<button className={pillButton} type="button">
<Hand className="size-4" />
<span className="truncate">Default permissions</span>
<ChevronDown className="size-4 opacity-70" />
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
<button className={cn(pillButton, "mr-1")} type="button">
<span className="font-medium text-foreground">Custom</span>
<span>High</span>
<ChevronDown className="size-4 opacity-70" />
</button>
<button aria-label="Voice input" className={iconButton} type="button">
<Mic className="size-[18px]" />
</button>
<button
aria-label={isGenerating ? "Stop" : "Send"}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
canSend || isGenerating
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted-foreground/30 text-foreground/70"
)}
disabled={!(canSend || isGenerating)}
onClick={isGenerating && onStop ? onStop : undefined}
type={isGenerating && onStop ? "button" : "submit"}
>
{isGenerating ? (
<Square className="size-3.5" />
) : (
<ArrowUp className="size-5" />
)}
</button>
</div>
</div>
</div>
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
<button className={contextPill} type="button">
<FolderGit2 className="size-4" />
<span>design-ai</span>
<ChevronDown className="size-3.5 opacity-70" />
</button>
<button className={contextPill} type="button">
<Laptop className="size-4" />
<span>Work locally</span>
<ChevronDown className="size-3.5 opacity-70" />
</button>
<button className={contextPill} type="button">
<GitBranch className="size-4" />
<span>main</span>
<ChevronDown className="size-3.5 opacity-70" />
</button>
</div>
</form>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { nanoid } from "nanoid";
import type { ChatStatus } from "ai";
import { useCallback, useState } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import { ChatInput } from "@/components/chat/chat-input";
type ChatMessage = {
id: string;
role: "user" | "assistant";
text: string;
};
const HEADING = "What should we build in design-ai?";
export function ChatPanel() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>("ready");
// UI-only: append the user's message and a mock assistant reply.
const send = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) {
return;
}
setMessages((prev) => [
...prev,
{ id: nanoid(), role: "user", text: trimmed },
]);
setStatus("submitted");
window.setTimeout(() => {
setStatus("streaming");
setMessages((prev) => [
...prev,
{
id: nanoid(),
role: "assistant",
text: `This is a **UI-only preview** of temetro. Connecting to your records and a model comes next.\n\nYou asked:\n\n> ${trimmed}`,
},
]);
window.setTimeout(() => setStatus("ready"), 250);
}, 500);
}, []);
const handleStop = useCallback(() => setStatus("ready"), []);
const promptInput = (
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
);
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{HEADING}
</h1>
{promptInput}
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
<Conversation>
<ConversationContent className="mx-auto w-full max-w-3xl">
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.role === "assistant" ? (
<MessageResponse>{message.text}</MessageResponse>
) : (
<span className="whitespace-pre-wrap">{message.text}</span>
)}
</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
export function BillingPanel() {
return (
<>
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold tracking-tight">Early Member</h3>
<Badge className="bg-emerald-500/15 text-emerald-400">Active</Badge>
</div>
<p className="text-sm text-muted-foreground">
For our founding community of early members.
</p>
<Button className={cn("rounded-lg", whiteButton)}>Change plan</Button>
</div>
<div className="sm:text-right">
<p className="text-3xl font-semibold tracking-tight">Free</p>
<p className="text-sm text-muted-foreground">
4.00% + $0.40 per transaction
</p>
</div>
</SettingsCard>
<SettingsSection
action={
<Button className="rounded-lg" variant="secondary">
Add payment method
</Button>
}
description="Cards used to pay for your temetro subscription"
title="Payment methods"
>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">No payment methods on file</p>
</SettingsCard>
</SettingsSection>
<SettingsSection
action={
<Button className="rounded-lg" variant="secondary">
Edit
</Button>
}
description="Used on invoices for your temetro subscription"
title="Billing address"
>
<SettingsCard className="p-5">
<p className="text-sm">khalid</p>
</SettingsCard>
</SettingsSection>
</>
);
}
@@ -0,0 +1,123 @@
"use client";
import type { ReactNode } from "react";
import { Copy } from "lucide-react";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
export function SettingsSection({
title,
description,
action,
children,
}: {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
export function SettingsCard({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
{children}
</div>
);
}
export function ToggleRow({
title,
description,
defaultChecked = false,
}: {
title: string;
description?: string;
defaultChecked?: boolean;
}) {
return (
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
<div className="space-y-0.5">
<p className="text-sm font-medium">{title}</p>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
<Switch defaultChecked={defaultChecked} />
</SettingsCard>
);
}
export function CopyField({
label,
description,
value,
}: {
label: string;
description?: string;
value: string;
}) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">{label}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pr-1 pl-3 sm:w-80">
<span className="flex-1 truncate text-sm text-muted-foreground">
{value}
</span>
<button
className="flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
type="button"
>
<Copy className="size-3.5" />
Copy
</button>
</div>
</div>
);
}
/** Light/white CTA used for primary actions on the dark settings surface. */
export const whiteButton =
"bg-foreground text-background hover:bg-foreground/90";
/** Field label with an optional required asterisk. */
export function FieldLabel({
children,
required,
}: {
children: ReactNode;
required?: boolean;
}) {
return (
<span className="text-sm font-medium">
{children}
{required ? <span className="text-muted-foreground"> *</span> : null}
</span>
);
}
@@ -0,0 +1,201 @@
"use client";
import { ChevronDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
CopyField,
FieldLabel,
SettingsCard,
SettingsSection,
ToggleRow,
whiteButton,
} from "@/components/settings/settings-parts";
const customerNotifications = [
{
title: "Order confirmation",
description: "Sent when a customer completes a one-time purchase",
},
{
title: "Subscription confirmation",
description: "Sent when a customer starts a new subscription",
},
{
title: "Subscription cycled",
description: "Sent when a subscription automatically renews",
},
{
title: "Trial converted",
description: "Sent when a trial ends and the subscription becomes paid",
},
{
title: "Renewal reminder",
description: "Sent 7 days before a subscription with a long billing cycle renews",
},
{
title: "Trial conversion reminder",
description: "Sent before a trial ends and converts to a paid subscription",
},
{
title: "Subscription updated",
description: "Sent when a customer changes their subscription to a different product",
},
];
export function PreferencesPanel() {
return (
<>
<SettingsSection title="Organization">
<SettingsCard className="space-y-6 p-5">
<CopyField
description="Unique identifier for your organization"
label="Identifier"
value="62a5278f-91c6-4912-b711-ee1c9c2f0a73"
/>
<CopyField
description="Used for Customer Portal, Transaction Statements, etc."
label="Organization Slug"
value="khalid"
/>
<div className="flex items-end gap-4">
<div className="space-y-1.5">
<FieldLabel>Logo</FieldLabel>
<Avatar className="size-10 rounded-xl" size="lg">
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
K
</AvatarFallback>
</Avatar>
</div>
<div className="flex-1 space-y-1.5">
<FieldLabel required>Organization Name</FieldLabel>
<Input defaultValue="khalid" />
</div>
</div>
<div className="space-y-1.5">
<FieldLabel>Country</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
>
Select country
<ChevronDown className="size-4" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel required>Website</FieldLabel>
<Input placeholder="https://acme.com" />
</div>
<div className="space-y-1.5">
<FieldLabel required>Support Email</FieldLabel>
<Input placeholder="support@acme.com" />
</div>
</div>
<div className="space-y-2.5">
<div className="space-y-0.5">
<FieldLabel>Social Media</FieldLabel>
<p className="text-xs text-muted-foreground">
Your personal social media links are used for identity verification. They
will never be shown publicly.
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
<Plus className="size-4" />
Add Social
</Button>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Emails automatically sent to customers for purchases, renewals, and other subscription lifecycle events"
title="Customer notifications"
>
<div className="space-y-3">
{customerNotifications.map((item) => (
<ToggleRow
defaultChecked
description={item.description}
key={item.title}
title={item.title}
/>
))}
</div>
</SettingsSection>
<SettingsSection
description="Emails sent to members of your organization for account and product activity"
title="Account notifications"
>
<div className="space-y-3">
<ToggleRow
defaultChecked
description="Send a notification when new orders are created"
title="New Orders"
/>
<ToggleRow
defaultChecked
description="Send a notification when new subscriptions are created"
title="New Subscriptions"
/>
</div>
</SettingsSection>
<SettingsSection
description="Manage alpha & beta features for your organization"
title="Features"
>
<div className="space-y-3">
<ToggleRow
description="Show translated checkouts to your customers"
title="Localized Checkout"
/>
<ToggleRow
description="Enable seat-based pricing for subscription products. Requires the member model to be enabled."
title="Seat-Based Billing"
/>
</div>
</SettingsSection>
<SettingsSection
description="Manage access tokens to authenticate with the temetro API"
title="Developers"
>
<SettingsCard className="flex flex-col items-start gap-4 p-6">
<p className="text-sm text-muted-foreground">
You don&apos;t have any active organization access tokens.
</p>
<Button className={cn("rounded-lg", whiteButton)} size="sm">
Create token
</Button>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Irreversible actions for this organization"
title="Danger Zone"
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Delete Organization</p>
<p className="text-sm text-muted-foreground">
Permanently delete this organization and all associated data. This action
cannot be undone.
</p>
</div>
<Button className="rounded-lg bg-destructive text-white hover:bg-destructive/90">
Delete
</Button>
</SettingsCard>
</SettingsSection>
</>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { BillingPanel } from "@/components/settings/settings-billing";
import { PreferencesPanel } from "@/components/settings/settings-preferences";
const TABS = [
"Preferences",
"Billing",
"Members",
"Webhooks",
"Custom Fields",
] as const;
type Tab = (typeof TABS)[number];
function PlaceholderPanel({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<SettingsSection description={description} title={title}>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">Nothing here yet.</p>
</SettingsCard>
</SettingsSection>
);
}
export function SettingsView() {
const [tab, setTab] = useState<Tab>("Preferences");
return (
<div className="mx-auto w-full max-w-3xl px-6 py-10">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{tab}</h1>
<nav className="flex flex-wrap items-center gap-1">
{TABS.map((item) => (
<button
className={cn(
"rounded-lg px-3 py-1.5 text-sm transition-colors",
tab === item
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
key={item}
onClick={() => setTab(item)}
type="button"
>
{item}
</button>
))}
</nav>
</div>
<div className="mt-10 space-y-12">
{tab === "Preferences" && <PreferencesPanel />}
{tab === "Billing" && <BillingPanel />}
{tab === "Members" && (
<PlaceholderPanel
description="Invite and manage members of your organization"
title="Members"
/>
)}
{tab === "Webhooks" && (
<PlaceholderPanel
description="Send event notifications to your own endpoints"
title="Webhooks"
/>
)}
{tab === "Custom Fields" && (
<PlaceholderPanel
description="Collect additional information from your customers"
title="Custom Fields"
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
import {
Bookmark,
MessageSquare,
Plus,
Settings,
Users,
} from "lucide-react";
import Image from "next/image";
import { Logo } from "@/components/sidebar-02/logo";
import type { Route } from "./nav-main";
import DashboardNavigation from "@/components/sidebar-02/nav-main";
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
import { TeamSwitcher } from "@/components/sidebar-02/team-switcher";
const sampleNotifications = [
{
id: "1",
avatar: "/avatars/01.png",
fallback: "LR",
text: "New lab results are available.",
time: "10m ago",
},
{
id: "2",
avatar: "/avatars/02.png",
fallback: "PC",
text: "A patient chart was updated.",
time: "1h ago",
},
{
id: "3",
avatar: "/avatars/03.png",
fallback: "CT",
text: "New message from the care team.",
time: "2h ago",
},
];
const dashboardRoutes: Route[] = [
{
id: "new-chat",
title: "New chat",
icon: <Plus className="size-4" />,
link: "/",
},
{
id: "patients",
title: "Patients",
icon: <Users className="size-4" />,
link: "#",
},
{
id: "conversations",
title: "Conversations",
icon: <MessageSquare className="size-4" />,
link: "#",
},
{
id: "saved",
title: "Saved",
icon: <Bookmark className="size-4" />,
link: "#",
},
{
id: "settings",
title: "Settings",
icon: <Settings className="size-4" />,
link: "/settings",
},
];
const teams = [{ id: "1", name: "temetro", logo: Logo, plan: "Clinic" }];
export function DashboardSidebar() {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
return (
<Sidebar variant="inset" collapsible="icon">
<SidebarHeader
className={cn(
"flex md:pt-3.5",
isCollapsed
? "flex-row items-center justify-between gap-y-4 md:flex-col md:items-start md:justify-start"
: "flex-row items-center justify-between"
)}
>
<a href="#" className="flex items-center gap-2">
<Image
src="/temetro-logo.png"
alt="temetro"
width={32}
height={32}
className="size-8 shrink-0"
priority
/>
{!isCollapsed && (
<span className="font-semibold text-black dark:text-white">
temetro
</span>
)}
</a>
<motion.div
key={isCollapsed ? "header-collapsed" : "header-expanded"}
className={cn(
"flex items-center gap-2",
isCollapsed ? "flex-row md:flex-col-reverse" : "flex-row"
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8 }}
>
<NotificationsPopover notifications={sampleNotifications} />
<SidebarTrigger />
</motion.div>
</SidebarHeader>
<SidebarContent className="gap-4 px-2 py-4">
<DashboardNavigation routes={dashboardRoutes} />
</SidebarContent>
<SidebarFooter className="px-2">
<TeamSwitcher teams={teams} />
</SidebarFooter>
</Sidebar>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
export default function Sidebar02() {
return (
<SidebarProvider>
<div className="relative flex h-dvh w-full">
<DashboardSidebar />
<SidebarInset className="flex flex-col" />
</div>
</SidebarProvider>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { SVGProps } from "react";
export const Logo = (props: SVGProps<SVGSVGElement>) => (
<svg
fill="currentColor"
height="48"
viewBox="0 0 40 48"
width="40"
{...props}
>
<clipPath id="a">
<path d="m0 0h40v48h-40z" />
</clipPath>
<g clipPath="url(#a)">
<path d="m25.0887 5.05386-3.933-1.05386-3.3145 12.3696-2.9923-11.16736-3.9331 1.05386 3.233 12.0655-8.05262-8.0526-2.87919 2.8792 8.83271 8.8328-10.99975-2.9474-1.05385625 3.933 12.01860625 3.2204c-.1376-.5935-.2104-1.2119-.2104-1.8473 0-4.4976 3.646-8.1436 8.1437-8.1436 4.4976 0 8.1436 3.646 8.1436 8.1436 0 .6313-.0719 1.2459-.2078 1.8359l10.9227 2.9267 1.0538-3.933-12.0664-3.2332 11.0005-2.9476-1.0539-3.933-12.0659 3.233 8.0526-8.0526-2.8792-2.87916-8.7102 8.71026z" />
<path d="m27.8723 26.2214c-.3372 1.4256-1.0491 2.7063-2.0259 3.7324l7.913 7.9131 2.8792-2.8792z" />
<path d="m25.7665 30.0366c-.9886 1.0097-2.2379 1.7632-3.6389 2.1515l2.8794 10.746 3.933-1.0539z" />
<path d="m21.9807 32.2274c-.65.1671-1.3313.2559-2.0334.2559-.7522 0-1.4806-.102-2.1721-.2929l-2.882 10.7558 3.933 1.0538z" />
<path d="m17.6361 32.1507c-1.3796-.4076-2.6067-1.1707-3.5751-2.1833l-7.9325 7.9325 2.87919 2.8792z" />
<path d="m13.9956 29.8973c-.9518-1.019-1.6451-2.2826-1.9751-3.6862l-10.95836 2.9363 1.05385 3.933z" />
</g>
</svg>
);
+106
View File
@@ -0,0 +1,106 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuItem as SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronUp } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { useState } from "react";
export type Route = {
id: string;
title: string;
icon?: React.ReactNode;
link: string;
subs?: {
title: string;
link: string;
icon?: React.ReactNode;
}[];
};
export default function DashboardNavigation({ routes }: { routes: Route[] }) {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const [openCollapsible, setOpenCollapsible] = useState<string | null>(null);
return (
<SidebarMenu>
{routes.map((route) => {
const isOpen = !isCollapsed && openCollapsible === route.id;
const hasSubRoutes = !!route.subs?.length;
return (
<SidebarMenuItem key={route.id}>
{hasSubRoutes ? (
<Collapsible
open={isOpen}
onOpenChange={(open) =>
setOpenCollapsible(open ? route.id : null)
}
className="w-full"
>
<CollapsibleTrigger render={<SidebarMenuButton className={cn(
"flex w-full items-center rounded-lg px-2 transition-colors",
isOpen
? "bg-sidebar-muted text-foreground"
: "text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
isCollapsed && "justify-center"
)} />}>{route.icon}{!isCollapsed && (
<span className="ml-2 flex-1 text-sm font-medium">
{route.title}
</span>
)}{!isCollapsed && hasSubRoutes && (
<span className="ml-auto">
{isOpen ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</span>
)}</CollapsibleTrigger>
{!isCollapsed && (
<CollapsibleContent>
<SidebarMenuSub className="my-1 ml-3.5 ">
{route.subs?.map((subRoute) => (
<SidebarMenuSubItem
key={`${route.id}-${subRoute.title}`}
className="h-auto"
>
<SidebarMenuSubButton render={<Link href={subRoute.link} prefetch={true} className="flex items-center rounded-md px-4 py-1.5 text-sm font-medium text-muted-foreground hover:bg-sidebar-muted hover:text-foreground" />}>{subRoute.title}</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
)}
</Collapsible>
) : (
<SidebarMenuButton tooltip={route.title} render={<Link href={route.link} prefetch={true} className={cn(
"flex items-center rounded-lg px-2 transition-colors text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
isCollapsed && "justify-center"
)} />}>{route.icon}{!isCollapsed && (
<span className="ml-2 text-sm font-medium">
{route.title}
</span>
)}</SidebarMenuButton>
)}
</SidebarMenuItem>
);
})}
</SidebarMenu>
);
}
@@ -0,0 +1,53 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { BellIcon } from "lucide-react";
type Notification = {
id: string;
avatar: string;
fallback: string;
text: string;
time: string;
};
export function NotificationsPopover({
notifications,
}: {
notifications: Notification[];
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="rounded-full" aria-label="Open notifications" />}><BellIcon className="size-5" /></DropdownMenuTrigger>
<DropdownMenuContent side="right" className="w-80 my-6">
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.map(({ id, avatar, fallback, text, time }) => (
<DropdownMenuItem key={id} className="flex items-start gap-3">
<Avatar className="size-8">
<AvatarImage src={avatar} alt="Avatar" />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">{text}</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
View all notifications
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,81 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { ChevronsUpDown, Plus } from "lucide-react";
import * as React from "react";
type Team = {
name: string;
logo: React.ElementType;
plan: string;
};
export function TeamSwitcher({ teams }: { teams: Team[] }) {
const { isMobile } = useSidebar();
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
if (!activeTeam) return null;
const Logo = activeTeam.logo;
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" />}><div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-background text-foreground">
<Logo className="size-4" />
</div><div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{activeTeam.name}
</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div><ChevronsUpDown className="ml-auto" /></DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg mb-4"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Teams
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<team.logo className="size-4 shrink-0" />
</div>
{team.name}
<DropdownMenuShortcut>{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<div className="font-medium text-muted-foreground">Add team</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn(
"flex w-full flex-col overflow-hidden rounded-2xl border",
className
)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b data-open:bg-muted/50", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-4 text-left text-sm font-medium transition-all outline-none hover:underline aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden px-4 text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2.5 right-3", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+87
View File
@@ -0,0 +1,87 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-4xl border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+56
View File
@@ -0,0 +1,56 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+100
View File
@@ -0,0 +1,100 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-4xl bg-card py-6 text-sm text-card-foreground shadow-md ring-1 ring-foreground/5 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-4xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-4xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+242
View File
@@ -0,0 +1,242 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-9 bg-input/50">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1.5 text-foreground **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("my-1.5 h-px bg-border/50", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none in-data-[slot=dialog-content]:rounded-3xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4 bg-secondary"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-36 rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+51
View File
@@ -0,0 +1,51 @@
"use client"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-72 origin-(--transform-origin) rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+155
View File
@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-4xl border border-transparent bg-input/50 transition-[color,box-shadow,background-color] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-3xl has-data-[align=block-start]:rounded-3xl has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-2xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-3xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-3 has-[>button]:-ml-1 has-[>kbd]:-ml-1",
"inline-end": "order-last pr-3 has-[>button]:-mr-1 has-[>kbd]:-mr-1",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-3.5 [.border-b]:pb-3.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-3.5 [.border-t]:pt-3.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 rounded-4xl text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-xl p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("text-base font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+83
View File
@@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-3 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+204
View File
@@ -0,0 +1,204 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1.5 p-1.5", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-3xl border border-transparent bg-input/50 px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1.5 my-1.5 h-px bg-border",
className
)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/30 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4 bg-secondary"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-input/50 shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn(
"flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
className
)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-9 text-sm",
sm: "h-8 text-xs",
lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-xl px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-xl"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-2xl bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border-2 transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-11 data-[size=sm]:h-4 data-[size=sm]:w-7 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-unchecked:border-transparent data-unchecked:bg-input/90 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:h-4 group-data-[size=default]/switch:w-6 group-data-[size=sm]/switch:h-3 group-data-[size=sm]/switch:w-4 data-checked:translate-x-[calc(100%-8px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full p-1 text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-2 rounded-full border border-transparent! px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:rounded-2xl group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-1.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full resize-none rounded-2xl border border-transparent bg-input/50 px-3 py-3 text-base transition-[color,box-shadow,background-color] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:translate-x-[1.5px] data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:translate-x-[-1.5px] data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:translate-x-[-1.5px] data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:translate-x-[1.5px] data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+7818 -108
View File
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -9,9 +9,36 @@
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.28.6",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@xyflow/react": "^12.10.2",
"ai": "^6.0.193",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.40.0",
"lucide-react": "^1.17.0",
"media-chrome": "^4.19.0",
"motion": "^12.40.0",
"nanoid": "^5.1.11",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-jsx-parser": "^2.4.1",
"shadcn": "^4.8.3",
"shiki": "^4.1.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

Some files were not shown because too many files have changed in this diff Show More