Compare commits

...

3 Commits

Author SHA1 Message Date
4266a3ff43 skipped linting for test deployments 2025-10-09 04:56:40 -06:00
074c79f302 feat: redesign terminal UI with theme support and retro aesthetic
- Add terminal-chat-interface component with dual-panel layout
- Implement light/dark mode with next-themes
- Reorganize shadcn components to shadcn-io subdirectory
- Add custom retro icons (security, terminal, bot, etc.)
- Update color scheme with oklch values for both themes
- Add theme toggle and Gitea repository link
- Include corner bracket accents and grid/scan line effects
- Fix hydration mismatch for session time display
2025-10-09 04:00:19 -06:00
8cbc9538ca more shadcn components 2025-10-09 02:12:03 -06:00
46 changed files with 5559 additions and 273 deletions

6
.gitignore vendored
View File

@ -166,6 +166,12 @@ Thumbs.db
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
.cursorrules
.cursorrules/*
!.cursorrules/.gitignore
.cursor/*
.cursor/*/*
.cursor
# Turborepo cache (if you introduce turbo later) # Turborepo cache (if you introduce turbo later)
.turbo/ .turbo/

View File

@ -2,6 +2,12 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -12,8 +12,11 @@
"cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts" "cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts"
}, },
"dependencies": { "dependencies": {
"@icons-pack/react-simple-icons": "^13.8.0",
"@opennextjs/cloudflare": "^1.3.0", "@opennextjs/cloudflare": "^1.3.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
@ -22,15 +25,26 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"ai": "^5.0.62",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"harden-react-markdown": "^1.1.2",
"katex": "^0.16.23",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"next": "15.4.6", "next": "15.4.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.13.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -38,6 +52,7 @@
"@types/node": "^20.19.19", "@types/node": "^20.19.19",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"tailwindcss": "^4", "tailwindcss": "^4",

File diff suppressed because it is too large Load Diff

View File

@ -3,180 +3,165 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.98 0 0);
--foreground: oklch(0.15 0 0);
--card: oklch(0.99 0 0);
--card-foreground: oklch(0.15 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.35 0.15 250);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.92 0 0);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.94 0 0);
--muted-foreground: oklch(0.50 0 0);
--accent: oklch(0.88 0.08 250);
--accent-foreground: oklch(0.25 0.15 250);
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.88 0 0);
--input: oklch(0.92 0 0);
--ring: oklch(0.35 0.15 250);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.55 0.22 264.53);
--chart-3: oklch(0.72 0 0);
--chart-4: oklch(0.92 0 0);
--chart-5: oklch(0.56 0 0);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-primary: oklch(0.35 0.15 250);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.92 0 0);
--sidebar-accent-foreground: oklch(0.15 0 0);
--sidebar-border: oklch(0.92 0 0);
--sidebar-ring: oklch(0.35 0.15 250);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.18;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.08 0.01 250);
--foreground: oklch(0.90 0.05 200);
--card: oklch(0.12 0.01 250);
--card-foreground: oklch(0.90 0.05 200);
--popover: oklch(0.14 0.01 250);
--popover-foreground: oklch(0.90 0.05 200);
--primary: oklch(0.70 0.15 195);
--primary-foreground: oklch(0.08 0.01 250);
--secondary: oklch(0.22 0.01 250);
--secondary-foreground: oklch(0.90 0.05 200);
--muted: oklch(0.20 0.01 250);
--muted-foreground: oklch(0.55 0.03 200);
--accent: oklch(0.28 0.05 250);
--accent-foreground: oklch(0.75 0.15 180);
--destructive: oklch(0.60 0.20 25);
--destructive-foreground: oklch(0.95 0 0);
--border: oklch(0.24 0.02 250);
--input: oklch(0.28 0.02 250);
--ring: oklch(0.70 0.15 195);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.58 0.21 260.84);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
--sidebar: oklch(0.12 0.01 250);
--sidebar-foreground: oklch(0.90 0.05 200);
--sidebar-primary: oklch(0.70 0.15 195);
--sidebar-primary-foreground: oklch(0.08 0.01 250);
--sidebar-accent: oklch(0.28 0.05 250);
--sidebar-accent-foreground: oklch(0.75 0.15 180);
--sidebar-border: oklch(0.24 0.02 250);
--sidebar-ring: oklch(0.70 0.15 195);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.18;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: 'JetBrains Mono', monospace;
--font-mono: 'JetBrains Mono', monospace;
--font-serif: 'JetBrains Mono', monospace;
--radius: 0rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
--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); --color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
}
:root { --shadow-2xs: var(--shadow-2xs);
--radius: 0rem; --shadow-xs: var(--shadow-xs);
--background: oklch(0 0 0); --shadow-sm: var(--shadow-sm);
--foreground: oklch(0.8664 0.2948 142.4953); --shadow: var(--shadow);
--card: oklch(0.2178 0 0); --shadow-md: var(--shadow-md);
--card-foreground: oklch(0.8664 0.2948 142.4953); --shadow-lg: var(--shadow-lg);
--popover: oklch(0.1448 0 0); --shadow-xl: var(--shadow-xl);
--popover-foreground: oklch(0.8664 0.2948 142.4953); --shadow-2xl: var(--shadow-2xl);
--primary: oklch(0.7323 0.2492 142.4953);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.5430 0.1848 142.4953);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.2782 0.0947 142.4953);
--muted-foreground: oklch(0.6394 0.2176 142.4953);
--accent: oklch(0.3895 0.1325 142.4953);
--accent-foreground: oklch(0.8664 0.2948 142.4953);
--destructive: oklch(0.6280 0.2577 29.2339);
--border: oklch(0.3350 0.1140 142.4953);
--input: oklch(0.1448 0 0);
--ring: oklch(0.8664 0.2948 142.4953);
--chart-1: oklch(0.8664 0.2948 142.4953);
--chart-2: oklch(0.6863 0.2335 142.4953);
--chart-3: oklch(0.4932 0.1678 142.4953);
--chart-4: oklch(0.3895 0.1325 142.4953);
--chart-5: oklch(0.2782 0.0947 142.4953);
--sidebar: oklch(0.1149 0 0);
--sidebar-foreground: oklch(0.8664 0.2948 142.4953);
--sidebar-primary: oklch(0.7323 0.2492 142.4953);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5430 0.1848 142.4953);
--sidebar-accent-foreground: oklch(0.8664 0.2948 142.4953);
--sidebar-border: oklch(0.3350 0.1140 142.4953);
--sidebar-ring: oklch(0.8664 0.2948 142.4953);
--destructive-foreground: oklch(1.0000 0 0);
--font-sans: 'JetBrains Mono', monospace;
--font-serif: 'JetBrains Mono', monospace;
--font-mono: 'JetBrains Mono', monospace;
--shadow-color: #00ff00;
--shadow-opacity: 0.2;
--shadow-blur: 0.2rem;
--shadow-spread: 0rem;
--shadow-offset-x: 0rem;
--shadow-offset-y: 0.1rem;
--letter-spacing: 0.025em;
--spacing: 0.25rem;
--shadow-2xs: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.10);
--shadow-xs: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.10);
--shadow-sm: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 1px 2px -1px hsl(120 100% 50% / 0.20);
--shadow: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 1px 2px -1px hsl(120 100% 50% / 0.20);
--shadow-md: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 2px 4px -1px hsl(120 100% 50% / 0.20);
--shadow-lg: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 4px 6px -1px hsl(120 100% 50% / 0.20);
--shadow-xl: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 8px 10px -1px hsl(120 100% 50% / 0.20);
--shadow-2xl: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.50);
--tracking-normal: 0.025em;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.1822 0 0);
--card-foreground: oklch(0.9706 0.0017 67.8024);
--popover: oklch(0.1448 0 0);
--popover-foreground: oklch(0.9706 0.0017 67.8024);
--primary: oklch(1.0000 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.9706 0.0017 67.8024);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.3979 0 0);
--muted-foreground: oklch(0.8975 0.0042 91.4501);
--accent: oklch(0.6829 0.0045 91.4665);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0.6280 0.2577 29.2339);
--border: oklch(0.4794 0.0129 297.3461);
--input: oklch(0.1448 0 0);
--ring: oklch(1.0000 0 0);
--chart-1: oklch(0.8664 0.2948 142.4953);
--chart-2: oklch(0.6863 0.2335 142.4953);
--chart-3: oklch(0.4932 0.1678 142.4953);
--chart-4: oklch(0.3895 0.1325 142.4953);
--chart-5: oklch(0.2782 0.0947 142.4953);
--sidebar: oklch(0.1149 0 0);
--sidebar-foreground: oklch(0.8664 0.2948 142.4953);
--sidebar-primary: oklch(0.7323 0.2492 142.4953);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5430 0.1848 142.4953);
--sidebar-accent-foreground: oklch(0.8664 0.2948 142.4953);
--sidebar-border: oklch(0.3350 0.1140 142.4953);
--sidebar-ring: oklch(0.8664 0.2948 142.4953);
--destructive-foreground: oklch(1.0000 0 0);
--radius: 0rem;
--font-sans: 'JetBrains Mono', monospace;
--font-serif: 'JetBrains Mono', monospace;
--font-mono: 'JetBrains Mono', monospace;
--shadow-color: #00ff00;
--shadow-opacity: 0.2;
--shadow-blur: 0.2rem;
--shadow-spread: 0rem;
--shadow-offset-x: 0rem;
--shadow-offset-y: 0.1rem;
--letter-spacing: 0.025em;
--spacing: 0.25rem;
--shadow-2xs: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.10);
--shadow-xs: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.10);
--shadow-sm: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 1px 2px -1px hsl(120 100% 50% / 0.20);
--shadow: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 1px 2px -1px hsl(120 100% 50% / 0.20);
--shadow-md: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 2px 4px -1px hsl(120 100% 50% / 0.20);
--shadow-lg: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 4px 6px -1px hsl(120 100% 50% / 0.20);
--shadow-xl: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.20), 0rem 8px 10px -1px hsl(120 100% 50% / 0.20);
--shadow-2xl: 0rem 0.1rem 0.2rem 0rem hsl(120 100% 50% / 0.50);
} }
@layer base { @layer base {
@ -187,4 +172,30 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
} }
@keyframes scan-lines {
0% {
transform: translateY(0);
}
100% {
transform: translateY(4px);
}
}
@keyframes cursor-blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.animate-scan-lines {
animation: scan-lines 0.1s linear infinite;
}
.animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
} }

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Bandit Runner",
description: "Generated by create next app", description: "Security Automation Console",
}; };
export default function RootLayout({ export default function RootLayout({
@ -23,11 +24,18 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
> >
{children} {children}
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@ -1,103 +1,5 @@
import Image from "next/image"; import { TerminalChatInterface } from "@/components/terminal-chat-interface"
export default function Home() { export default function Home() {
return ( return <TerminalChatInterface />
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
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={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto 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"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
} }

View File

@ -0,0 +1,117 @@
export function TerminalIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
className={className}
>
<rect x="2" y="3" width="20" height="18" />
<line x1="2" y1="7" x2="22" y2="7" />
<polyline points="6,11 9,14 6,17" />
<line x1="12" y1="17" x2="18" y2="17" />
</svg>
)
}
export function BotIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
className={className}
>
<rect x="6" y="8" width="12" height="10" />
<rect x="8" y="11" width="2" height="2" />
<rect x="14" y="11" width="2" height="2" />
<line x1="12" y1="5" x2="12" y2="8" />
<circle cx="12" cy="4" r="1" />
<line x1="6" y1="18" x2="4" y2="21" />
<line x1="18" y1="18" x2="20" y2="21" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
}
export function DatabaseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
className={className}
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<line x1="3" y1="12" x2="21" y2="12" />
</svg>
)
}
export function SecurityIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
className={className}
>
<path d="M12 2L4 6v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V6l-8-4z" />
<path d="M9 12l2 2 4-4" />
</svg>
)
}
export function GitBranchIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
className={className}
>
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<line x1="6" y1="9" x2="6" y2="15" />
<path d="M18 15c0-3-3-4.5-6-4.5" />
</svg>
)
}
export function ServerIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
className={className}
>
<rect x="2" y="2" width="20" height="6" />
<rect x="2" y="10" width="20" height="6" />
<rect x="2" y="18" width="20" height="4" />
<circle cx="6" cy="5" r="1" />
<circle cx="6" cy="13" r="1" />
<circle cx="6" cy="20" r="1" />
</svg>
)
}

View File

@ -0,0 +1,416 @@
"use client"
import type React from "react"
import { useState, useRef, useEffect } from "react"
import { Github } from "lucide-react"
import { Input } from "@/components/ui/shadcn-io/input"
import { ScrollArea } from "@/components/ui/shadcn-io/scroll-area"
import { ThemeToggle } from "@/components/theme-toggle"
import { SecurityIcon } from "@/components/retro-icons"
import { cn } from "@/lib/utils"
interface TerminalLine {
type: "input" | "output" | "error"
content: string
timestamp: Date
}
interface ChatMessage {
type: "user" | "agent" | "typing"
content: string
timestamp: Date
}
const SCAN_LINES_OVERLAY =
"absolute inset-0 pointer-events-none bg-[linear-gradient(transparent_50%,hsl(var(--primary)/0.03)_50%)] bg-[length:100%_4px] animate-scan-lines"
const GRID_PATTERN =
"absolute inset-0 pointer-events-none opacity-[0.015] bg-[linear-gradient(hsl(var(--primary))_1px,transparent_1px),linear-gradient(90deg,hsl(var(--primary))_1px,transparent_1px)] bg-[size:20px_20px]"
export function TerminalChatInterface() {
const [terminalLines, setTerminalLines] = useState<TerminalLine[]>([
{ type: "output", content: "Bandit Runner Console v1.0", timestamp: new Date() },
{ type: "output", content: "System initialized. Ready for commands.", timestamp: new Date() },
{ type: "output", content: "", timestamp: new Date() },
])
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
{ type: "agent", content: "Agent ready. Awaiting commands...", timestamp: new Date() },
])
const [currentCommand, setCurrentCommand] = useState("")
const [chatInput, setChatInput] = useState("")
const [commandHistory, setCommandHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
const [isTyping, setIsTyping] = useState(false)
const [sessionTime, setSessionTime] = useState("")
const [focusedPanel, setFocusedPanel] = useState<"terminal" | "chat">("terminal")
const [mounted, setMounted] = useState(false)
const terminalEndRef = useRef<HTMLDivElement>(null)
const chatEndRef = useRef<HTMLDivElement>(null)
const terminalInputRef = useRef<HTMLInputElement>(null)
const chatInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setMounted(true)
setSessionTime(new Date().toLocaleTimeString())
terminalInputRef.current?.focus()
}, [])
useEffect(() => {
terminalEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [terminalLines])
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [chatMessages])
const formatTimestamp = (date: Date) => {
return date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
}
const handleCommandSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!currentCommand.trim()) return
const command = currentCommand.trim()
setCommandHistory((prev) => [...prev, command])
setHistoryIndex(-1)
setTerminalLines((prev) => [
...prev,
{ type: "input", content: `$ ${command}`, timestamp: new Date() },
])
setTimeout(() => {
setTerminalLines((prev) => [
...prev,
{
type: "output",
content: `Executing: ${command}...`,
timestamp: new Date(),
},
{
type: "output",
content: `Command completed successfully.`,
timestamp: new Date(),
},
{ type: "output", content: "", timestamp: new Date() },
])
}, 100)
setCurrentCommand("")
}
const handleChatSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!chatInput.trim()) return
const message = chatInput.trim()
setChatMessages((prev) => [
...prev,
{
type: "user",
content: message,
timestamp: new Date(),
},
])
setChatInput("")
setIsTyping(true)
setTimeout(() => {
setIsTyping(false)
setChatMessages((prev) => [
...prev,
{
type: "agent",
content: `Processing: "${message}"`,
timestamp: new Date(),
},
])
}, 1500)
}
const handleCommandKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowUp") {
e.preventDefault()
if (commandHistory.length === 0) return
const newIndex = historyIndex === -1 ? commandHistory.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setCurrentCommand(commandHistory[newIndex])
} else if (e.key === "ArrowDown") {
e.preventDefault()
if (historyIndex === -1) return
const newIndex = historyIndex + 1
if (newIndex >= commandHistory.length) {
setHistoryIndex(-1)
setCurrentCommand("")
} else {
setHistoryIndex(newIndex)
setCurrentCommand(commandHistory[newIndex])
}
} else if (e.key === "Escape") {
setFocusedPanel("chat")
chatInputRef.current?.focus()
}
}
const handleChatKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
setFocusedPanel("terminal")
terminalInputRef.current?.focus()
}
}
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === "j" && e.ctrlKey) {
e.preventDefault()
setFocusedPanel("chat")
chatInputRef.current?.focus()
} else if (e.key === "k" && e.ctrlKey) {
e.preventDefault()
setFocusedPanel("terminal")
terminalInputRef.current?.focus()
}
}
window.addEventListener("keydown", handleGlobalKeyDown)
return () => window.removeEventListener("keydown", handleGlobalKeyDown)
}, [])
return (
<div className="h-screen w-full bg-background flex flex-col font-mono overflow-hidden p-3 sm:p-6 md:p-8">
<div className="w-full max-w-[1900px] mx-auto flex flex-col h-full gap-4 sm:gap-6">
{/* Header with corner accents */}
<div className="relative flex-shrink-0">
{/* Corner brackets */}
<div className="absolute -left-1 -top-1 w-6 h-6 border-l-2 border-t-2 border-primary" />
<div className="absolute -right-1 -top-1 w-6 h-6 border-r-2 border-t-2 border-primary" />
<div className="absolute -left-1 -bottom-1 w-6 h-6 border-l-2 border-b-2 border-primary" />
<div className="absolute -right-1 -bottom-1 w-6 h-6 border-r-2 border-b-2 border-primary" />
<div className="border-l border-r border-border bg-card px-6 sm:px-8 py-3 sm:py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4">
<div className="w-1 h-8 bg-primary" />
<SecurityIcon className="w-5 h-5 sm:w-6 sm:h-6 text-primary hidden xs:block" />
<div>
<div className="text-foreground text-sm sm:text-base font-bold tracking-wider">
BANDIT RUNNER
</div>
<div className="text-muted-foreground text-[10px] sm:text-xs">
Security Automation Console
</div>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-4 text-[10px] sm:text-xs">
{mounted && (
<div className="hidden md:flex items-center gap-2">
<span className="text-muted-foreground">SESSION</span>
<span className="text-primary font-mono">{sessionTime}</span>
</div>
)}
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 bg-accent-foreground" />
<span className="text-accent-foreground font-bold">ONLINE</span>
</div>
<a
href="https://git.biohazardvfx.com/Nicholai/bandit-runner"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center border border-border bg-card text-muted-foreground hover:text-primary hover:border-primary transition-colors"
title="View Repository"
>
<Github className="w-4 h-4" />
</a>
<ThemeToggle />
</div>
</div>
</div>
</div>
{/* Main content area */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-4 sm:gap-6 min-h-0">
{/* Terminal Panel */}
<div className="relative flex flex-col h-[55vh] lg:h-auto">
{/* Corner accents */}
<div className="absolute -left-1 -top-1 w-4 h-4 border-l-2 border-t-2 border-primary z-20" />
<div className="absolute -right-1 -top-1 w-4 h-4 border-r-2 border-t-2 border-primary z-20" />
<div className="flex-1 flex flex-col border border-border bg-card relative overflow-hidden">
<div className={GRID_PATTERN} />
<div className={SCAN_LINES_OVERLAY} />
{/* Header */}
<div className="relative z-10 flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-primary" />
<span className="text-foreground text-xs font-bold tracking-widest">TERMINAL</span>
</div>
<div className="flex gap-1.5">
<div className="w-3 h-3 border border-primary/40" />
<div className="w-3 h-3 border border-primary/40" />
<div className="w-3 h-3 border border-primary" />
</div>
</div>
{/* Terminal content */}
<ScrollArea className="flex-1 p-4 relative z-10">
<div className="space-y-1">
{terminalLines.map((line, idx) => (
<div
key={idx}
className={cn(
"text-xs md:text-sm leading-relaxed flex gap-3 font-mono",
line.type === "input" && "text-accent-foreground font-bold",
line.type === "output" && "text-foreground/80",
line.type === "error" && "text-destructive",
)}
>
{line.content && (
<>
<span className="text-muted-foreground text-[10px] sm:text-xs flex-shrink-0 w-20">
{formatTimestamp(line.timestamp)}
</span>
<span className="flex-1">{line.content}</span>
</>
)}
</div>
))}
<div ref={terminalEndRef} />
</div>
</ScrollArea>
{/* Input area */}
<div className="border-t border-border p-3 bg-muted/20 relative z-10">
<form onSubmit={handleCommandSubmit} className="flex items-center gap-2">
<div className="flex items-center gap-2 text-primary">
<div className="w-1 h-4 bg-primary" />
<span className="text-sm">$</span>
</div>
<Input
ref={terminalInputRef}
value={currentCommand}
onChange={(e) => setCurrentCommand(e.target.value)}
onKeyDown={handleCommandKeyDown}
placeholder="enter command..."
className="flex-1 bg-transparent border-0 text-foreground placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 font-mono text-sm h-6 px-0 caret-primary"
/>
</form>
</div>
{/* Footer */}
<div className="border-t border-border px-3 py-1.5 bg-muted/30 relative z-10">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span className="hidden sm:inline">user@bandit-runner</span>
<span> history ESC switch panels</span>
</div>
</div>
</div>
</div>
{/* Agent Panel */}
<div className="relative flex flex-col h-[40vh] lg:h-auto">
{/* Corner accents */}
<div className="absolute -left-1 -top-1 w-4 h-4 border-l-2 border-t-2 border-primary z-20" />
<div className="absolute -right-1 -top-1 w-4 h-4 border-r-2 border-t-2 border-primary z-20" />
<div className="flex-1 flex flex-col border border-border bg-card relative overflow-hidden">
<div className={GRID_PATTERN} />
<div className={SCAN_LINES_OVERLAY} />
{/* Header */}
<div className="relative z-10 flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-1 h-4 bg-accent-foreground" />
<span className="text-foreground text-xs font-bold tracking-widest">AGENT</span>
</div>
<div className="flex gap-1.5">
<div className="w-3 h-3 border border-accent-foreground/40" />
<div className="w-3 h-3 border border-accent-foreground" />
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4 relative z-10">
<div className="space-y-4">
{chatMessages.map((msg, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center gap-2 text-[10px]">
<span className="text-muted-foreground font-mono">
{formatTimestamp(msg.timestamp)}
</span>
<div className="h-px flex-1 bg-border" />
<span className={cn(
"font-bold px-2 py-0.5 border",
msg.type === "user"
? "text-accent-foreground border-accent-foreground/30"
: "text-primary border-primary/30"
)}>
{msg.type === "user" ? "USER" : "AGENT"}
</span>
</div>
<div className={cn(
"text-xs md:text-sm leading-relaxed pl-4 border-l-2 font-mono",
msg.type === "user"
? "text-accent-foreground border-accent-foreground/30"
: "text-foreground/80 border-primary/30"
)}>
{msg.content}
</div>
</div>
))}
{isTyping && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px]">
<span className="text-muted-foreground font-mono">
{formatTimestamp(new Date())}
</span>
<div className="h-px flex-1 bg-border" />
<span className="text-primary border border-primary/30 font-bold px-2 py-0.5">
AGENT
</span>
</div>
<div className="text-xs md:text-sm text-foreground/60 pl-4 border-l-2 border-primary/30 flex items-center gap-2">
<span>Processing</span>
<span className="animate-cursor-blink text-primary"></span>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
</ScrollArea>
{/* Input area */}
<div className="border-t border-border p-3 bg-muted/20 relative z-10">
<form onSubmit={handleChatSubmit} className="flex items-center gap-2 bg-background/40 px-3 py-2 border border-border/50">
<div className="flex items-center gap-2 text-primary">
<div className="w-1 h-4 bg-accent-foreground" />
<span className="text-sm"></span>
</div>
<Input
ref={chatInputRef}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={handleChatKeyDown}
placeholder="message agent..."
className="flex-1 bg-transparent border-0 text-foreground placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 font-mono text-sm h-6 px-0 caret-accent-foreground"
/>
</form>
</div>
{/* Footer */}
<div className="border-t border-border px-3 py-1.5 bg-muted/30 relative z-10">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Ctrl+K/J nav</span>
<span className="hidden sm:inline">DeepSeek-V3</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,12 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<button
className="w-8 h-8 flex items-center justify-center border border-border bg-card text-muted-foreground"
disabled
>
<div className="w-4 h-4" />
</button>
)
}
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center border border-primary/40 bg-card text-primary hover:border-primary transition-colors"
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</button>
)
}

View File

@ -0,0 +1,65 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn-ui/components/ui/tooltip';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ComponentProps } from 'react';
export type ActionsProps = ComponentProps<'div'>;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = 'ghost',
size = 'sm',
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
'size-9 p-1.5 text-muted-foreground hover:text-foreground',
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@ -0,0 +1,212 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
type BranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const BranchContext = createContext<BranchContextType | null>(null);
const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error('Branch components must be used within Branch');
}
return context;
};
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = Array.isArray(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 BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const BranchSelector = ({
className,
from,
...props
}: BranchSelectorProps) => {
const { totalBranches } = useBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<div
className={cn(
'flex items-center gap-2 self-end px-10',
from === 'assistant' ? 'justify-start' : 'justify-end',
className
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
'size-7 shrink-0 rounded-full text-muted-foreground transition-colors',
'hover:bg-accent hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
'font-medium text-muted-foreground text-xs tabular-nums',
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View File

@ -0,0 +1,148 @@
'use client';
import { Button } from '@/components/ui/shadcn-io/button';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/esm/styles/prism';
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'relative w-full overflow-hidden rounded-md border bg-background text-foreground',
className
)}
{...props}
>
<div className="relative">
<SyntaxHighlighter
className="overflow-hidden dark:hidden"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneLight}
>
{code}
</SyntaxHighlighter>
<SyntaxHighlighter
className="hidden overflow-hidden dark:block"
codeTagProps={{
className: 'font-mono text-sm',
}}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
}}
language={language}
lineNumberStyle={{
color: 'hsl(var(--muted-foreground))',
paddingRight: '1rem',
minWidth: '2.5rem',
}}
showLineNumbers={showLineNumbers}
style={oneDark}
>
{code}
</SyntaxHighlighter>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</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 { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
onError?.(new Error('Clipboard API not available'));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View File

@ -0,0 +1,62 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ArrowDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', 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('p-4', className)} {...props} />
);
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',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@ -0,0 +1,24 @@
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { Experimental_GeneratedImage } from 'ai';
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
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}`}
/>
);

View File

@ -0,0 +1,283 @@
'use client';
import { Badge } from '@repo/shadcn-ui/components/ui/badge';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@repo/shadcn-ui/components/ui/carousel';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@repo/shadcn-ui/components/ui/hover-card';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import { type ComponentProps, useCallback, useEffect, useState, useRef, createContext, useContext } from 'react';
// Context to share carousel API with child components
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
// Hook to access carousel API from the nearest InlineCitationCarousel parent
const useCarouselApi = () => {
const api = useContext(CarouselApiContext);
return api;
};
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 asChild>
<Badge
className={cn('ml-1 rounded-full', className)}
variant="secondary"
{...props}
>
{sources.length ? (
<>
{new URL(sources[0]).hostname}{' '}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
'unknown'
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<'div'>;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
);
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', 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);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on('select', () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
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>
);

View File

@ -0,0 +1,96 @@
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { HTMLAttributes } from 'react';
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: 'currentcolor' }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
'inline-flex animate-spin items-center justify-center',
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View File

@ -0,0 +1,64 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/shadcn-io/avatar';
import { cn } from '@/lib/utils';
import type { UIMessage } from 'ai';
import type { ComponentProps, HTMLAttributes } from 'react';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full items-end justify-end gap-2 py-4',
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
'[&>div]:max-w-[80%]',
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
'flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm',
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
className
)}
{...props}
>
<div className="is-user:dark">{children}</div>
</div>
);
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
src: string;
name?: string;
};
export const MessageAvatar = ({
src,
name,
className,
...props
}: MessageAvatarProps) => (
<Avatar
className={cn('size-8 ring ring-1 ring-border', className)}
{...props}
>
<AvatarImage alt="" className="mt-0 mb-0" src={src} />
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
</Avatar>
);

View File

@ -0,0 +1,225 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn-ui/components/ui/select';
import { Textarea } from '@repo/shadcn-ui/components/ui/textarea';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ChatStatus } from 'ai';
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
KeyboardEventHandler,
} from 'react';
import { Children } from 'react';
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
<form
className={cn(
'w-full divide-y overflow-hidden rounded-xl border bg-background shadow-sm',
className
)}
{...props}
/>
);
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
minHeight?: number;
maxHeight?: number;
};
export const PromptInputTextarea = ({
onChange,
className,
placeholder = 'What would you like to know?',
minHeight = 48,
maxHeight = 164,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Allow newline
return;
}
// Submit on Enter (without Shift)
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
}
}
};
return (
<Textarea
className={cn(
'w-full resize-none rounded-none border-none p-3 shadow-none outline-none ring-0',
'field-sizing-content max-h-[6lh] bg-transparent dark:bg-transparent',
'focus-visible:ring-0',
className
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputToolbar = ({
className,
...props
}: PromptInputToolbarProps) => (
<div
className={cn('flex items-center justify-between p-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn(
'flex items-center gap-1',
'[&_button:first-child]:rounded-bl-xl',
className
)}
{...props}
/>
);
export type PromptInputButtonProps = ComponentProps<typeof Button>;
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
...props
}: PromptInputButtonProps) => {
const newSize =
(size ?? Children.count(props.children) > 1) ? 'default' : 'icon';
return (
<Button
className={cn(
'shrink-0 gap-1.5 rounded-lg',
variant === 'ghost' && 'text-muted-foreground',
newSize === 'default' && 'px-3',
className
)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
};
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
status?: ChatStatus;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon',
status,
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <SendIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Loader2Icon className="size-4 animate-spin" />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
return (
<Button
className={cn('gap-1.5 rounded-lg', className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? Icon}
</Button>
);
};
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
<Select {...props} />
);
export type PromptInputModelSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputModelSelectTrigger = ({
className,
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className
)}
{...props}
/>
);
export type PromptInputModelSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputModelSelectContent = ({
className,
...props
}: PromptInputModelSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputModelSelectItem = ({
className,
...props
}: PromptInputModelSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputModelSelectValueProps = ComponentProps<
typeof SelectValue
>;
export const PromptInputModelSelectValue = ({
className,
...props
}: PromptInputModelSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);

View File

@ -0,0 +1,180 @@
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/shadcn-io/collapsible';
import { cn } from '@/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import { Response } from './response';
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
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;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = false,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
});
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.round((Date.now() - startTime) / 1000));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (isStreaming && !isOpen) {
setIsOpen(true);
} else if (!isStreaming && isOpen && !defaultOpen && !hasAutoClosedRef) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosedRef(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn('not-prose mb-4', className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
title?: string;
};
export const ReasoningTrigger = memo(
({
className,
title = 'Reasoning',
children,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
'flex items-center gap-2 text-muted-foreground text-sm',
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{isStreaming || duration === 0 ? (
<p>Thinking...</p>
) : (
<p>Thought for {duration} seconds</p>
)}
<ChevronDownIcon
className={cn(
'size-4 text-muted-foreground transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
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-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
)
);
Reasoning.displayName = 'Reasoning';
ReasoningTrigger.displayName = 'ReasoningTrigger';
ReasoningContent.displayName = 'ReasoningContent';

View File

@ -0,0 +1,392 @@
'use client';
import { cn } from '@/lib/utils';
import type { ComponentProps, HTMLAttributes } from 'react';
import { isValidElement, memo } from 'react';
import ReactMarkdown, { type Options } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { CodeBlock, CodeBlockCopyButton } from './code-block';
import 'katex/dist/katex.min.css';
import hardenReactMarkdown from 'harden-react-markdown';
/**
* Parses markdown text and removes incomplete tokens to prevent partial rendering
* of links, images, bold, and italic formatting during streaming.
*/
function parseIncompleteMarkdown(text: string): string {
if (!text || typeof text !== 'string') {
return text;
}
let result = text;
// Handle incomplete links and images
// Pattern: [...] or ![...] where the closing ] is missing
const linkImagePattern = /(!?\[)([^\]]*?)$/;
const linkMatch = result.match(linkImagePattern);
if (linkMatch) {
// If we have an unterminated [ or ![, remove it and everything after
const startIndex = result.lastIndexOf(linkMatch[1]);
result = result.substring(0, startIndex);
}
// Handle incomplete bold formatting (**)
const boldPattern = /(\*\*)([^*]*?)$/;
const boldMatch = result.match(boldPattern);
if (boldMatch) {
// Count the number of ** in the entire string
const asteriskPairs = (result.match(/\*\*/g) || []).length;
// If odd number of **, we have an incomplete bold - complete it
if (asteriskPairs % 2 === 1) {
result = `${result}**`;
}
}
// Handle incomplete italic formatting (__)
const italicPattern = /(__)([^_]*?)$/;
const italicMatch = result.match(italicPattern);
if (italicMatch) {
// Count the number of __ in the entire string
const underscorePairs = (result.match(/__/g) || []).length;
// If odd number of __, we have an incomplete italic - complete it
if (underscorePairs % 2 === 1) {
result = `${result}__`;
}
}
// Handle incomplete single asterisk italic (*)
const singleAsteriskPattern = /(\*)([^*]*?)$/;
const singleAsteriskMatch = result.match(singleAsteriskPattern);
if (singleAsteriskMatch) {
// Count single asterisks that aren't part of **
const singleAsterisks = result.split('').reduce((acc, char, index) => {
if (char === '*') {
// Check if it's part of a ** pair
const prevChar = result[index - 1];
const nextChar = result[index + 1];
if (prevChar !== '*' && nextChar !== '*') {
return acc + 1;
}
}
return acc;
}, 0);
// If odd number of single *, we have an incomplete italic - complete it
if (singleAsterisks % 2 === 1) {
result = `${result}*`;
}
}
// Handle incomplete single underscore italic (_)
const singleUnderscorePattern = /(_)([^_]*?)$/;
const singleUnderscoreMatch = result.match(singleUnderscorePattern);
if (singleUnderscoreMatch) {
// Count single underscores that aren't part of __
const singleUnderscores = result.split('').reduce((acc, char, index) => {
if (char === '_') {
// Check if it's part of a __ pair
const prevChar = result[index - 1];
const nextChar = result[index + 1];
if (prevChar !== '_' && nextChar !== '_') {
return acc + 1;
}
}
return acc;
}, 0);
// If odd number of single _, we have an incomplete italic - complete it
if (singleUnderscores % 2 === 1) {
result = `${result}_`;
}
}
// Handle incomplete inline code blocks (`) - but avoid code blocks (```)
const inlineCodePattern = /(`)([^`]*?)$/;
const inlineCodeMatch = result.match(inlineCodePattern);
if (inlineCodeMatch) {
// Check if we're dealing with a code block (triple backticks)
const hasCodeBlockStart = result.includes('```');
const codeBlockPattern = /```[\s\S]*?```/g;
const completeCodeBlocks = (result.match(codeBlockPattern) || []).length;
const allTripleBackticks = (result.match(/```/g) || []).length;
// If we have an odd number of ``` sequences, we're inside an incomplete code block
// In this case, don't complete inline code
const insideIncompleteCodeBlock = allTripleBackticks % 2 === 1;
if (!insideIncompleteCodeBlock) {
// Count the number of single backticks that are NOT part of triple backticks
let singleBacktickCount = 0;
for (let i = 0; i < result.length; i++) {
if (result[i] === '`') {
// Check if this backtick is part of a triple backtick sequence
const isTripleStart = result.substring(i, i + 3) === '```';
const isTripleMiddle =
i > 0 && result.substring(i - 1, i + 2) === '```';
const isTripleEnd = i > 1 && result.substring(i - 2, i + 1) === '```';
if (!(isTripleStart || isTripleMiddle || isTripleEnd)) {
singleBacktickCount++;
}
}
}
// If odd number of single backticks, we have an incomplete inline code - complete it
if (singleBacktickCount % 2 === 1) {
result = `${result}\``;
}
}
}
// Handle incomplete strikethrough formatting (~~)
const strikethroughPattern = /(~~)([^~]*?)$/;
const strikethroughMatch = result.match(strikethroughPattern);
if (strikethroughMatch) {
// Count the number of ~~ in the entire string
const tildePairs = (result.match(/~~/g) || []).length;
// If odd number of ~~, we have an incomplete strikethrough - complete it
if (tildePairs % 2 === 1) {
result = `${result}~~`;
}
}
return result;
}
// Create a hardened version of ReactMarkdown
const HardenedMarkdown = hardenReactMarkdown(ReactMarkdown);
export type ResponseProps = HTMLAttributes<HTMLDivElement> & {
options?: Options;
children: Options['children'];
allowedImagePrefixes?: ComponentProps<
ReturnType<typeof hardenReactMarkdown>
>['allowedImagePrefixes'];
allowedLinkPrefixes?: ComponentProps<
ReturnType<typeof hardenReactMarkdown>
>['allowedLinkPrefixes'];
defaultOrigin?: ComponentProps<
ReturnType<typeof hardenReactMarkdown>
>['defaultOrigin'];
parseIncompleteMarkdown?: boolean;
};
const components: Options['components'] = {
ol: ({ node, children, className, ...props }) => (
<ol className={cn('ml-4 list-outside list-decimal', className)} {...props}>
{children}
</ol>
),
li: ({ node, children, className, ...props }) => (
<li className={cn('py-1', className)} {...props}>
{children}
</li>
),
ul: ({ node, children, className, ...props }) => (
<ul className={cn('ml-4 list-outside list-disc', className)} {...props}>
{children}
</ul>
),
hr: ({ node, className, ...props }) => (
<hr className={cn('my-6 border-border', className)} {...props} />
),
strong: ({ node, children, className, ...props }) => (
<span className={cn('font-semibold', className)} {...props}>
{children}
</span>
),
a: ({ node, children, className, ...props }) => (
<a
className={cn('font-medium text-primary underline', className)}
rel="noreferrer"
target="_blank"
{...props}
>
{children}
</a>
),
h1: ({ node, children, className, ...props }) => (
<h1
className={cn('mt-6 mb-2 font-semibold text-3xl', className)}
{...props}
>
{children}
</h1>
),
h2: ({ node, children, className, ...props }) => (
<h2
className={cn('mt-6 mb-2 font-semibold text-2xl', className)}
{...props}
>
{children}
</h2>
),
h3: ({ node, children, className, ...props }) => (
<h3 className={cn('mt-6 mb-2 font-semibold text-xl', className)} {...props}>
{children}
</h3>
),
h4: ({ node, children, className, ...props }) => (
<h4 className={cn('mt-6 mb-2 font-semibold text-lg', className)} {...props}>
{children}
</h4>
),
h5: ({ node, children, className, ...props }) => (
<h5
className={cn('mt-6 mb-2 font-semibold text-base', className)}
{...props}
>
{children}
</h5>
),
h6: ({ node, children, className, ...props }) => (
<h6 className={cn('mt-6 mb-2 font-semibold text-sm', className)} {...props}>
{children}
</h6>
),
table: ({ node, children, className, ...props }) => (
<div className="my-4 overflow-x-auto">
<table
className={cn('w-full border-collapse border border-border', className)}
{...props}
>
{children}
</table>
</div>
),
thead: ({ node, children, className, ...props }) => (
<thead className={cn('bg-muted/50', className)} {...props}>
{children}
</thead>
),
tbody: ({ node, children, className, ...props }) => (
<tbody className={cn('divide-y divide-border', className)} {...props}>
{children}
</tbody>
),
tr: ({ node, children, className, ...props }) => (
<tr className={cn('border-border border-b', className)} {...props}>
{children}
</tr>
),
th: ({ node, children, className, ...props }) => (
<th
className={cn('px-4 py-2 text-left font-semibold text-sm', className)}
{...props}
>
{children}
</th>
),
td: ({ node, children, className, ...props }) => (
<td className={cn('px-4 py-2 text-sm', className)} {...props}>
{children}
</td>
),
blockquote: ({ node, children, className, ...props }) => (
<blockquote
className={cn(
'my-4 border-muted-foreground/30 border-l-4 pl-4 text-muted-foreground italic',
className
)}
{...props}
>
{children}
</blockquote>
),
code: ({ node, className, ...props }) => {
const inline = node?.position?.start.line === node?.position?.end.line;
if (!inline) {
return <code className={className} {...props} />;
}
return (
<code
className={cn(
'rounded bg-muted px-1.5 py-0.5 font-mono text-sm',
className
)}
{...props}
/>
);
},
pre: ({ node, className, children }) => {
let language = 'javascript';
if (typeof node?.properties?.className === 'string') {
language = node.properties.className.replace('language-', '');
}
// Extract code content from children safely
let code = '';
if (
isValidElement(children) &&
children.props &&
typeof (children.props as any).children === 'string'
) {
code = (children.props as any).children;
} else if (typeof children === 'string') {
code = children;
}
return (
<CodeBlock
className={cn('my-4 h-auto', className)}
code={code}
language={language}
>
<CodeBlockCopyButton
onCopy={() => console.log('Copied code to clipboard')}
onError={() => console.error('Failed to copy code to clipboard')}
/>
</CodeBlock>
);
},
};
export const Response = memo(
({
className,
options,
children,
allowedImagePrefixes,
allowedLinkPrefixes,
defaultOrigin,
parseIncompleteMarkdown: shouldParseIncompleteMarkdown = true,
...props
}: ResponseProps) => {
// Parse the children to remove incomplete markdown tokens if enabled
const parsedChildren =
typeof children === 'string' && shouldParseIncompleteMarkdown
? parseIncompleteMarkdown(children)
: children;
return (
<div
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
{...props}
>
<HardenedMarkdown
allowedImagePrefixes={allowedImagePrefixes ?? ['*']}
allowedLinkPrefixes={allowedLinkPrefixes ?? ['*']}
components={components}
defaultOrigin={defaultOrigin}
rehypePlugins={[rehypeKatex]}
remarkPlugins={[remarkGfm, remarkMath]}
{...options}
>
{parsedChildren}
</HardenedMarkdown>
</div>
);
},
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = 'Response';

View File

@ -0,0 +1,74 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/shadcn-ui/components/ui/collapsible';
import { cn } from '@repo/shadcn-ui/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="flex items-center gap-2" {...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>
);

View File

@ -0,0 +1,56 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
ScrollArea,
ScrollBar,
} from '@repo/shadcn-ui/components/ui/scroll-area';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ComponentProps } 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 = () => {
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>
);
};

View File

@ -0,0 +1,94 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/shadcn-ui/components/ui/collapsible';
import { cn } from '@repo/shadcn-ui/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(
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
defaultOpen={defaultOpen}
{...props}
/>
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
{children ?? (
<div className="flex cursor-pointer items-center gap-2 text-muted-foreground 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>
);

View File

@ -0,0 +1,142 @@
'use client';
import { Badge } from '@/components/ui/shadcn-io/badge';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/shadcn-io/collapsible';
import { cn } from '@/lib/utils';
import type { ToolUIPart } from 'ai';
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { CodeBlock } from './code-block';
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn('not-prose mb-4 w-full rounded-md border', className)}
{...props}
/>
);
export type ToolHeaderProps = {
type: ToolUIPart['type'];
state: ToolUIPart['state'];
className?: string;
};
const getStatusBadge = (status: ToolUIPart['state']) => {
const labels = {
'input-streaming': 'Pending',
'input-available': 'Running',
'output-available': 'Completed',
'output-error': 'Error',
} as const;
const icons = {
'input-streaming': <CircleIcon className="size-4" />,
'input-available': <ClockIcon className="size-4 animate-pulse" />,
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
'output-error': <XCircleIcon className="size-4 text-red-600" />,
} as const;
return (
<Badge className="rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
className,
type,
state,
...props
}: ToolHeaderProps) => (
<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">{type}</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 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<'div'> & {
input: ToolUIPart['input'];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn('space-y-2 overflow-hidden p-4', 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: ReactNode;
errorText: ToolUIPart['errorText'];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
return (
<div className={cn('space-y-2 p-4', 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>{output}</div>}
</div>
</div>
);
};

View File

@ -0,0 +1,269 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@repo/shadcn-ui/components/ui/collapsible';
import { Input } from '@repo/shadcn-ui/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn-ui/components/ui/tooltip';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ChevronDownIcon } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
export type 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 = (newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
};
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
};
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 asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</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 handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
};
// Use defaultValue for uncontrolled input when no onChange is provided
if (!onChange && !value) {
return (
<Input
className="h-8 flex-1 text-sm"
defaultValue={url}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
{...props}
/>
);
}
return (
<Input
className="h-8 flex-1 text-sm"
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? url}
{...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)}
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?: Array<{
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 asChild>
<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'
)}
/>
</Button>
</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, index) => (
<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()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{' '}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@ -4,7 +4,7 @@ import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/shadcn-io/button"
function AlertDialog({ function AlertDialog({
...props ...props

View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,620 @@
'use client';
import {
type IconType,
SiAstro,
SiBiome,
SiBower,
SiBun,
SiC,
SiCircleci,
SiCoffeescript,
SiCplusplus,
SiCss,
SiCssmodules,
SiDart,
SiDocker,
SiDocusaurus,
SiDotenv,
SiEditorconfig,
SiEslint,
SiGatsby,
SiGitignoredotio,
SiGnubash,
SiGo,
SiGraphql,
SiGrunt,
SiGulp,
SiHandlebarsdotjs,
SiHtml5,
SiJavascript,
SiJest,
SiJson,
SiLess,
SiMarkdown,
SiMdx,
SiMintlify,
SiMocha,
SiMysql,
SiNextdotjs,
SiPerl,
SiPhp,
SiPostcss,
SiPrettier,
SiPrisma,
SiPug,
SiPython,
SiR,
SiReact,
SiReadme,
SiRedis,
SiRemix,
SiRive,
SiRollupdotjs,
SiRuby,
SiSanity,
SiSass,
SiScala,
SiSentry,
SiShadcnui,
SiStorybook,
SiStylelint,
SiSublimetext,
SiSvelte,
SiSvg,
SiSwift,
SiTailwindcss,
SiToml,
SiTypescript,
SiVercel,
SiVite,
SiVuedotjs,
SiWebassembly,
} from '@icons-pack/react-simple-icons';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { CheckIcon, CopyIcon } from 'lucide-react';
import type {
ComponentProps,
HTMLAttributes,
ReactElement,
ReactNode,
} from 'react';
import {
cloneElement,
createContext,
useContext,
useEffect,
useState,
} from 'react';
import { Button } from '@/components/ui/shadcn-io/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/shadcn-io/select';
import { cn } from '@/lib/utils';
export type BundledLanguage = string;
const filenameIconMap = {
'.env': SiDotenv,
'*.astro': SiAstro,
'biome.json': SiBiome,
'.bowerrc': SiBower,
'bun.lockb': SiBun,
'*.c': SiC,
'*.cpp': SiCplusplus,
'.circleci/config.yml': SiCircleci,
'*.coffee': SiCoffeescript,
'*.module.css': SiCssmodules,
'*.css': SiCss,
'*.dart': SiDart,
Dockerfile: SiDocker,
'docusaurus.config.js': SiDocusaurus,
'.editorconfig': SiEditorconfig,
'.eslintrc': SiEslint,
'eslint.config.*': SiEslint,
'gatsby-config.*': SiGatsby,
'.gitignore': SiGitignoredotio,
'*.go': SiGo,
'*.graphql': SiGraphql,
'*.sh': SiGnubash,
'Gruntfile.*': SiGrunt,
'gulpfile.*': SiGulp,
'*.hbs': SiHandlebarsdotjs,
'*.html': SiHtml5,
'*.js': SiJavascript,
'*.json': SiJson,
'*.test.js': SiJest,
'*.less': SiLess,
'*.md': SiMarkdown,
'*.mdx': SiMdx,
'mintlify.json': SiMintlify,
'mocha.opts': SiMocha,
'*.mustache': SiHandlebarsdotjs,
'*.sql': SiMysql,
'next.config.*': SiNextdotjs,
'*.pl': SiPerl,
'*.php': SiPhp,
'postcss.config.*': SiPostcss,
'prettier.config.*': SiPrettier,
'*.prisma': SiPrisma,
'*.pug': SiPug,
'*.py': SiPython,
'*.r': SiR,
'*.rb': SiRuby,
'*.jsx': SiReact,
'*.tsx': SiReact,
'readme.md': SiReadme,
'*.rdb': SiRedis,
'remix.config.*': SiRemix,
'*.riv': SiRive,
'rollup.config.*': SiRollupdotjs,
'sanity.config.*': SiSanity,
'*.sass': SiSass,
'*.scss': SiSass,
'*.sc': SiScala,
'*.scala': SiScala,
'sentry.client.config.*': SiSentry,
'components.json': SiShadcnui,
'storybook.config.*': SiStorybook,
'stylelint.config.*': SiStylelint,
'.sublime-settings': SiSublimetext,
'*.svelte': SiSvelte,
'*.svg': SiSvg,
'*.swift': SiSwift,
'tailwind.config.*': SiTailwindcss,
'*.toml': SiToml,
'*.ts': SiTypescript,
'vercel.json': SiVercel,
'vite.config.*': SiVite,
'*.vue': SiVuedotjs,
'*.wasm': SiWebassembly,
};
const lineNumberClassNames = cn(
'[&_code]:[counter-reset:line]',
'[&_code]:[counter-increment:line_0]',
'[&_.line]:before:content-[counter(line)]',
'[&_.line]:before:inline-block',
'[&_.line]:before:[counter-increment:line]',
'[&_.line]:before:w-4',
'[&_.line]:before:mr-4',
'[&_.line]:before:text-[13px]',
'[&_.line]:before:text-right',
'[&_.line]:before:text-muted-foreground/50',
'[&_.line]:before:font-mono',
'[&_.line]:before:select-none'
);
const darkModeClassNames = cn(
'dark:[&_.shiki]:!text-[var(--shiki-dark)]',
'dark:[&_.shiki]:!bg-[var(--shiki-dark-bg)]',
'dark:[&_.shiki]:![font-style:var(--shiki-dark-font-style)]',
'dark:[&_.shiki]:![font-weight:var(--shiki-dark-font-weight)]',
'dark:[&_.shiki]:![text-decoration:var(--shiki-dark-text-decoration)]',
'dark:[&_.shiki_span]:!text-[var(--shiki-dark)]',
'dark:[&_.shiki_span]:![font-style:var(--shiki-dark-font-style)]',
'dark:[&_.shiki_span]:![font-weight:var(--shiki-dark-font-weight)]',
'dark:[&_.shiki_span]:![text-decoration:var(--shiki-dark-text-decoration)]'
);
const lineHighlightClassNames = cn(
'[&_.line.highlighted]:bg-blue-50',
'[&_.line.highlighted]:after:bg-blue-500',
'[&_.line.highlighted]:after:absolute',
'[&_.line.highlighted]:after:left-0',
'[&_.line.highlighted]:after:top-0',
'[&_.line.highlighted]:after:bottom-0',
'[&_.line.highlighted]:after:w-0.5',
'dark:[&_.line.highlighted]:!bg-blue-500/10'
);
const lineDiffClassNames = cn(
'[&_.line.diff]:after:absolute',
'[&_.line.diff]:after:left-0',
'[&_.line.diff]:after:top-0',
'[&_.line.diff]:after:bottom-0',
'[&_.line.diff]:after:w-0.5',
'[&_.line.diff.add]:bg-emerald-50',
'[&_.line.diff.add]:after:bg-emerald-500',
'[&_.line.diff.remove]:bg-rose-50',
'[&_.line.diff.remove]:after:bg-rose-500',
'dark:[&_.line.diff.add]:!bg-emerald-500/10',
'dark:[&_.line.diff.remove]:!bg-rose-500/10'
);
const lineFocusedClassNames = cn(
'[&_code:has(.focused)_.line]:blur-[2px]',
'[&_code:has(.focused)_.line.focused]:blur-none'
);
const wordHighlightClassNames = cn(
'[&_.highlighted-word]:bg-blue-50',
'dark:[&_.highlighted-word]:!bg-blue-500/10'
);
const codeBlockClassName = cn(
'mt-0 bg-background text-sm',
'[&_pre]:py-4',
'[&_.shiki]:!bg-[var(--shiki-bg)]',
'[&_code]:w-full',
'[&_code]:grid',
'[&_code]:overflow-x-auto',
'[&_code]:bg-transparent',
'[&_.line]:px-4',
'[&_.line]:w-full',
'[&_.line]:relative'
);
type CodeBlockData = {
language: string;
filename: string;
code: string;
};
type CodeBlockContextType = {
value: string | undefined;
onValueChange: ((value: string) => void) | undefined;
data: CodeBlockData[];
};
const CodeBlockContext = createContext<CodeBlockContextType>({
value: undefined,
onValueChange: undefined,
data: [],
});
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
data: CodeBlockData[];
};
export const CodeBlock = ({
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultValue,
className,
data,
...props
}: CodeBlockProps) => {
const [value, onValueChange] = useControllableState({
defaultProp: defaultValue ?? '',
prop: controlledValue,
onChange: controlledOnValueChange,
});
return (
<CodeBlockContext.Provider value={{ value, onValueChange, data }}>
<div
className={cn('size-full overflow-hidden rounded-md border', className)}
{...props}
/>
</CodeBlockContext.Provider>
);
};
export type CodeBlockHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CodeBlockHeader = ({
className,
...props
}: CodeBlockHeaderProps) => (
<div
className={cn(
'flex flex-row items-center border-b bg-secondary p-1',
className
)}
{...props}
/>
);
export type CodeBlockFilesProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: (item: CodeBlockData) => ReactNode;
};
export const CodeBlockFiles = ({
className,
children,
...props
}: CodeBlockFilesProps) => {
const { data } = useContext(CodeBlockContext);
return (
<div
className={cn('flex grow flex-row items-center gap-2', className)}
{...props}
>
{data.map(children)}
</div>
);
};
export type CodeBlockFilenameProps = HTMLAttributes<HTMLDivElement> & {
icon?: IconType;
value?: string;
};
export const CodeBlockFilename = ({
className,
icon,
value,
children,
...props
}: CodeBlockFilenameProps) => {
const { value: activeValue } = useContext(CodeBlockContext);
const defaultIcon = Object.entries(filenameIconMap).find(([pattern]) => {
const regex = new RegExp(
`^${pattern.replace(/\\/g, '\\\\').replace(/\./g, '\\.').replace(/\*/g, '.*')}$`
);
return regex.test(children as string);
})?.[1];
const Icon = icon ?? defaultIcon;
if (value !== activeValue) {
return null;
}
return (
<div
className="flex items-center gap-2 bg-secondary px-4 py-1.5 text-muted-foreground text-xs"
{...props}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="flex-1 truncate">{children}</span>
</div>
);
};
export type CodeBlockSelectProps = ComponentProps<typeof Select>;
export const CodeBlockSelect = (props: CodeBlockSelectProps) => {
const { value, onValueChange } = useContext(CodeBlockContext);
return <Select onValueChange={onValueChange} value={value} {...props} />;
};
export type CodeBlockSelectTriggerProps = ComponentProps<typeof SelectTrigger>;
export const CodeBlockSelectTrigger = ({
className,
...props
}: CodeBlockSelectTriggerProps) => (
<SelectTrigger
className={cn(
'w-fit border-none text-muted-foreground text-xs shadow-none',
className
)}
{...props}
/>
);
export type CodeBlockSelectValueProps = ComponentProps<typeof SelectValue>;
export const CodeBlockSelectValue = (props: CodeBlockSelectValueProps) => (
<SelectValue {...props} />
);
export type CodeBlockSelectContentProps = Omit<
ComponentProps<typeof SelectContent>,
'children'
> & {
children: (item: CodeBlockData) => ReactNode;
};
export const CodeBlockSelectContent = ({
children,
...props
}: CodeBlockSelectContentProps) => {
const { data } = useContext(CodeBlockContext);
return <SelectContent {...props}>{data.map(children)}</SelectContent>;
};
export type CodeBlockSelectItemProps = ComponentProps<typeof SelectItem>;
export const CodeBlockSelectItem = ({
className,
...props
}: CodeBlockSelectItemProps) => (
<SelectItem className={cn('text-sm', className)} {...props} />
);
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
asChild,
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { data, value } = useContext(CodeBlockContext);
const code = data.find((item) => item.language === value)?.code;
const copyToClipboard = () => {
if (
typeof window === 'undefined' ||
!navigator.clipboard.writeText ||
!code
) {
return;
}
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
}, onError);
};
if (asChild) {
return cloneElement(children as ReactElement, {
// @ts-expect-error - we know this is a button
onClick: copyToClipboard,
});
}
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn('shrink-0', className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon className="text-muted-foreground" size={14} />}
</Button>
);
};
type CodeBlockFallbackProps = HTMLAttributes<HTMLDivElement>;
const CodeBlockFallback = ({ children, ...props }: CodeBlockFallbackProps) => (
<div {...props}>
<pre className="w-full">
<code>
{children
?.toString()
.split('\n')
.map((line, i) => (
<span className="line" key={i}>
{line}
</span>
))}
</code>
</pre>
</div>
);
export type CodeBlockBodyProps = Omit<
HTMLAttributes<HTMLDivElement>,
'children'
> & {
children: (item: CodeBlockData) => ReactNode;
};
export const CodeBlockBody = ({ children, ...props }: CodeBlockBodyProps) => {
const { data } = useContext(CodeBlockContext);
return <div {...props}>{data.map(children)}</div>;
};
export type CodeBlockItemProps = HTMLAttributes<HTMLDivElement> & {
value: string;
lineNumbers?: boolean;
};
export const CodeBlockItem = ({
children,
lineNumbers = true,
className,
value,
...props
}: CodeBlockItemProps) => {
const { value: activeValue } = useContext(CodeBlockContext);
if (value !== activeValue) {
return null;
}
return (
<div
className={cn(
codeBlockClassName,
lineHighlightClassNames,
lineDiffClassNames,
lineFocusedClassNames,
wordHighlightClassNames,
darkModeClassNames,
lineNumbers && lineNumberClassNames,
className
)}
{...props}
>
{children}
</div>
);
};
export type CodeBlockContentProps = HTMLAttributes<HTMLDivElement> & {
themes?: {
light: string;
dark: string;
};
language?: BundledLanguage;
syntaxHighlighting?: boolean;
children: string;
};
export const CodeBlockContent = ({
children,
themes = {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
language = 'typescript',
syntaxHighlighting = true,
...props
}: CodeBlockContentProps) => {
const [highlightedCode, setHighlightedCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(syntaxHighlighting);
useEffect(() => {
if (!syntaxHighlighting) {
setIsLoading(false);
return;
}
const loadHighlightedCode = async () => {
try {
const { codeToHtml } = await import('shiki');
const html = await codeToHtml(children, {
lang: language,
themes: {
light: themes.light,
dark: themes.dark,
},
});
setHighlightedCode(html);
setIsLoading(false);
} catch (error) {
console.error(`Failed to highlight code for language "${language}":`, error);
setIsLoading(false);
}
};
loadHighlightedCode();
}, [children, language, themes, syntaxHighlighting]);
if (!syntaxHighlighting || isLoading) {
return <CodeBlockFallback {...props}>{children}</CodeBlockFallback>;
}
return (
<div
dangerouslySetInnerHTML={{ __html: highlightedCode }}
{...props}
/>
);
};

View File

@ -0,0 +1,63 @@
import {
transformerNotationDiff,
transformerNotationErrorLevel,
transformerNotationFocus,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from '@shikijs/transformers';
import type { HTMLAttributes } from 'react';
import {
type BundledLanguage,
type CodeOptionsMultipleThemes,
codeToHtml,
} from 'shiki';
export type CodeBlockContentProps = HTMLAttributes<HTMLDivElement> & {
themes?: CodeOptionsMultipleThemes['themes'];
language?: BundledLanguage;
children: string;
syntaxHighlighting?: boolean;
};
export const CodeBlockContent = async ({
children,
themes,
language,
syntaxHighlighting = true,
...props
}: CodeBlockContentProps) => {
const html = syntaxHighlighting
? await codeToHtml(children as string, {
lang: language ?? 'typescript',
themes: themes ?? {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
transformers: [
transformerNotationDiff({
matchAlgorithm: 'v3',
}),
transformerNotationHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationWordHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationFocus({
matchAlgorithm: 'v3',
}),
transformerNotationErrorLevel({
matchAlgorithm: 'v3',
}),
],
})
: children;
return (
<div
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Kinda how Shiki works"
dangerouslySetInnerHTML={{ __html: html }}
{...props}
/>
);
};

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }