diff --git a/.cursor/rules/convex-id-types.mdc b/.cursor/rules/convex-id-types.mdc new file mode 100644 index 0000000..ad1b2c8 --- /dev/null +++ b/.cursor/rules/convex-id-types.mdc @@ -0,0 +1,68 @@ +--- +description: Use Convex branded Id types instead of plain strings for document IDs. Applies when working with Convex state, props, or mutations. +globs: apps/web/src/**/*.tsx,apps/web/src/**/*.ts,packages/convex-backend/convex/**/*.ts +alwaysApply: false +--- + +# Convex Branded ID Types + +## Rule + +NEVER use `string` for Convex document IDs. NEVER use `as never` or `as Id<"...">` casts to silence type mismatches. Instead, use the generated `Id<"tablename">` type from `@harness/convex-backend/convex/_generated/dataModel` throughout the entire type chain — state, props, callbacks, and function parameters. + +## Why + +Convex generates branded types like `Id<"harnesses">` that are distinct from `string` at the type level. This prevents accidentally passing an `Id<"harnesses">` where an `Id<"conversations">` is expected. Using `as never` completely defeats this safety. + +## Import + +```tsx +import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; +``` + +## Pattern: State + +```tsx +// GOOD — proper branded type +const [activeId, setActiveId] = useState | null>(null); + +// BAD — plain string loses type safety +const [activeId, setActiveId] = useState(null); +``` + +## Pattern: Component Props + +```tsx +// GOOD — types flow from Convex queries to mutations without casts +function HarnessCard({ + harness, + onDelete, +}: { + harness: { _id: Id<"harnesses">; name: string }; + onDelete: (id: Id<"harnesses">) => void; +}) { + return ; +} +``` + +## Pattern: Mutations + +```tsx +// GOOD — types match, no casts needed +const removeHarness = useMutation({ + mutationFn: useConvexMutation(api.harnesses.remove), +}); +removeHarness.mutate({ id: harness._id }); // Id<"harnesses"> flows naturally + +// BAD — as never bypasses ALL type checking +removeHarness.mutate({ id: someString as never }); +``` + +## How It Works + +1. Convex query results already return `_id` as `Id<"tablename">` +2. Store these in state with `useState | null>` +3. Pass through props with `Id<"tablename">` (not `string`) +4. When the value reaches a mutation call, types match — zero casts needed + +The key is to never widen `Id<"tablename">` to `string` at any point in the chain. diff --git a/.cursor/rules/tanstack-auth-guards.mdc b/.cursor/rules/tanstack-auth-guards.mdc new file mode 100644 index 0000000..1e4d99b --- /dev/null +++ b/.cursor/rules/tanstack-auth-guards.mdc @@ -0,0 +1,95 @@ +--- +description: Use TanStack Router beforeLoad guards for authentication instead of navigating during render. Applies when creating or modifying protected routes in this project. +globs: apps/web/src/routes/**/*.tsx +alwaysApply: false +--- + +# TanStack Router + Clerk Auth Guards + +## Rule + +NEVER call `navigate()` during the render phase for authentication redirects. This is a React anti-pattern that causes unpredictable behavior, especially with SSR (TanStack Start). + +Instead, use TanStack Router's `beforeLoad` option to check authentication and `throw redirect()` before the component ever mounts. + +## How it works in this project + +The root route (`__root.tsx`) already fetches Clerk auth state server-side and injects `userId` into the route context: + +```tsx +// __root.tsx — beforeLoad returns { userId, token } into context +beforeLoad: async (ctx) => { + const { userId, token } = await fetchClerkAuth(); + if (token) { + ctx.context.convexQueryClient.serverHttpClient?.setAuth(token); + } + return { userId, token }; +}, +``` + +Child routes access `context.userId` in their own `beforeLoad`. + +## Pattern: Protected Route + +```tsx +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/my-protected-route")({ + beforeLoad: ({ context }) => { + if (!context.userId) { + throw redirect({ to: "/sign-in" }); + } + }, + component: MyProtectedPage, +}); + +function MyProtectedPage() { + // No need for useUser() auth checks here — the user is guaranteed + // to be authenticated by the time this component renders. +} +``` + +## Anti-pattern (DO NOT use) + +```tsx +// BAD — navigating during render +function MyPage() { + const { user } = useUser(); + const navigate = useNavigate(); + + if (!user) { + navigate({ to: "/sign-in" }); // Side effect during render! + return null; + } +} +``` + +## Data-dependent redirects + +For redirects that depend on client-side data (e.g., Convex queries), use `useEffect` — not a bare call during render: + +```tsx +function ChatPage() { + const navigate = useNavigate(); + const { data: harnesses, isLoading } = useQuery(convexQuery(api.harnesses.list, {})); + + useEffect(() => { + if (harnesses && harnesses.length === 0) { + navigate({ to: "/onboarding" }); + } + }, [harnesses, navigate]); + + if (isLoading || !harnesses || harnesses.length === 0) { + return ; + } + + // ... rest of component +} +``` + +## Key points + +- `beforeLoad` runs top-down through the route tree, before any child `beforeLoad` or component. +- `throw redirect()` accepts the same options as `navigate()` (e.g., `replace: true`, `search: {}`). +- If `beforeLoad` throws, child routes will not load at all. +- Do NOT import `useUser` from Clerk just for auth checks in route components — use `context.userId` in `beforeLoad` instead. diff --git a/apps/web/.env.example b/apps/web/.env.example index 5806535..4556958 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -6,3 +6,6 @@ CLERK_SECRET_KEY=sk_test_ # Convex configuration, get this URL from your [Dashboard](dashboard.convex.dev) VITE_CONVEX_URL=https://your-project.convex.cloud + +# FastAPI backend URL +VITE_FASTAPI_URL=http://localhost:8000 diff --git a/apps/web/biome.json b/apps/web/biome.json index 695f635..ebaa37d 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -24,7 +24,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "useUniqueElementIds": "off" + } } }, "javascript": { diff --git a/apps/web/package.json b/apps/web/package.json index 5b011c6..49d7c75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,7 +15,9 @@ "dependencies": { "@clerk/tanstack-react-start": "^0.29.1", "@convex-dev/react-query": "^0.1.0", + "@harness/convex-backend": "workspace:*", "@t3-oss/env-core": "^0.13.8", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "^0.7.0", "@tanstack/react-query": "^5.90.21", @@ -27,15 +29,21 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.31.7", + "geist": "^1.7.0", + "highlight.js": "^11.11.1", "lucide-react": "^0.561.0", - "nitro": "npm:nitro-nightly@latest", + "motion": "^12.34.3", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hot-toast": "^2.6.0", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.3.6", + "use-sync-external-store": "^1.6.0", "vite-tsconfig-paths": "^6.0.2", "zod": "^4.1.11" }, diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..d2da290 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/fonts/Geist-Variable.woff2 b/apps/web/public/fonts/Geist-Variable.woff2 new file mode 100644 index 0000000..b2f0121 Binary files /dev/null and b/apps/web/public/fonts/Geist-Variable.woff2 differ diff --git a/apps/web/public/fonts/GeistMono-Variable.woff2 b/apps/web/public/fonts/GeistMono-Variable.woff2 new file mode 100644 index 0000000..dbdb8c2 Binary files /dev/null and b/apps/web/public/fonts/GeistMono-Variable.woff2 differ diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx deleted file mode 100644 index fcfbbe9..0000000 --- a/apps/web/src/components/Header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { Globe, Home, Menu, X } from "lucide-react"; - -import { useState } from "react"; -import ClerkHeader from "../integrations/clerk/header-user.tsx"; - -export default function Header() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> -
- -

Harness

-
- - - - ); -} diff --git a/apps/web/src/components/harness-mark.tsx b/apps/web/src/components/harness-mark.tsx new file mode 100644 index 0000000..8864262 --- /dev/null +++ b/apps/web/src/components/harness-mark.tsx @@ -0,0 +1,26 @@ +export function HarnessMark({ + size = 24, + className, +}: { + size?: number; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/markdown-message.tsx b/apps/web/src/components/markdown-message.tsx new file mode 100644 index 0000000..3c3ff03 --- /dev/null +++ b/apps/web/src/components/markdown-message.tsx @@ -0,0 +1,144 @@ +import { Check, Copy } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; +import remarkGfm from "remark-gfm"; +import { cn } from "../lib/utils"; + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef>(undefined); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text); + setCopied(true); + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +} + +const components: Components = { + pre({ children, ...props }) { + return ( +
+				{children}
+			
+ ); + }, + + code({ className, children, ...props }) { + const match = /language-(\w+)/.exec(className || ""); + const isBlock = Boolean(match); + const codeText = String(children).replace(/\n$/, ""); + + if (isBlock) { + return ( +
+
+ + {match?.[1]} + + +
+
+ + {children} + +
+
+ ); + } + + return ( + + {children} + + ); + }, + + a({ href, children, ...props }) { + return ( + + {children} + + ); + }, + + table({ children, ...props }) { + return ( +
+ + {children} +
+
+ ); + }, + + th({ children, ...props }) { + return ( + + {children} + + ); + }, + + td({ children, ...props }) { + return ( + + {children} + + ); + }, +}; + +const remarkPlugins = [remarkGfm]; +const rehypePlugins = [rehypeHighlight]; + +export function MarkdownMessage({ content }: { content: string }) { + return ( +
+ + {content} + +
+ ); +} diff --git a/apps/web/src/components/mcp-oauth-connect-row.tsx b/apps/web/src/components/mcp-oauth-connect-row.tsx new file mode 100644 index 0000000..8ff179c --- /dev/null +++ b/apps/web/src/components/mcp-oauth-connect-row.tsx @@ -0,0 +1,121 @@ +import { useAuth } from "@clerk/tanstack-react-start"; +import { Loader2, Server, Shield } from "lucide-react"; +import { motion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { env } from "../env"; +import type { McpServerEntry } from "../lib/mcp"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +const API_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; + +export function OAuthConnectRow({ + server, + isConnected, +}: { + server: McpServerEntry; + isConnected: boolean; +}) { + const { getToken } = useAuth(); + const [connecting, setConnecting] = useState(false); + + // Refs so the cleanup effect always sees the latest handler/interval. + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + cleanupRef.current?.(); + }; + }, []); + + const handleConnect = useCallback(async () => { + setConnecting(true); + try { + const token = await getToken(); + const res = await fetch( + `${API_URL}/api/mcp/oauth/start?server_url=${encodeURIComponent(server.url)}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + if (!res.ok) throw new Error("Failed to start OAuth"); + const data = await res.json(); + + const popup = window.open( + data.authorization_url, + "mcp-oauth", + "width=600,height=700", + ); + + const handler = (event: MessageEvent) => { + if (event.data?.type === "mcp-oauth-callback") { + cleanup(); + if (event.data.success) { + toast.success(`Connected to ${server.name}`); + } else { + toast.error(event.data.error || "OAuth connection failed"); + } + setConnecting(false); + popup?.close(); + } + }; + + const interval = setInterval(() => { + if (popup?.closed) { + cleanup(); + setConnecting(false); + } + }, 500); + + const cleanup = () => { + clearInterval(interval); + window.removeEventListener("message", handler); + cleanupRef.current = null; + }; + cleanupRef.current = cleanup; + + window.addEventListener("message", handler); + } catch { + toast.error("Failed to start OAuth flow"); + setConnecting(false); + } + }, [getToken, server.url, server.name]); + + return ( + + +
+

{server.name}

+

+ {server.url} +

+
+ {isConnected ? ( + +
+ Connected + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/mcp-server-status.tsx b/apps/web/src/components/mcp-server-status.tsx new file mode 100644 index 0000000..f913b82 --- /dev/null +++ b/apps/web/src/components/mcp-server-status.tsx @@ -0,0 +1,406 @@ +import { useAuth } from "@clerk/tanstack-react-start"; +import { convexQuery } from "@convex-dev/react-query"; +import { api } from "@harness/convex-backend/convex/_generated/api"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, Loader2, Server, Shield } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { env } from "../env"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +const API_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; + +/** + * Start an OAuth popup flow for an MCP server. + * Returns a cleanup function. Calls onSuccess/onError when done. + */ +function startOAuthPopup( + getToken: () => Promise, + serverUrl: string, + opts: { + onSuccess?: () => void; + onError?: (msg: string) => void; + onDone?: () => void; + }, +) { + let cancelled = false; + let intervalId: ReturnType | undefined; + + const run = async () => { + try { + const token = await getToken(); + if (cancelled) return; + const res = await fetch( + `${API_URL}/api/mcp/oauth/start?server_url=${encodeURIComponent(serverUrl)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error("Failed to start OAuth"); + const data = await res.json(); + + const popup = window.open( + data.authorization_url, + "mcp-oauth", + "width=600,height=700", + ); + + const handler = (event: MessageEvent) => { + if (event.data?.type === "mcp-oauth-callback") { + window.removeEventListener("message", handler); + if (event.data.success) { + opts.onSuccess?.(); + } else { + opts.onError?.(event.data.error || "OAuth connection failed"); + } + opts.onDone?.(); + popup?.close(); + } + }; + window.addEventListener("message", handler); + + intervalId = setInterval(() => { + if (popup?.closed) { + clearInterval(intervalId); + window.removeEventListener("message", handler); + opts.onDone?.(); + } + }, 500); + } catch { + opts.onError?.("Failed to start OAuth flow"); + opts.onDone?.(); + } + }; + + run(); + + return () => { + cancelled = true; + if (intervalId) clearInterval(intervalId); + }; +} + +type McpServer = { + name: string; + url: string; + authType: "none" | "bearer" | "oauth"; + authToken?: string; +}; + +export type HealthStatus = + | "checking" + | "reachable" + | "unreachable" + | "auth_required"; + +type ServerStatus = "connected" | "expired" | "disconnected" | "checking"; + +function getServerStatus( + server: McpServer, + oauthStatuses: Array<{ + mcpServerUrl: string; + connected: boolean; + expiresAt: number; + scopes: string; + }>, + healthStatus?: HealthStatus, +): ServerStatus { + // If health check is running, show checking state + if (healthStatus === "checking") return "checking"; + + // For OAuth servers: combine token status with health check + if (server.authType === "oauth") { + const tokenStatus = oauthStatuses.find( + (s) => s.mcpServerUrl === server.url, + ); + if (!tokenStatus || !tokenStatus.connected) return "disconnected"; + if (tokenStatus.expiresAt < Date.now() / 1000 + 60) return "expired"; + // Token valid — also check health if available + if (healthStatus === "unreachable") return "disconnected"; + if (healthStatus === "auth_required") return "expired"; + return "connected"; + } + + // For non-OAuth servers: use health check result + if (healthStatus === "unreachable") return "disconnected"; + if (healthStatus === "auth_required") return "disconnected"; + if (healthStatus === "reachable") return "connected"; + // No health data yet → checking + return "checking"; +} + +const STATUS_DOT: Record = { + connected: "bg-emerald-500", + expired: "bg-amber-400", + disconnected: "bg-red-400", + checking: "bg-muted-foreground/40", +}; + +const STATUS_LABEL: Record = { + connected: "Connected", + expired: "Token expired", + disconnected: "Unreachable", + checking: "Checking…", +}; + +export function McpServerStatus({ + servers, + healthStatuses = {}, +}: { + servers: McpServer[]; + healthStatuses?: Record; +}) { + const { data: oauthStatuses } = useQuery( + convexQuery(api.mcpOAuthTokens.listStatuses, {}), + ); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + if (servers.length === 0) return null; + + const statuses = servers.map((s) => ({ + server: s, + status: oauthStatuses + ? getServerStatus(s, oauthStatuses, healthStatuses[s.url]) + : ("checking" as ServerStatus), + })); + + const allConnected = statuses.every((s) => s.status === "connected"); + const hasIssue = statuses.some( + (s) => s.status === "expired" || s.status === "disconnected", + ); + + const anyChecking = statuses.some((s) => s.status === "checking"); + + const summaryColor = anyChecking + ? "bg-muted-foreground/40" + : allConnected + ? "bg-emerald-500" + : hasIssue + ? "bg-amber-400" + : "bg-muted-foreground/40"; + + return ( +
+ + + + + + {anyChecking ? "Checking MCP servers..." : "MCP server status"} + + + + + {open && ( + +
+ + MCP Servers + +
+
+ {statuses.map(({ server, status }) => ( + {}} + /> + ))} +
+
+ )} +
+
+ ); +} + +function McpServerRow({ + server, + status, + onReconnected, +}: { + server: McpServer; + status: ServerStatus; + onReconnected: () => void; +}) { + const { getToken } = useAuth(); + const [connecting, setConnecting] = useState(false); + + const handleReconnect = useCallback(() => { + setConnecting(true); + startOAuthPopup(getToken, server.url, { + onSuccess: () => { + toast.success(`Reconnected to ${server.name}`); + onReconnected(); + }, + onError: (msg) => toast.error(msg), + onDone: () => setConnecting(false), + }); + }, [getToken, server.url, server.name, onReconnected]); + + const needsReconnect = + server.authType === "oauth" && + (status === "expired" || status === "disconnected"); + + return ( +
+ {status === "checking" ? ( + + ) : ( +
+ )} +
+
{server.name}
+
+ {STATUS_LABEL[status]} +
+
+ {needsReconnect && ( + + )} + {server.authType !== "oauth" && status === "connected" && ( + + {server.authType === "bearer" ? "Key" : "Open"} + + )} + {server.authType === "oauth" && status === "connected" && ( + +
+ OAuth + + )} +
+ ); +} + +/** + * Parse a tool result string to check if it's an auth_required error. + * Returns { serverUrl, error } if so, null otherwise. + */ +export function parseAuthRequiredError( + result: string, +): { serverUrl: string; error: string } | null { + try { + const parsed = JSON.parse(result); + if (parsed?.auth_required === true && parsed?.server_url) { + return { serverUrl: parsed.server_url, error: parsed.error ?? "" }; + } + } catch { + // Not JSON or not the right shape + } + return null; +} + +/** + * Inline prompt shown inside a tool call result when OAuth re-auth is needed. + */ +export function OAuthReconnectPrompt({ + serverUrl, + errorMessage, +}: { + serverUrl: string; + errorMessage: string; +}) { + const { getToken } = useAuth(); + const [connecting, setConnecting] = useState(false); + const [reconnected, setReconnected] = useState(false); + + const handleReconnect = useCallback(() => { + setConnecting(true); + startOAuthPopup(getToken, serverUrl, { + onSuccess: () => { + toast.success("Reconnected — you can retry the message"); + setReconnected(true); + }, + onError: (msg) => toast.error(msg), + onDone: () => setConnecting(false), + }); + }, [getToken, serverUrl]); + + if (reconnected) { + return ( +
+ + Reconnected. Retry your message to use this tool. +
+ ); + } + + return ( +
+ + + {errorMessage || "OAuth authorization required for this MCP server."} + + +
+ ); +} diff --git a/apps/web/src/components/message-actions.tsx b/apps/web/src/components/message-actions.tsx new file mode 100644 index 0000000..0e38f1c --- /dev/null +++ b/apps/web/src/components/message-actions.tsx @@ -0,0 +1,148 @@ +import { Check, Copy, GitFork, Pencil, RefreshCw } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import type { UsageData } from "../lib/use-chat-stream"; + +export type DisplayMode = "zen" | "standard" | "developer"; + +interface MessageActionsProps { + content: string; + role: "user" | "assistant"; + displayMode: DisplayMode; + onRegenerate?: () => void; + onFork?: () => void; + onEditPrompt?: () => void; + isStreaming?: boolean; + usage?: UsageData; + model?: string; +} + +export function MessageActions({ + content, + role, + displayMode, + onRegenerate, + onFork, + onEditPrompt, + isStreaming, + usage, + model, +}: MessageActionsProps) { + if (displayMode === "zen" || isStreaming) return null; + + const showCopy = displayMode === "standard" || displayMode === "developer"; + const showEditPrompt = + (displayMode === "standard" || displayMode === "developer") && + role === "user" && + onEditPrompt; + const showFork = + (displayMode === "standard" || displayMode === "developer") && + role === "assistant" && + onFork; + const showRegenerate = + displayMode === "developer" && role === "assistant" && onRegenerate; + const showInfo = + displayMode === "developer" && role === "assistant" && (usage || model); + + return ( +
+ {showCopy && } + {showEditPrompt && } + {showFork && } + {showRegenerate && } + {showInfo && } +
+ ); +} + +function CopyMessageButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef>(undefined); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(text); + setCopied(true); + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +} + +function EditPromptButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function ForkButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function RegenerateButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function UsageInfo({ usage, model }: { usage?: UsageData; model?: string }) { + const parts: string[] = []; + + if (model) { + parts.push(model); + } + + if (usage) { + parts.push(`${usage.promptTokens} in / ${usage.completionTokens} out`); + + if (usage.cost != null) { + parts.push(`$${usage.cost.toFixed(4)}`); + } + } + + return ( + + {parts.join(" · ")} + + ); +} diff --git a/apps/web/src/components/preset-mcp-grid.tsx b/apps/web/src/components/preset-mcp-grid.tsx new file mode 100644 index 0000000..ec9b89e --- /dev/null +++ b/apps/web/src/components/preset-mcp-grid.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { PRESET_MCPS } from "../lib/mcp"; +import { Checkbox } from "./ui/checkbox"; + +function McpLogo({ iconName, name }: { iconName: string; name: string }) { + const [failed, setFailed] = useState(false); + + if (!iconName || failed) { + return ( + + {name[0]} + + ); + } + + const isFavicon = iconName.startsWith("http"); + const src = isFavicon ? iconName : `https://cdn.simpleicons.org/${iconName}`; + + return ( + {name} setFailed(true)} + /> + ); +} + +interface PresetMcpGridProps { + selected: string[]; + onToggle: (id: string) => void; +} + +export function PresetMcpGrid({ selected, onToggle }: PresetMcpGridProps) { + return ( +
+ {PRESET_MCPS.map((mcp) => { + const isSelected = selected.includes(mcp.id); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..8808c0a --- /dev/null +++ b/apps/web/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + 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, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +}; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..20b20ad --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Slot } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden select-none", + { + 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 [a&]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 8f484d6..5ab9383 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,30 +5,29 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center whitespace-nowrap border border-transparent bg-clip-padding text-xs font-medium outline-none transition-all select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 focus-visible:ring-1 gap-2", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 hover:bg-destructive/20 text-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 focus-visible:border-destructive/40 dark:hover:bg-destructive/30", link: "text-primary underline-offset-4 hover:underline", }, size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", + default: "h-8 gap-1.5 px-2.5 has-[>svg]:px-2", + xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 px-2.5 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-3 has-[>svg]:px-3", + icon: "size-8", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-7", + "icon-lg": "size-9", }, }, defaultVariants: { diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..e31992a --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..97c3b99 --- /dev/null +++ b/apps/web/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { CheckIcon } from "lucide-react"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..4251e4e --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,155 @@ +import { XIcon } from "lucide-react"; +import { Dialog as DialogPrimitive } from "radix-ui"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a0143c3 --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..1ff9fd7 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..5709820 --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx new file mode 100644 index 0000000..5ec9b66 --- /dev/null +++ b/apps/web/src/components/ui/select.tsx @@ -0,0 +1,188 @@ +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { Select as SelectPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Select({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..6990ead --- /dev/null +++ b/apps/web/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import { Separator as SeparatorPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx new file mode 100644 index 0000000..4d2301b --- /dev/null +++ b/apps/web/src/components/ui/sheet.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { XIcon } from "lucide-react"; +import { Dialog as SheetPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..eded6ce --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx new file mode 100644 index 0000000..70e5b64 --- /dev/null +++ b/apps/web/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +