diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx
index a1d89c16..6a748e99 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -12,10 +12,10 @@ import {
CardDescription,
CardContent,
CardFooter,
-} from "@multica/ui/components/ui/card";
-import { Input } from "@multica/ui/components/ui/input";
-import { Button } from "@multica/ui/components/ui/button";
-import { Label } from "@multica/ui/components/ui/label";
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
function LoginPageContent() {
const router = useRouter();
diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
index 79428b46..b1ebd3b4 100644
--- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx
+++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx
@@ -14,7 +14,7 @@ import {
Plus,
Check,
} from "lucide-react";
-import { MulticaIcon } from "@multica/ui/components/multica-icon";
+import { MulticaIcon } from "@/components/multica-icon";
import {
Sidebar,
SidebarContent,
@@ -25,7 +25,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
-} from "@multica/ui/components/ui/sidebar";
+} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -34,10 +34,10 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "@multica/ui/components/ui/dropdown-menu";
-import { Input } from "@multica/ui/components/ui/input";
-import { Label } from "@multica/ui/components/ui/label";
-import { Button } from "@multica/ui/components/ui/button";
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -45,7 +45,7 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
-} from "@multica/ui/components/ui/dialog";
+} from "@/components/ui/dialog";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx
index c762a8d7..7e9c6ea4 100644
--- a/apps/web/app/(dashboard)/agents/page.tsx
+++ b/apps/web/app/(dashboard)/agents/page.tsx
@@ -40,11 +40,11 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
-} from "@multica/ui/components/ui/dialog";
-import { Button } from "@multica/ui/components/ui/button";
-import { Input } from "@multica/ui/components/ui/input";
-import { Textarea } from "@multica/ui/components/ui/textarea";
-import { Label } from "@multica/ui/components/ui/label";
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx
index da8b1237..ea804419 100644
--- a/apps/web/app/(dashboard)/inbox/page.tsx
+++ b/apps/web/app/(dashboard)/inbox/page.tsx
@@ -11,7 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types";
-import { Button } from "@multica/ui/components/ui/button";
+import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
index 6e23b41f..bf5c1faa 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
@@ -66,8 +66,8 @@ vi.mock("@/features/realtime", () => ({
useWSEvent: () => {},
}));
-// Mock @multica/ui calendar (react-day-picker needs browser APIs)
-vi.mock("@multica/ui/components/ui/calendar", () => ({
+// Mock calendar (react-day-picker needs browser APIs)
+vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx
index 76e56bca..27483389 100644
--- a/apps/web/app/(dashboard)/issues/[id]/page.tsx
+++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx
@@ -23,16 +23,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
-} from "@multica/ui/components/ui/alert-dialog";
-import { Calendar } from "@multica/ui/components/ui/calendar";
+} from "@/components/ui/alert-dialog";
+import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
-} from "@multica/ui/components/ui/popover";
-import { Button } from "@multica/ui/components/ui/button";
-import { Input } from "@multica/ui/components/ui/input";
-import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ActorAvatar } from "@/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
import { api } from "@/shared/api";
diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx
index 31157240..040db17a 100644
--- a/apps/web/app/(dashboard)/issues/page.tsx
+++ b/apps/web/app/(dashboard)/issues/page.tsx
@@ -29,18 +29,19 @@ import {
DialogTitle,
DialogFooter,
DialogTrigger,
-} from "@multica/ui/components/ui/dialog";
-import { Button } from "@multica/ui/components/ui/button";
-import { Input } from "@multica/ui/components/ui/input";
-import { Textarea } from "@multica/ui/components/ui/textarea";
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
-} from "@multica/ui/components/ui/select";
-import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
+ SelectGroup,
+} from "@/components/ui/select";
+import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useActorName } from "@/features/workspace";
@@ -567,10 +568,12 @@ export default function IssuesPage() {
- All Status
- {ALL_STATUSES.map((s) => (
- {STATUS_CONFIG[s].label}
- ))}
+
+ All Status
+ {ALL_STATUSES.map((s) => (
+ {STATUS_CONFIG[s].label}
+ ))}
+
diff --git a/apps/web/app/(dashboard)/knowledge-base/page.tsx b/apps/web/app/(dashboard)/knowledge-base/page.tsx
index a49168b4..d33cdfe4 100644
--- a/apps/web/app/(dashboard)/knowledge-base/page.tsx
+++ b/apps/web/app/(dashboard)/knowledge-base/page.tsx
@@ -7,8 +7,8 @@ import {
Search,
Link as LinkIcon,
} from "lucide-react";
-import { Input } from "@multica/ui/components/ui/input";
-import { Button } from "@multica/ui/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
index 60be7c5c..4388ba1f 100644
--- a/apps/web/app/(dashboard)/layout.tsx
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -2,8 +2,8 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { MulticaIcon } from "@multica/ui/components/multica-icon";
-import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
+import { MulticaIcon } from "@/components/multica-icon";
+import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { AppSidebar } from "./_components/app-sidebar";
diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx
index ea117c7b..a2be16db 100644
--- a/apps/web/app/(dashboard)/settings/page.tsx
+++ b/apps/web/app/(dashboard)/settings/page.tsx
@@ -3,17 +3,17 @@
import { useEffect, useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
-import { Input } from "@multica/ui/components/ui/input";
-import { Textarea } from "@multica/ui/components/ui/textarea";
-import { Label } from "@multica/ui/components/ui/label";
-import { Button } from "@multica/ui/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
-} from "@multica/ui/components/ui/select";
+} from "@/components/ui/select";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
diff --git a/apps/web/app/custom.css b/apps/web/app/custom.css
new file mode 100644
index 00000000..c9567711
--- /dev/null
+++ b/apps/web/app/custom.css
@@ -0,0 +1,31 @@
+/* =============================================================================
+ * Multica Web — Custom styles (non-shadcn)
+ * ============================================================================= */
+
+/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
+/* @see https://shiki.style/guide/dual-themes */
+.shiki,
+.shiki span {
+ color: var(--shiki-light);
+}
+
+.dark .shiki,
+.dark .shiki span {
+ color: var(--shiki-dark) !important;
+}
+
+/* Multica icon: entrance spin animation */
+@keyframes entrance-spin {
+ 0% { transform: rotate(0deg); opacity: 0; }
+ 50% { opacity: 1; }
+ 100% { transform: rotate(360deg); opacity: 1; }
+}
+
+.animate-entrance-spin {
+ animation: entrance-spin 0.6s ease-out forwards;
+}
+
+/* Shadcn sidebar: remove default padding from inset container */
+[data-slot="sidebar-container"] {
+ padding: 0 !important;
+}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 30c92deb..18f4f442 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -1,2 +1,155 @@
@import "tailwindcss";
-@import "@multica/ui/globals.css";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "./custom.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-heading: var(--font-sans);
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --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-success: var(--success);
+ --color-warning: var(--warning);
+ --color-info: var(--info);
+ --color-canvas: var(--canvas);
+ --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-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.871 0.006 286.286);
+ --chart-2: oklch(0.552 0.016 285.938);
+ --chart-3: oklch(0.442 0.017 285.786);
+ --chart-4: oklch(0.37 0.013 285.805);
+ --chart-5: oklch(0.274 0.006 286.033);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+ --canvas: oklch(0.95 0.002 286);
+ --success: oklch(0.55 0.16 145);
+ --warning: oklch(0.75 0.16 85);
+ --info: oklch(0.55 0.18 250);
+ --scrollbar-thumb: oklch(0.82 0.003 286);
+ --scrollbar-thumb-hover: oklch(0.705 0.015 286.067);
+ --scrollbar-track: transparent;
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.92 0.004 286.32);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.871 0.006 286.286);
+ --chart-2: oklch(0.552 0.016 285.938);
+ --chart-3: oklch(0.442 0.017 285.786);
+ --chart-4: oklch(0.37 0.013 285.805);
+ --chart-5: oklch(0.274 0.006 286.033);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+ --canvas: oklch(0.2 0.005 286);
+ --success: oklch(0.65 0.15 145);
+ --warning: oklch(0.70 0.16 85);
+ --info: oklch(0.65 0.18 250);
+ --scrollbar-thumb: oklch(1 0 0 / 15%);
+ --scrollbar-thumb-hover: oklch(1 0 0 / 30%);
+ --scrollbar-track: transparent;
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ scrollbar-width: thin;
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
+ }
+ *::-webkit-scrollbar { width: 6px; height: 6px; }
+ *::-webkit-scrollbar-track { background: var(--scrollbar-track); }
+ *::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
+ *::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
\ No newline at end of file
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 8c4bc1b7..da6e533b 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,10 +1,15 @@
import type { Metadata } from "next";
-import { ThemeProvider } from "@multica/ui/components/theme-provider";
-import { Toaster } from "@multica/ui/components/ui/sonner";
+import { Geist, Geist_Mono } from "next/font/google";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/sonner";
+import { cn } from "@/lib/utils";
import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime";
import "./globals.css";
+const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
+const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
+
export const metadata: Metadata = {
title: "Multica",
description: "AI-native task management",
@@ -20,14 +25,13 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
-
+
{children}
diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx
index f25fae49..0ce7e1f6 100644
--- a/apps/web/app/pair/local/page.tsx
+++ b/apps/web/app/pair/local/page.tsx
@@ -4,14 +4,14 @@ import Link from "next/link";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { DaemonPairingSession } from "@multica/types";
-import { Button } from "@multica/ui/components/ui/button";
-import { Label } from "@multica/ui/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
-} from "@multica/ui/components/ui/select";
+} from "@/components/ui/select";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 00000000..661ed3de
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "inverted-translucent",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/apps/web/components/common/actor-avatar.tsx b/apps/web/components/common/actor-avatar.tsx
new file mode 100644
index 00000000..c9133a7a
--- /dev/null
+++ b/apps/web/components/common/actor-avatar.tsx
@@ -0,0 +1,44 @@
+import { Bot } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface ActorAvatarProps {
+ actorType: string;
+ actorId: string;
+ size?: number;
+ getName?: (type: string, id: string) => string;
+ getInitials?: (type: string, id: string) => string;
+ className?: string;
+}
+
+function ActorAvatar({
+ actorType,
+ actorId,
+ size = 20,
+ getName,
+ getInitials,
+ className,
+}: ActorAvatarProps) {
+ const name = getName?.(actorType, actorId);
+ const initials = getInitials?.(actorType, actorId);
+ const isAgent = actorType === "agent";
+
+ return (
+
+ {isAgent ? (
+
+ ) : (
+ initials
+ )}
+
+ );
+}
+
+export { ActorAvatar, type ActorAvatarProps };
diff --git a/apps/web/components/loading-indicator.tsx b/apps/web/components/loading-indicator.tsx
new file mode 100644
index 00000000..8216e45b
--- /dev/null
+++ b/apps/web/components/loading-indicator.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { Spinner } from "@/components/spinner";
+import { cn } from "@/lib/utils";
+
+export type LoadingVariant = "generating" | "streaming";
+
+interface LoadingIndicatorProps {
+ variant: LoadingVariant;
+ className?: string;
+}
+
+const VARIANT_TEXT: Record = {
+ generating: "Generating...",
+ streaming: "Streaming...",
+};
+
+/**
+ * Unified loading indicator for chat.
+ * Use "generating" when waiting for AI response (no content yet).
+ * Use "streaming" when content is actively being received.
+ */
+export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) {
+ return (
+
+
+ {VARIANT_TEXT[variant]}
+
+ );
+}
diff --git a/apps/web/components/markdown/CodeBlock.tsx b/apps/web/components/markdown/CodeBlock.tsx
new file mode 100644
index 00000000..414740c9
--- /dev/null
+++ b/apps/web/components/markdown/CodeBlock.tsx
@@ -0,0 +1,250 @@
+import * as React from 'react'
+import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
+import { cn } from '@/lib/utils'
+
+export interface CodeBlockProps {
+ code: string
+ language?: string
+ className?: string
+ /**
+ * Render mode affects code block styling:
+ * - 'terminal': Minimal, keeps control chars visible
+ * - 'minimal': Clean code, basic styling
+ * - 'full': Rich styling with background, copy button, etc.
+ */
+ mode?: 'terminal' | 'minimal' | 'full'
+}
+
+// Map common aliases to Shiki language names
+const LANGUAGE_ALIASES: Record = {
+ js: 'javascript',
+ ts: 'typescript',
+ py: 'python',
+ sh: 'bash',
+ zsh: 'bash',
+ yml: 'yaml',
+ rb: 'ruby',
+ rs: 'rust',
+ kt: 'kotlin',
+ 'objective-c': 'objc',
+ objc: 'objc'
+}
+
+// Simple LRU cache for highlighted code
+const highlightCache = new Map()
+const CACHE_MAX_SIZE = 200
+
+function getCacheKey(code: string, lang: string): string {
+ return `${lang}:${code}`
+}
+
+function isValidLanguage(lang: string): lang is BundledLanguage {
+ const normalized = LANGUAGE_ALIASES[lang] || lang
+ return normalized in bundledLanguages
+}
+
+/**
+ * CodeBlock - Syntax highlighted code block using Shiki
+ *
+ * Uses Shiki dual themes with CSS variables for light/dark switching.
+ * No JS-based dark mode detection needed — theme switching is handled
+ * entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
+ *
+ * @see https://shiki.style/guide/dual-themes
+ */
+export function CodeBlock({
+ code,
+ language = 'text',
+ className,
+ mode = 'full'
+}: CodeBlockProps): React.JSX.Element {
+ const [highlighted, setHighlighted] = React.useState(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [copied, setCopied] = React.useState(false)
+
+ // Resolve language alias - keep as string to allow 'text' fallback
+ const langLower = language.toLowerCase()
+ const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
+
+ React.useEffect(() => {
+ let cancelled = false
+
+ async function highlight(): Promise {
+ const cacheKey = getCacheKey(code, resolvedLang)
+
+ const cached = highlightCache.get(cacheKey)
+ if (cached) {
+ if (!cancelled) {
+ setHighlighted(cached)
+ setIsLoading(false)
+ }
+ return
+ }
+
+ try {
+ // Use valid language or fallback to plaintext
+ const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
+
+ // Dual themes: Shiki outputs CSS variables for both themes in one pass.
+ // CSS handles switching via .dark selector (see globals.css).
+ const html = await codeToHtml(code, {
+ lang,
+ themes: {
+ light: 'github-light',
+ dark: 'github-dark',
+ },
+ defaultColor: false,
+ })
+
+ // Cache the result
+ if (highlightCache.size >= CACHE_MAX_SIZE) {
+ const firstKey = highlightCache.keys().next().value
+ if (firstKey) highlightCache.delete(firstKey)
+ }
+ highlightCache.set(cacheKey, html)
+
+ if (!cancelled) {
+ setHighlighted(html)
+ setIsLoading(false)
+ }
+ } catch (error) {
+ // Fallback to plain text on error
+ console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
+ if (!cancelled) {
+ setHighlighted(null)
+ setIsLoading(false)
+ }
+ }
+ }
+
+ highlight()
+
+ return () => {
+ cancelled = true
+ }
+ }, [code, resolvedLang])
+
+ const handleCopy = React.useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(code)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (err) {
+ console.error('Failed to copy code:', err)
+ }
+ }, [code])
+
+ // Terminal mode: raw monospace with minimal styling
+ if (mode === 'terminal') {
+ return (
+
+ {code}
+
+ )
+ }
+
+ // Minimal mode: just syntax highlighting, no chrome
+ if (mode === 'minimal') {
+ if (isLoading || !highlighted) {
+ return (
+
+ {code}
+
+ )
+ }
+
+ return (
+
+ )
+ }
+
+ // Full mode: rich styling with header and copy button
+ return (
+
+ {/* Language label + copy button */}
+
+
+ {resolvedLang !== 'text' ? resolvedLang : 'plain text'}
+
+
+
+
+ {/* Code content */}
+
+ {isLoading || !highlighted ? (
+
+ {code}
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+/**
+ * InlineCode - Styled inline code span
+ * Features: subtle background (3%), subtle border (5%), 75% opacity text
+ */
+export function InlineCode({
+ children,
+ className
+}: {
+ children: React.ReactNode
+ className?: string
+}): React.JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx
new file mode 100644
index 00000000..348cfc37
--- /dev/null
+++ b/apps/web/components/markdown/Markdown.tsx
@@ -0,0 +1,301 @@
+import * as React from 'react'
+import ReactMarkdown, { type Components } from 'react-markdown'
+import rehypeRaw from 'rehype-raw'
+import remarkGfm from 'remark-gfm'
+import { cn } from '@/lib/utils'
+import { CodeBlock, InlineCode } from './CodeBlock'
+import { preprocessLinks } from './linkify'
+
+/**
+ * Render modes for markdown content:
+ *
+ * - 'terminal': Raw output with minimal formatting, control chars visible
+ * Best for: Debug output, raw logs, when you want to see exactly what's there
+ *
+ * - 'minimal': Clean rendering with syntax highlighting but no extra chrome
+ * Best for: Chat messages, inline content, when you want readability without clutter
+ *
+ * - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
+ * Best for: Documentation, long-form content, when presentation matters
+ */
+export type RenderMode = 'terminal' | 'minimal' | 'full'
+
+export interface MarkdownProps {
+ children: string
+ /**
+ * Render mode controlling formatting level
+ * @default 'minimal'
+ */
+ mode?: RenderMode
+ className?: string
+ /**
+ * Message ID for memoization (optional)
+ * When provided, memoizes parsed blocks to avoid re-parsing during streaming
+ */
+ id?: string
+ /**
+ * Callback when a URL is clicked
+ */
+ onUrlClick?: (url: string) => void
+ /**
+ * Callback when a file path is clicked
+ */
+ onFileClick?: (path: string) => void
+}
+
+// File path detection regex - matches paths starting with /, ~/, or ./
+const FILE_PATH_REGEX =
+ /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
+
+/**
+ * Create custom components based on render mode
+ */
+function createComponents(
+ mode: RenderMode,
+ onUrlClick?: (url: string) => void,
+ onFileClick?: (path: string) => void
+): Partial {
+ const baseComponents: Partial = {
+ // Links: Make clickable with callbacks
+ a: ({ href, children }) => {
+ const handleClick = (e: React.MouseEvent): void => {
+ e.preventDefault()
+ if (href) {
+ // Check if it's a file path
+ if (FILE_PATH_REGEX.test(href) && onFileClick) {
+ onFileClick(href)
+ } else if (onUrlClick) {
+ onUrlClick(href)
+ } else {
+ // Default: open in new window
+ window.open(href, '_blank', 'noopener,noreferrer')
+ }
+ }
+ }
+
+ return (
+
+ {children}
+
+ )
+ }
+ }
+
+ // Terminal mode: minimal formatting
+ if (mode === 'terminal') {
+ return {
+ ...baseComponents,
+ // No special code handling - just monospace
+ code: ({ children }) => {children},
+ pre: ({ children }) => {children},
+ // Minimal paragraph spacing
+ p: ({ children }) => {children}
,
+ // Simple lists
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ // Plain tables
+ table: ({ children }) => ,
+ th: ({ children }) => {children} | ,
+ td: ({ children }) => {children} |
+ }
+ }
+
+ // Minimal mode: clean with syntax highlighting
+ if (mode === 'minimal') {
+ return {
+ ...baseComponents,
+ // Inline code
+ code: ({ className, children, ...props }) => {
+ const match = /language-(\w+)/.exec(className || '')
+ const isBlock =
+ 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
+
+ // Block code - use CodeBlock with full mode
+ if (match || isBlock) {
+ const code = String(children).replace(/\n$/, '')
+ return
+ }
+
+ // Inline code
+ return {children}
+ },
+ pre: ({ children }) => <>{children}>,
+ // Comfortable paragraph spacing
+ p: ({ children }) => {children}
,
+ // Styled lists
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ // Clean tables
+ table: ({ children }) => (
+
+ ),
+ thead: ({ children }) => {children},
+ th: ({ children }) => (
+ {children} |
+ ),
+ td: ({ children }) => {children} | ,
+ // Headings - H1/H2 same size, differentiated by weight
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => (
+ {children}
+ ),
+ h3: ({ children }) => (
+ {children}
+ ),
+ // Blockquotes
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ // Horizontal rules
+ hr: () =>
,
+ // Strong/emphasis
+ strong: ({ children }) => {children},
+ em: ({ children }) => {children}
+ }
+ }
+
+ // Full mode: rich styling
+ return {
+ ...baseComponents,
+ // Full code blocks with copy button
+ code: ({ className, children, ...props }) => {
+ const match = /language-(\w+)/.exec(className || '')
+ const isBlock =
+ 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
+
+ if (match || isBlock) {
+ const code = String(children).replace(/\n$/, '')
+ return
+ }
+
+ return {children}
+ },
+ pre: ({ children }) => <>{children}>,
+ // Rich paragraph spacing
+ p: ({ children }) => {children}
,
+ // Styled lists
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ // Beautiful tables
+ table: ({ children }) => (
+
+ ),
+ thead: ({ children }) => {children},
+ tbody: ({ children }) => {children},
+ th: ({ children }) => {children} | ,
+ td: ({ children }) => {children} | ,
+ tr: ({ children }) => {children}
,
+ // Rich headings
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => (
+ {children}
+ ),
+ h3: ({ children }) => {children}
,
+ h4: ({ children }) => {children}
,
+ // Styled blockquotes
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ // Task lists (GFM)
+ input: ({ type, checked }) => {
+ if (type === 'checkbox') {
+ return (
+
+ )
+ }
+ return
+ },
+ // Horizontal rules
+ hr: () =>
,
+ // Strong/emphasis
+ strong: ({ children }) => {children},
+ em: ({ children }) => {children},
+ del: ({ children }) => {children}
+ }
+}
+
+/**
+ * Markdown - Customizable markdown renderer with multiple render modes
+ *
+ * Features:
+ * - Three render modes: terminal, minimal, full
+ * - Syntax highlighting via Shiki
+ * - GFM support (tables, task lists, strikethrough)
+ * - Clickable links and file paths
+ * - Memoization for streaming performance
+ */
+export function Markdown({
+ children,
+ mode = 'minimal',
+ className,
+ onUrlClick,
+ onFileClick
+}: MarkdownProps): React.JSX.Element {
+ const components = React.useMemo(
+ () => createComponents(mode, onUrlClick, onFileClick),
+ [mode, onUrlClick, onFileClick]
+ )
+
+ // Preprocess to convert raw URLs and file paths to markdown links
+ const processedContent = React.useMemo(() => preprocessLinks(children), [children])
+
+ return (
+
+
+ {processedContent}
+
+
+ )
+}
+
+/**
+ * MemoizedMarkdown - Optimized for streaming scenarios
+ *
+ * Splits content into blocks and memoizes each block separately,
+ * so only new/changed blocks re-render during streaming.
+ */
+export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
+ // If id is provided, use it for memoization
+ if (prevProps.id && nextProps.id) {
+ return (
+ prevProps.id === nextProps.id &&
+ prevProps.children === nextProps.children &&
+ prevProps.mode === nextProps.mode
+ )
+ }
+ // Otherwise compare content and mode
+ return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
+})
+MemoizedMarkdown.displayName = 'MemoizedMarkdown'
diff --git a/apps/web/components/markdown/StreamingMarkdown.tsx b/apps/web/components/markdown/StreamingMarkdown.tsx
new file mode 100644
index 00000000..aad2f330
--- /dev/null
+++ b/apps/web/components/markdown/StreamingMarkdown.tsx
@@ -0,0 +1,225 @@
+import * as React from 'react'
+import { Markdown, type RenderMode } from './Markdown'
+
+export interface StreamingMarkdownProps {
+ content: string
+ isStreaming: boolean
+ mode?: RenderMode
+ className?: string
+ onUrlClick?: (url: string) => void
+ onFileClick?: (path: string) => void
+}
+
+interface Block {
+ content: string
+ isCodeBlock: boolean
+}
+
+/**
+ * djb2 hash (XOR variant) by Daniel J. Bernstein.
+ * Used to generate stable React keys for completed content blocks.
+ *
+ * - 5381: empirically chosen initial value that produces fewer collisions
+ * - (hash << 5) + hash: equivalent to hash * 33
+ * - ^ charCode: XOR variant, favored by Bernstein over additive version
+ * - >>> 0: convert to unsigned 32-bit integer
+ *
+ * Not cryptographic — just fast with good distribution for short strings.
+ * @see http://www.cse.yorku.ca/~oz/hash.html
+ */
+function simpleHash(str: string): string {
+ let hash = 5381
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
+ }
+ return (hash >>> 0).toString(36)
+}
+
+/**
+ * Split content into blocks (paragraphs and code blocks)
+ *
+ * Block boundaries:
+ * - Double newlines (paragraph separators)
+ * - Code fences (```)
+ *
+ * This is intentionally simple - just string scanning, no regex per line.
+ */
+function splitIntoBlocks(content: string): Block[] {
+ const blocks: Block[] = []
+ const lines = content.split('\n')
+ let currentBlock = ''
+ let inCodeBlock = false
+ let inMathBlock = false
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i] ?? ''
+
+ // Check for code fence (``` at start of line, optionally followed by language)
+ if (line.startsWith('```')) {
+ if (!inCodeBlock) {
+ // Starting a code block - flush current paragraph first
+ if (currentBlock.trim()) {
+ blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
+ currentBlock = ''
+ }
+ inCodeBlock = true
+ currentBlock = line + '\n'
+ } else {
+ // Ending a code block
+ currentBlock += line
+ blocks.push({ content: currentBlock, isCodeBlock: true })
+ currentBlock = ''
+ inCodeBlock = false
+ }
+ } else if (inCodeBlock) {
+ // Inside code block - append line
+ currentBlock += line + '\n'
+ // Check for display math fence ($$)
+ } else if (line.trim() === '$$') {
+ if (!inMathBlock) {
+ // Starting a math block - flush current paragraph first
+ if (currentBlock.trim()) {
+ blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
+ currentBlock = ''
+ }
+ inMathBlock = true
+ currentBlock = line + '\n'
+ } else {
+ // Ending a math block
+ currentBlock += line
+ blocks.push({ content: currentBlock, isCodeBlock: false })
+ currentBlock = ''
+ inMathBlock = false
+ }
+ } else if (inMathBlock) {
+ // Inside math block - append line (don't split on blank lines)
+ currentBlock += line + '\n'
+ } else if (line === '') {
+ // Empty line outside code block = paragraph boundary
+ if (currentBlock.trim()) {
+ blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
+ currentBlock = ''
+ }
+ } else {
+ // Regular text line
+ if (currentBlock) {
+ currentBlock += '\n' + line
+ } else {
+ currentBlock = line
+ }
+ }
+ }
+
+ // Flush remaining content
+ if (currentBlock) {
+ blocks.push({
+ content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
+ isCodeBlock: inCodeBlock
+ })
+ }
+
+ return blocks
+}
+
+/**
+ * Memoized block component
+ *
+ * Only re-renders if content or mode changes.
+ * The key is assigned by the parent based on content hash,
+ * so identical content won't even attempt to render.
+ */
+const MemoizedBlock = React.memo(
+ function Block({
+ content,
+ mode,
+ className,
+ onUrlClick,
+ onFileClick
+ }: {
+ content: string
+ mode: RenderMode
+ className?: string
+ onUrlClick?: (url: string) => void
+ onFileClick?: (path: string) => void
+ }) {
+ return (
+
+ {content}
+
+ )
+ },
+ (prev, next) => {
+ // Only re-render if content actually changed
+ return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
+ }
+)
+MemoizedBlock.displayName = 'MemoizedBlock'
+
+/**
+ * StreamingMarkdown - Optimized markdown renderer for streaming content
+ *
+ * Splits content into blocks (paragraphs, code blocks) and memoizes each block
+ * independently. Only the last (active) block re-renders during streaming.
+ *
+ * Key insight: Completed blocks get a content-hash as their React key.
+ * Same content = same key = React skips re-render entirely.
+ *
+ * @example
+ * Content: "Hello\n\n```js\ncode\n```\n\nMore..."
+ *
+ * Block 1: "Hello" -> key="block-abc123" -> memoized
+ * Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
+ * Block 3: "More..." -> key="active-2" -> re-renders
+ */
+export function StreamingMarkdown({
+ content,
+ isStreaming,
+ mode = 'minimal',
+ className,
+ onUrlClick,
+ onFileClick
+}: StreamingMarkdownProps): React.JSX.Element {
+ // Split into blocks - memoized to avoid recomputation
+ // Must be called unconditionally to satisfy Rules of Hooks
+ const blocks = React.useMemo(
+ () => (isStreaming ? splitIntoBlocks(content) : []),
+ [content, isStreaming]
+ )
+
+ // Not streaming - use simple Markdown (no block splitting needed)
+ if (!isStreaming) {
+ return (
+
+ {content}
+
+ )
+ }
+
+ // Empty content - return null, let parent handle loading indicator
+ if (blocks.length === 0) {
+ return <>>
+ }
+
+ return (
+ <>
+ {blocks.map((block, i) => {
+ const isLastBlock = i === blocks.length - 1
+
+ // Complete blocks use content hash as key -> stable identity -> memoized
+ // Last block uses "active" prefix -> always re-renders on content change
+ const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}`
+
+ return (
+
+ )
+ })}
+ >
+ )
+}
diff --git a/apps/web/components/markdown/index.ts b/apps/web/components/markdown/index.ts
new file mode 100644
index 00000000..a2a89c50
--- /dev/null
+++ b/apps/web/components/markdown/index.ts
@@ -0,0 +1,4 @@
+export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown'
+export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
+export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
+export { preprocessLinks, detectLinks, hasLinks } from './linkify'
diff --git a/apps/web/components/markdown/linkify.ts b/apps/web/components/markdown/linkify.ts
new file mode 100644
index 00000000..f4ee1809
--- /dev/null
+++ b/apps/web/components/markdown/linkify.ts
@@ -0,0 +1,215 @@
+import LinkifyIt from 'linkify-it'
+
+/**
+ * Linkify - URL and file path detection for markdown preprocessing
+ *
+ * Uses linkify-it (12M downloads/week) for battle-tested URL detection,
+ * plus custom regex for local file paths.
+ */
+
+// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
+const linkify = new LinkifyIt()
+
+// File path regex - detects /path, ~/path, ./path with common extensions
+// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
+const FILE_PATH_REGEX =
+ /(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
+
+interface DetectedLink {
+ type: 'url' | 'email' | 'file'
+ text: string
+ url: string
+ start: number
+ end: number
+}
+
+interface CodeRange {
+ start: number
+ end: number
+}
+
+/**
+ * Find all code block and inline code ranges in text
+ * These ranges should be excluded from link detection
+ */
+function findCodeRanges(text: string): CodeRange[] {
+ const ranges: CodeRange[] = []
+
+ // Find fenced code blocks (```...```)
+ const fencedRegex = /```[\s\S]*?```/g
+ let match
+ while ((match = fencedRegex.exec(text)) !== null) {
+ ranges.push({ start: match.index, end: match.index + match[0].length })
+ }
+
+ // Find display math blocks ($$...$$)
+ const displayMathRegex = /\$\$[\s\S]*?\$\$/g
+ while ((match = displayMathRegex.exec(text)) !== null) {
+ const pos = match.index
+ const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
+ if (!insideOther) {
+ ranges.push({ start: pos, end: pos + match[0].length })
+ }
+ }
+
+ // Find inline math ($...$)
+ const inlineMathRegex = /(? pos >= r.start && pos < r.end)
+ if (!insideOther) {
+ ranges.push({ start: pos, end: pos + match[0].length })
+ }
+ }
+
+ // Find inline code (`...`)
+ // But skip escaped backticks and code inside fenced blocks
+ const inlineRegex = /(? pos >= r.start && pos < r.end)
+ if (!insideOther) {
+ ranges.push({ start: pos, end: pos + match[0].length })
+ }
+ }
+
+ return ranges
+}
+
+/**
+ * Check if a position is inside any code range
+ */
+function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
+ return ranges.some((r) => pos >= r.start && pos < r.end)
+}
+
+/**
+ * Check if a link at given position is already a markdown link
+ * Looks for patterns like [text](url) or [text][ref]
+ */
+function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
+ // Check if preceded by ]( which indicates we're inside a markdown link href
+ // Pattern: [text](URL) - we're checking if URL is our link
+ const before = text.slice(Math.max(0, linkStart - 2), linkStart)
+ if (before.endsWith('](')) return true
+
+ // Check if preceded by ][ for reference links
+ if (before.endsWith('][')) return true
+
+ // Check if the link text is wrapped in []
+ // Pattern: [URL](href) - URL is being used as link text
+ const charBefore = text[linkStart - 1]
+ const charAfter = text[linkEnd]
+ if (charBefore === '[' && charAfter === ']') return true
+
+ return false
+}
+
+/**
+ * Check if ranges overlap
+ */
+function rangesOverlap(
+ a: { start: number; end: number },
+ b: { start: number; end: number }
+): boolean {
+ return a.start < b.end && b.start < a.end
+}
+
+/**
+ * Detect all links (URLs, emails, file paths) in text
+ */
+export function detectLinks(text: string): DetectedLink[] {
+ const links: DetectedLink[] = []
+
+ // 1. Detect URLs and emails with linkify-it
+ const urlMatches = linkify.match(text) || []
+ for (const match of urlMatches) {
+ links.push({
+ type: match.schema === 'mailto:' ? 'email' : 'url',
+ text: match.text,
+ url: match.url,
+ start: match.index,
+ end: match.lastIndex
+ })
+ }
+
+ // 2. Detect file paths with custom regex
+ // Reset regex state
+ FILE_PATH_REGEX.lastIndex = 0
+ let fileMatch
+ while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
+ const path = fileMatch[1]
+ if (!path) continue // Skip if no capture group
+
+ // Calculate actual start position (after any leading whitespace/punctuation)
+ const fullMatch = fileMatch[0]
+ const pathOffset = fullMatch.indexOf(path)
+ const start = fileMatch.index + pathOffset
+
+ // Check for overlaps with URL matches (URLs take precedence)
+ const pathRange = { start, end: start + path.length }
+ const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
+ if (overlapsUrl) continue
+
+ links.push({
+ type: 'file',
+ text: path,
+ url: path, // File paths are passed as-is to onFileClick handler
+ start,
+ end: start + path.length
+ })
+ }
+
+ // Sort by position
+ return links.sort((a, b) => a.start - b.start)
+}
+
+/**
+ * Preprocess text to convert raw URLs and file paths into markdown links
+ * Skips code blocks and already-linked content
+ */
+export function preprocessLinks(text: string): string {
+ // Quick check - if no potential links, return early
+ if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
+ return text
+ }
+
+ const codeRanges = findCodeRanges(text)
+ const links = detectLinks(text)
+
+ if (links.length === 0) return text
+
+ // Build result, converting raw links to markdown links
+ let result = ''
+ let lastIndex = 0
+
+ for (const link of links) {
+ // Skip if inside code block
+ if (isInsideCode(link.start, codeRanges)) continue
+
+ // Skip if already a markdown link
+ if (isAlreadyLinked(text, link.start, link.end)) continue
+
+ // Add text before this link
+ result += text.slice(lastIndex, link.start)
+
+ // Convert to markdown link
+ result += `[${link.text}](${link.url})`
+
+ lastIndex = link.end
+ }
+
+ // Add remaining text
+ result += text.slice(lastIndex)
+
+ return result
+}
+
+/**
+ * Test if text contains any detectable links
+ * Useful for optimization - skip preprocessing if no links present
+ */
+export function hasLinks(text: string): boolean {
+ return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
+}
diff --git a/apps/web/components/multica-icon.tsx b/apps/web/components/multica-icon.tsx
new file mode 100644
index 00000000..45d0e5b5
--- /dev/null
+++ b/apps/web/components/multica-icon.tsx
@@ -0,0 +1,105 @@
+import { useState, useEffect } from "react";
+import { cn } from "@/lib/utils";
+
+interface MulticaIconProps extends React.ComponentProps<"span"> {
+ /**
+ * If true, play a one-time entrance spin animation.
+ */
+ animate?: boolean;
+ /**
+ * If true, disable hover spin animation.
+ */
+ noSpin?: boolean;
+ /**
+ * If true, show a border around the icon.
+ */
+ bordered?: boolean;
+ /**
+ * Size of the bordered icon: "sm" (default), "md", "lg"
+ */
+ size?: "sm" | "md" | "lg";
+}
+
+const borderedSizes = {
+ sm: { wrapper: "p-1.5", icon: "size-3.5" },
+ md: { wrapper: "p-2", icon: "size-4" },
+ lg: { wrapper: "p-2.5", icon: "size-5" },
+};
+
+/**
+ * Pure CSS 8-pointed asterisk icon matching the Multica logo.
+ * Uses currentColor so it adapts to light/dark themes automatically.
+ * Clip-path polygon traced from the original SVG path coordinates.
+ */
+export function MulticaIcon({
+ className,
+ animate = false,
+ noSpin = false,
+ bordered = false,
+ size = "sm",
+ ...props
+}: MulticaIconProps) {
+ const [entranceDone, setEntranceDone] = useState(!animate);
+
+ useEffect(() => {
+ if (!animate) return;
+ const timer = setTimeout(() => setEntranceDone(true), 600);
+ return () => clearTimeout(timer);
+ }, [animate]);
+
+ const clipPath = `polygon(
+ 45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
+ 81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
+ 100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
+ 55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
+ 18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
+ 0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
+ )`;
+
+ if (bordered) {
+ const sizeConfig = borderedSizes[size];
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/spinner.tsx b/apps/web/components/spinner.tsx
new file mode 100644
index 00000000..91818dfa
--- /dev/null
+++ b/apps/web/components/spinner.tsx
@@ -0,0 +1,47 @@
+/**
+ * Spinner — 3x3 grid pulse for **active processing / execution** states.
+ *
+ * Use when the system is actively doing work or waiting for human action
+ * (streaming content, generating responses, awaiting approval).
+ * For passive content-loading states, use `` instead.
+ *
+ * Inherits color from `currentColor` (use Tailwind `text-*`).
+ * Scales with font-size (use Tailwind `text-*` for size).
+ */
+import { cn } from "@/lib/utils"
+
+export interface SpinnerProps {
+ /** Additional className for styling (color via text-*, size via Tailwind text-*) */
+ className?: string
+}
+
+const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
+
+const cubeStyle: React.CSSProperties = {
+ backgroundColor: "currentColor",
+ animation: "spinner-grid 1.3s infinite ease-in-out",
+ transform: "scale3d(0.5, 0.5, 1)",
+}
+
+export function Spinner({ className }: SpinnerProps) {
+ return (
+
+ {DELAYS.map((delay, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx
new file mode 100644
index 00000000..3d44f481
--- /dev/null
+++ b/apps/web/components/theme-provider.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
+
+function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+function isTypingTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) {
+ return false
+ }
+
+ return (
+ target.isContentEditable ||
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.tagName === "SELECT"
+ )
+}
+
+function ThemeHotkey() {
+ const { resolvedTheme, setTheme } = useTheme()
+
+ React.useEffect(() => {
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.defaultPrevented || event.repeat) {
+ return
+ }
+
+ if (event.metaKey || event.ctrlKey || event.altKey) {
+ return
+ }
+
+ if (event.key.toLowerCase() !== "d") {
+ return
+ }
+
+ if (isTypingTarget(event.target)) {
+ return
+ }
+
+ setTheme(resolvedTheme === "dark" ? "light" : "dark")
+ }
+
+ window.addEventListener("keydown", onKeyDown)
+
+ return () => {
+ window.removeEventListener("keydown", onKeyDown)
+ }
+ }, [resolvedTheme, setTheme])
+
+ return null
+}
+
+export { ThemeProvider }
diff --git a/apps/web/components/theme-toggle.tsx b/apps/web/components/theme-toggle.tsx
new file mode 100644
index 00000000..69227c83
--- /dev/null
+++ b/apps/web/components/theme-toggle.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Sun, Moon, Monitor } from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "@/components/ui/dropdown-menu"
+import { SidebarMenuButton } from "@/components/ui/sidebar"
+
+export function ThemeToggle() {
+ const { setTheme } = useTheme()
+
+ return (
+
+
+
+
+ Theme
+
+ }
+ />
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ )
+}
diff --git a/apps/web/components/ui/accordion.tsx b/apps/web/components/ui/accordion.tsx
new file mode 100644
index 00000000..6142db91
--- /dev/null
+++ b/apps/web/components/ui/accordion.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Trigger.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Panel.Props) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..0ee2c5f4
--- /dev/null
+++ b/apps/web/components/ui/alert-dialog.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx
new file mode 100644
index 00000000..2a176c75
--- /dev/null
+++ b/apps/web/components/ui/alert.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
diff --git a/apps/web/components/ui/aspect-ratio.tsx b/apps/web/components/ui/aspect-ratio.tsx
new file mode 100644
index 00000000..4c0ebaa5
--- /dev/null
+++ b/apps/web/components/ui/aspect-ratio.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@/lib/utils"
+
+function AspectRatio({
+ ratio,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & { ratio: number }) {
+ return (
+
+ )
+}
+
+export { AspectRatio }
diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx
new file mode 100644
index 00000000..e4fed865
--- /dev/null
+++ b/apps/web/components/ui/avatar.tsx
@@ -0,0 +1,109 @@
+"use client"
+
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx
new file mode 100644
index 00000000..b20959dd
--- /dev/null
+++ b/apps/web/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps
) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/apps/web/components/ui/breadcrumb.tsx b/apps/web/components/ui/breadcrumb.tsx
new file mode 100644
index 00000000..3d85c18d
--- /dev/null
+++ b/apps/web/components/ui/breadcrumb.tsx
@@ -0,0 +1,125 @@
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
+
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ )
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"a">) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn("transition-colors hover:text-foreground", className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "breadcrumb-link",
+ },
+ })
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? (
+
+ )}
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ svg]:size-4",
+ className
+ )}
+ {...props}
+ >
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/apps/web/components/ui/button-group.tsx b/apps/web/components/ui/button-group.tsx
new file mode 100644
index 00000000..3c97c265
--- /dev/null
+++ b/apps/web/components/ui/button-group.tsx
@@ -0,0 +1,87 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+ vertical:
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "button-group-text",
+ },
+ })
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx
new file mode 100644
index 00000000..ded01b25
--- /dev/null
+++ b/apps/web/components/ui/button.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/apps/web/components/ui/calendar.tsx b/apps/web/components/ui/calendar.tsx
new file mode 100644
index 00000000..1d3044aa
--- /dev/null
+++ b/apps/web/components/ui/calendar.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import * as React from "react"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale,
+} from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative rounded-(--cell-radius)",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute inset-0 bg-popover opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "font-medium select-none",
+ captionLayout === "label"
+ ? "text-sm"
+ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] text-muted-foreground select-none",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end
+ ),
+ today: cn(
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: ({ ...props }) => (
+
+ ),
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+