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/.github/workflows/backend-cd.yml b/.github/workflows/backend-cd.yml new file mode 100644 index 0000000..9a607fc --- /dev/null +++ b/.github/workflows/backend-cd.yml @@ -0,0 +1,58 @@ +name: Deploy Backend + +on: + push: + branches: [staging, main] + paths: + - "packages/fastapi/**" + - ".github/workflows/backend-cd.yml" + +concurrency: + group: backend-deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Determine environment + id: env + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "deploy_path=/opt/harness-api" >> "$GITHUB_OUTPUT" + echo "service=harness-api" >> "$GITHUB_OUTPUT" + else + echo "deploy_path=/opt/harness-api-staging" >> "$GITHUB_OUTPUT" + echo "service=harness-api-staging" >> "$GITHUB_OUTPUT" + fi + + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/harness.pem + chmod 600 ~/.ssh/harness.pem + ssh-keyscan -H 52.45.218.243 >> ~/.ssh/known_hosts + + - name: Deploy to EC2 + run: | + rsync -avz --delete \ + --exclude='.env' \ + --exclude='.venv' \ + --exclude='__pycache__' \ + --exclude='.git' \ + -e "ssh -i ~/.ssh/harness.pem" \ + packages/fastapi/ ec2-user@52.45.218.243:${{ steps.env.outputs.deploy_path }}/ + + - name: Install dependencies and restart + run: | + ssh -i ~/.ssh/harness.pem ec2-user@52.45.218.243 << EOF + cd ${{ steps.env.outputs.deploy_path }} + python3.11 -m venv .venv 2>/dev/null || true + .venv/bin/pip install -q -r requirements.txt + sudo systemctl restart ${{ steps.env.outputs.service }} + sleep 2 + sudo systemctl is-active ${{ steps.env.outputs.service }} + EOF diff --git a/.github/workflows/frontend-cd.yml b/.github/workflows/frontend-cd.yml new file mode 100644 index 0000000..f4bcd88 --- /dev/null +++ b/.github/workflows/frontend-cd.yml @@ -0,0 +1,55 @@ +name: Deploy Frontend + +on: + push: + branches: [staging, main] + paths: + - "apps/web/**" + - "packages/convex-backend/**" + - ".github/workflows/frontend-cd.yml" + +concurrency: + group: frontend-deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Deploy Convex backend + run: cd packages/convex-backend && npx convex deploy + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Determine environment + id: env + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "worker_name=harness-web" >> "$GITHUB_OUTPUT" + echo "fastapi_url=${{ secrets.FASTAPI_URL_PROD }}" >> "$GITHUB_OUTPUT" + else + echo "worker_name=harness-web-staging" >> "$GITHUB_OUTPUT" + echo "fastapi_url=${{ secrets.FASTAPI_URL_STAGING }}" >> "$GITHUB_OUTPUT" + fi + + - name: Build frontend + run: cd apps/web && bun run build + env: + VITE_CONVEX_URL: ${{ secrets.VITE_CONVEX_URL }} + VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + VITE_FASTAPI_URL: ${{ steps.env.outputs.fastapi_url }} + + - name: Deploy to Cloudflare Workers + run: cd apps/web && npx wrangler deploy --name ${{ steps.env.outputs.worker_name }} + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 4630464..60d47ca 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ dist # Misc .DS_Store *.pem +docs/chat-loop-runtime-and-mcp-gateway-proposal.md +docs/claudedaytonaplan.md +docs/daytona-integration-proposal.md 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/.husky/pre-commit b/apps/web/.husky/pre-commit new file mode 100755 index 0000000..3264720 --- /dev/null +++ b/apps/web/.husky/pre-commit @@ -0,0 +1 @@ +cd apps/web && bunx biome check --staged --no-errors-on-unmatched diff --git a/apps/web/biome.json b/apps/web/biome.json index 695f635..3a698fa 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -24,7 +24,22 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "useUniqueElementIds": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + }, + "suspicious": { + "noArrayIndexKey": "warn", + "noExplicitAny": "warn" + }, + "a11y": { + "noStaticElementInteractions": "warn", + "useSemanticElements": "warn", + "noLabelWithoutControl": "warn" + } } }, "javascript": { diff --git a/apps/web/package.json b/apps/web/package.json index 5b011c6..53f4e51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,10 @@ "dev": "vite dev --port 3000", "build": "vite build", "preview": "vite preview", + "preview:cf": "vite build && wrangler dev", + "deploy": "vite build && wrangler deploy", + "deploy:staging": "vite build && wrangler deploy --env staging", + "deploy:production": "vite build && wrangler deploy --env production", "test": "vitest run", "format": "biome format --write", "lint": "biome lint", @@ -15,7 +19,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", @@ -24,23 +30,33 @@ "@tanstack/react-router-ssr-query": "^1.131.7", "@tanstack/react-start": "^1.132.0", "@tanstack/router-plugin": "^1.132.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "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" }, "devDependencies": { "@biomejs/biome": "2.2.4", + "@cloudflare/vite-plugin": "^1.30.3", "@tanstack/devtools-vite": "^0.3.11", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", @@ -51,6 +67,7 @@ "jsdom": "^27.0.0", "typescript": "5.9.2", "vite": "^7.1.7", - "vitest": "^3.0.5" + "vitest": "^3.0.5", + "wrangler": "^4.79.0" } } \ No newline at end of file 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/attachment-chip.tsx b/apps/web/src/components/attachment-chip.tsx new file mode 100644 index 0000000..2621122 --- /dev/null +++ b/apps/web/src/components/attachment-chip.tsx @@ -0,0 +1,55 @@ +import { FileText, Loader2, Music, X } from "lucide-react"; +import type { PendingAttachment } from "../hooks/use-file-attachments"; + +export function AttachmentChip({ + attachment, + onRemove, +}: { + attachment: PendingAttachment; + onRemove: () => void; +}) { + const isImage = attachment.mimeType.startsWith("image/"); + const isPdf = attachment.mimeType === "application/pdf"; + const isAudio = attachment.mimeType.startsWith("audio/"); + + const Icon = isAudio ? Music : FileText; + + return ( +
+ {isImage && attachment.previewUrl ? ( + {attachment.fileName} + ) : isPdf || isAudio ? ( +
+ + + {attachment.fileName} + +
+ ) : null} + + {attachment.status === "uploading" && ( +
+ +
+ )} + + {attachment.status === "error" && ( +
+ Error +
+ )} + + +
+ ); +} 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..57e6f97 --- /dev/null +++ b/apps/web/src/components/mcp-oauth-connect-row.tsx @@ -0,0 +1,124 @@ +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"; +const BACKEND_ORIGIN = new URL(API_URL).origin; + +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.origin !== BACKEND_ORIGIN) return; + if (popup && event.source !== popup) return; + 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..b348bdc --- /dev/null +++ b/apps/web/src/components/mcp-server-status.tsx @@ -0,0 +1,409 @@ +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"; +const BACKEND_ORIGIN = new URL(API_URL).origin; + +/** + * 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.origin !== BACKEND_ORIGIN) return; + if (popup && event.source !== popup) return; + 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" | "tiger_junction"; + 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/message-attachments.tsx b/apps/web/src/components/message-attachments.tsx new file mode 100644 index 0000000..84db2aa --- /dev/null +++ b/apps/web/src/components/message-attachments.tsx @@ -0,0 +1,99 @@ +import { convexQuery } from "@convex-dev/react-query"; +import { api } from "@harness/convex-backend/convex/_generated/api"; +import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; +import { useQuery } from "@tanstack/react-query"; +import { FileText, Music } from "lucide-react"; +import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; + +interface Attachment { + storageId: Id<"_storage">; + mimeType: string; + fileName: string; + fileSize: number; +} + +function AttachmentItem({ attachment }: { attachment: Attachment }) { + const { data: url } = useQuery( + convexQuery(api.files.getFileUrl, { storageId: attachment.storageId }), + ); + + if (!url) return null; + + const square = + "h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-border"; + + if (attachment.mimeType.startsWith("image/")) { + return ( + + + + + + {attachment.fileName} + + + ); + } + + if (attachment.mimeType === "application/pdf") { + return ( + + + + {attachment.fileName} + + + ); + } + + if (attachment.mimeType.startsWith("audio/")) { + return ( +
+
+ + + {attachment.fileName} + +
+ {/* biome-ignore lint/a11y/useMediaCaption: audio recording playback */} +
+ ); + } + + return null; +} + +export function MessageAttachments({ + attachments, +}: { + attachments: Attachment[]; +}) { + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((a) => ( + + ))} +
+ ); +} 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/princeton-connect-row.tsx b/apps/web/src/components/princeton-connect-row.tsx new file mode 100644 index 0000000..68e7601 --- /dev/null +++ b/apps/web/src/components/princeton-connect-row.tsx @@ -0,0 +1,204 @@ +import { useUser } from "@clerk/tanstack-react-start"; +import { GraduationCap, Loader2, Mail } from "lucide-react"; +import { motion } from "motion/react"; +import { useCallback, useState } from "react"; +import toast from "react-hot-toast"; +import type { McpServerEntry } from "../lib/mcp"; +import { getPrincetonNetid } from "../lib/mcp"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +export function PrincetonConnectRow({ server }: { server: McpServerEntry }) { + const { user } = useUser(); + const netid = getPrincetonNetid(user); + + // Email verification flow state (for users without a Princeton email) + const [showForm, setShowForm] = useState(false); + const [netidInput, setNetidInput] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + const [emailId, setEmailId] = useState(null); + const [loading, setLoading] = useState(false); + const [step, setStep] = useState<"email" | "code">("email"); + + const handleAddEmail = useCallback(async () => { + if (!user || !netidInput.trim()) return; + setLoading(true); + try { + const email = `${netidInput.trim()}@princeton.edu`; + const res = await user.createEmailAddress({ email }); + await user.reload(); + + const emailAddress = user.emailAddresses.find((a) => a.id === res?.id); + if (!emailAddress) throw new Error("Email not found after creation"); + + await emailAddress.prepareVerification({ strategy: "email_code" }); + setEmailId(emailAddress.id); + setStep("code"); + toast.success(`Verification code sent to ${email}`); + } catch (err) { + console.error("[Princeton] Add email error:", err); + const message = + err instanceof Error ? err.message : "Failed to add email"; + toast.error(message); + } finally { + setLoading(false); + } + }, [user, netidInput]); + + const handleVerifyCode = useCallback(async () => { + if (!user || !emailId || !verificationCode.trim()) return; + setLoading(true); + try { + const emailAddress = user.emailAddresses.find((a) => a.id === emailId); + if (!emailAddress) throw new Error("Email not found"); + + const result = await emailAddress.attemptVerification({ + code: verificationCode.trim(), + }); + + if (result.verification?.status === "verified") { + await user.reload(); + toast.success("Princeton account verified!"); + setShowForm(false); + } else { + toast.error("Verification failed. Check the code and try again."); + } + } catch (err) { + console.error("[Princeton] Verify error:", err); + const message = + err instanceof Error ? err.message : "Verification failed"; + toast.error(message); + } finally { + setLoading(false); + } + }, [user, emailId, verificationCode]); + + // Already connected — show badge + if (netid) { + return ( + + +
+

{server.name}

+

+ Princeton University +

+
+ +
+ {netid} + + + ); + } + + // Show inline email verification form + if (showForm) { + return ( + +
+ +

+ Verify Princeton Account +

+
+ + {step === "email" ? ( +
+
+ setNetidInput(e.target.value)} + placeholder="netid" + className="rounded-r-none text-xs" + onKeyDown={(e) => e.key === "Enter" && handleAddEmail()} + /> + + @princeton.edu + +
+ +
+ ) : ( +
+ setVerificationCode(e.target.value)} + placeholder="Enter verification code" + className="flex-1 text-xs" + onKeyDown={(e) => e.key === "Enter" && handleVerifyCode()} + /> + +
+ )} + + +
+ ); + } + + // Default: show connect button + return ( + + +
+

{server.name}

+

+ Requires Princeton University account +

+
+ +
+ ); +} diff --git a/apps/web/src/components/recommended-skills-grid.tsx b/apps/web/src/components/recommended-skills-grid.tsx new file mode 100644 index 0000000..cce343d --- /dev/null +++ b/apps/web/src/components/recommended-skills-grid.tsx @@ -0,0 +1,72 @@ +import { Download, Zap } from "lucide-react"; +import type { SkillEntry } from "../lib/skills"; +import { RECOMMENDED_SKILLS } from "../lib/skills"; +import { Checkbox } from "./ui/checkbox"; + +interface RecommendedSkillsGridProps { + selected: SkillEntry[]; + onToggle: (skill: SkillEntry) => void; +} + +export function RecommendedSkillsGrid({ + selected, + onToggle, +}: RecommendedSkillsGridProps) { + if (RECOMMENDED_SKILLS.length === 0) { + return null; + } + + const formatInstalls = (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toString(); + }; + + return ( +
+ {RECOMMENDED_SKILLS.map((rec) => { + const isSelected = selected.some((s) => s.name === rec.skill.fullId); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/sandbox-result.tsx b/apps/web/src/components/sandbox-result.tsx new file mode 100644 index 0000000..9377340 --- /dev/null +++ b/apps/web/src/components/sandbox-result.tsx @@ -0,0 +1,863 @@ +import { useAuth } from "@clerk/clerk-react"; +import { + AlertTriangle, + Check, + ChevronDown, + Clock, + Code, + Copy, + ExternalLink, + File, + Folder, + GitBranch, + Github, + Terminal, + X, +} from "lucide-react"; +import { useCallback, useState } from "react"; +import { env } from "../env"; +import { useSandboxPanel } from "../lib/sandbox-panel-context"; +import { detectLanguage, useHighlighted } from "../lib/syntax-highlight"; +import { cn } from "../lib/utils"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +const API_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; +const BACKEND_ORIGIN = new URL(API_URL).origin; + +interface SandboxResultProps { + result: string; + toolName?: string; + /** Tool call arguments — passed through so components can show source code, etc. */ + args?: Record; +} + +/** + * Renders sandbox tool results as rich UI blocks in the chat. + * Parses the JSON result from sandbox tools and renders appropriate + * visualizations for code execution, commands, files, git, etc. + */ +export function SandboxResult({ result, args }: SandboxResultProps) { + let parsed: Record; + try { + parsed = JSON.parse(result); + } catch { + return ; + } + + const type = parsed.type as string; + + switch (type) { + case "code_execution": + return ; + case "command_result": + return ; + case "file_content": + return ; + case "image": + return ; + case "file_list": + return ; + case "git_status": + return ; + case "git_commit": + return ; + case "git_log": + return ; + case "git_diff": + return ; + case "git_branches": + return ; + case "search_results": + return ; + case "success": + return ; + case "error": + return ; + default: + return ; + } +} + +function CodeExecutionResult({ + data, + args, +}: { + data: Record; + args?: Record; +}) { + const [expanded, setExpanded] = useState(true); + const [codeVisible, setCodeVisible] = useState(false); + const exitCode = data.exit_code as number; + const stdout = data.stdout as string; + const stderr = data.stderr as string; + const language = data.language as string; + const executionTime = data.execution_time as number | undefined; + const charts = data.charts as + | Array<{ title?: string; png?: string }> + | undefined; + const success = exitCode === 0; + const sourceCode = (args?.code as string) ?? ""; + const highlightedCode = useHighlighted(sourceCode, language); + + const handleCopy = () => { + navigator.clipboard.writeText(stdout || stderr); + }; + + const handleCopyCode = () => { + navigator.clipboard.writeText(sourceCode); + }; + + return ( +
+ + + {expanded && ( +
+ {/* Source code (collapsible, like Cursor) */} + {sourceCode && ( +
+ + {codeVisible && ( +
+ {highlightedCode ? ( +
+											
+										
+ ) : ( +
+											{sourceCode}
+										
+ )} + +
+ )} +
+ )} + + {/* stdout */} + {stdout && ( +
+
+								{stdout}
+							
+ +
+ )} + + {/* stderr */} + {stderr && ( +
+
+ + stderr +
+
+								{stderr}
+							
+
+ )} + + {/* Chart images from execution artifacts */} + {charts && charts.length > 0 && ( +
+ {charts.map((chart, i) => ( +
+ {chart.title && ( +

+ {chart.title} +

+ )} + {chart.png && ( + {chart.title + )} +
+ ))} +
+ )} + + {!stdout && !stderr && (!charts || charts.length === 0) && ( +

+ No output +

+ )} +
+ )} +
+ ); +} + +function CommandResult({ data }: { data: Record }) { + const [expanded, setExpanded] = useState(true); + const exitCode = data.exit_code as number; + const stdout = data.stdout as string; + const stderr = data.stderr as string; + const command = data.command as string; + const success = exitCode === 0; + + return ( +
+ + + {expanded && (stdout || stderr) && ( +
+ {stdout && ( +
+							{stdout}
+						
+ )} + {stderr && ( +
+							{stderr}
+						
+ )} +
+ )} +
+ ); +} + +function FileContentResult({ data }: { data: Record }) { + const [expanded, setExpanded] = useState(true); + const path = data.path as string; + const content = data.content as string; + const fileName = path.split("/").pop() ?? path; + const language = detectLanguage(path); + const highlighted = useHighlighted(content, language); + + const handleCopy = () => { + navigator.clipboard.writeText(content); + }; + + return ( +
+ + + {expanded && ( +
+ {highlighted ? ( +
+							
+						
+ ) : ( +
+							{content}
+						
+ )} + +
+ )} +
+ ); +} + +function ImageResult({ data }: { data: Record }) { + const [expanded, setExpanded] = useState(true); + const path = data.path as string; + const mime = data.mime as string; + const b64 = data.data as string; + const fileName = path.split("/").pop() ?? path; + + return ( +
+ + + {expanded && ( +
+ {fileName} +
+ )} +
+ ); +} + +function FileListResult({ data }: { data: Record }) { + const panel = useSandboxPanel(); + const path = data.path as string; + const files = data.files as Array<{ + name: string; + path: string; + is_dir: boolean; + size: number | null; + }>; + + return ( +
+
+ + {path} + + {files.length} items + +
+
+ {files.map((file) => ( + + ))} +
+
+ ); +} + +function GitStatusResult({ data }: { data: Record }) { + const branch = data.branch as string; + const ahead = data.ahead as number; + const behind = data.behind as number; + const files = data.files as Array<{ path: string; status: string }>; + + return ( +
+
+ + {branch} + {ahead > 0 && ( + + +{ahead} ahead + + )} + {behind > 0 && ( + + -{behind} behind + + )} +
+ {files.length > 0 && ( +
+ {files.map((file) => ( +
+ + {file.status === "modified" + ? "M" + : file.status === "added" + ? "A" + : file.status === "deleted" + ? "D" + : "?"} + + {file.path} +
+ ))} +
+ )} +
+ ); +} + +function GitCommitResult({ data }: { data: Record }) { + const sha = data.sha as string; + const message = data.message as string; + + return ( +
+ + Committed: + + {sha.slice(0, 7)} + + {message} +
+ ); +} + +function GitLogResult({ data }: { data: Record }) { + const commits = data.commits as Array<{ + sha: string; + message: string; + author: string; + date: string; + }>; + + return ( +
+
+ + Git Log + + {commits.length} commits + +
+
+ {commits.map((commit) => ( +
+ + {commit.sha.slice(0, 7)} + + {commit.message} + + {commit.author} + +
+ ))} +
+
+ ); +} + +function GitDiffResult({ data }: { data: Record }) { + const [expanded, setExpanded] = useState(true); + const diff = data.diff as string; + + if (!diff.trim()) { + return ( +
+ + No changes +
+ ); + } + + return ( +
+ + + {expanded && ( +
+
+						{diff.split("\n").map((line, i) => (
+							
+								{line}
+							
+						))}
+					
+
+ )} +
+ ); +} + +function GitBranchesResult({ data }: { data: Record }) { + const branches = data.branches as string[]; + + return ( +
+
+ + Branches + + {branches.length} + +
+
+ {branches.map((branch) => ( +
+ + {branch} +
+ ))} +
+
+ ); +} + +function SearchResult({ data }: { data: Record }) { + const matches = (data.matches as Array>) ?? []; + const files = (data.files as string[]) ?? []; + const items = matches.length > 0 ? matches : files; + + return ( +
+
+ + Search Results + + {items.length} matches + +
+ {items.length > 0 && ( +
+ {matches.length > 0 + ? matches.map((m, i) => ( +
+ + {String(m.file ?? "")} + + {m.line != null && ( + + :{String(m.line)} + + )} + {String(m.content ?? "")} +
+ )) + : files.map((f) => ( +
+ {f} +
+ ))} +
+ )} +
+ ); +} + +function SuccessResult({ data }: { data: Record }) { + return ( +
+ + {String(data.message ?? "Success")} +
+ ); +} + +function ErrorResult({ data }: { data: Record }) { + const errorCode = data.error_code as string | undefined; + + if (errorCode === "github_auth_required") { + return ; + } + + return ( +
+
+ + + {String(data.message ?? "Error")} + +
+
+ ); +} + +function GitHubAuthRequiredError({ message }: { message: string }) { + const { getToken } = useAuth(); + const [status, setStatus] = useState<"idle" | "connecting" | "connected">( + "idle", + ); + + const handleConnect = useCallback(async () => { + setStatus("connecting"); + + try { + const token = await getToken(); + const res = await fetch(`${API_URL}/api/mcp/oauth/github/start`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("Failed to start GitHub OAuth"); + const data = await res.json(); + + const popup = window.open( + data.authorization_url, + "github-oauth", + "width=600,height=700", + ); + + const handler = (event: MessageEvent) => { + if (event.origin !== BACKEND_ORIGIN) return; + if (popup && event.source !== popup) return; + if (event.data?.type === "mcp-oauth-callback") { + window.removeEventListener("message", handler); + setStatus(event.data.success ? "connected" : "idle"); + } + }; + window.addEventListener("message", handler); + + const check = setInterval(() => { + if (popup?.closed) { + clearInterval(check); + window.removeEventListener("message", handler); + setStatus((s) => (s === "connecting" ? "idle" : s)); + } + }, 500); + } catch { + setStatus("idle"); + } + }, [getToken]); + + return ( +
+
+ +
+

+ GitHub Authentication Required +

+

+ {message || + "Connect your GitHub account to push, pull, and clone repositories in the sandbox."} +

+ {status === "connected" ? ( +
+ + GitHub connected — retry your message to push. +
+ ) : ( + + )} +
+
+
+ ); +} + +function FallbackResult({ result }: { result: string }) { + const [expanded, setExpanded] = useState(false); + const isLong = result.length > 200; + + return ( +
+
+				{result}
+			
+ {isLong && ( + + )} +
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/apps/web/src/components/sandbox/command-input.tsx b/apps/web/src/components/sandbox/command-input.tsx new file mode 100644 index 0000000..069cc6d --- /dev/null +++ b/apps/web/src/components/sandbox/command-input.tsx @@ -0,0 +1,329 @@ +import { useAuth } from "@clerk/clerk-react"; +import { + AlertCircle, + ChevronRight, + Loader2, + TerminalSquare, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type CommandResponse, createSandboxApi } from "../../lib/sandbox-api"; +import { useSandboxPanel } from "../../lib/sandbox-panel-context"; +import { cn } from "../../lib/utils"; +import { ScrollArea } from "../ui/scroll-area"; + +interface CommandEntry { + id: number; + command: string; + cwd: string; + result: CommandResponse | null; + error: string | null; + running: boolean; +} + +export function CommandRunner() { + const panel = useSandboxPanel(); + const { getToken } = useAuth(); + const [entries, setEntries] = useState([]); + const [input, setInput] = useState(""); + const [cwd, setCwd] = useState("/home/daytona"); + const [historyIndex, setHistoryIndex] = useState(-1); + const [tabHint, setTabHint] = useState(null); + const inputRef = useRef(null); + const bottomRef = useRef(null); + const tabCompletingRef = useRef(false); + const entryIdRef = useRef(0); + + const api = useMemo(() => createSandboxApi(getToken), [getToken]); + const sandboxId = panel?.sandboxId; + + const history = useMemo( + () => entries.filter((e) => !e.running).map((e) => e.command), + [entries], + ); + + // Refocus input after renders + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(timer); + }); + + // Scroll to bottom when entries change + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + const runCommand = useCallback( + async (command: string) => { + if (!sandboxId || !command.trim()) return; + + const trimmed = command.trim(); + const id = ++entryIdRef.current; + + setEntries((prev) => [ + ...prev, + { id, command: trimmed, cwd, result: null, error: null, running: true }, + ]); + setInput(""); + setHistoryIndex(-1); + setTabHint(null); + + // Handle cd: wrap with pwd to get resolved path + const cdMatch = trimmed.match(/^cd\s*(.*)?$/); + const cdTarget = cdMatch?.[1]?.trim() || "~"; + const actualCommand = cdMatch ? `cd ${cdTarget} && pwd` : trimmed; + + try { + const result = await api.runCommand(sandboxId, actualCommand, cwd); + + // Update entry by id (avoids stale index issues) + setEntries((prev) => + prev.map((e) => { + if (e.id !== id) return e; + if (cdMatch && result.exit_code === 0) { + const newDir = result.stdout + .trim() + .replace(/^\/home\/daytona/, "~"); + return { + ...e, + result: { ...result, stdout: newDir }, + running: false, + }; + } + return { ...e, result, running: false }; + }), + ); + + if (cdMatch && result.exit_code === 0 && result.stdout.trim()) { + setCwd(result.stdout.trim()); + } + } catch (err) { + setEntries((prev) => + prev.map((e) => + e.id === id ? { ...e, error: String(err), running: false } : e, + ), + ); + } + }, + [sandboxId, api, cwd], + ); + + const handleTabComplete = useCallback( + async (currentInput: string) => { + if (!sandboxId || tabCompletingRef.current) return; + tabCompletingRef.current = true; + setTabHint(null); + + try { + // Extract the word being completed (last space-separated token) + const parts = currentInput.split(/\s+/); + const partial = parts[parts.length - 1] || ""; + const prefix = + parts.length > 1 ? `${parts.slice(0, -1).join(" ")} ` : ""; + + // Use bash -c to ensure compgen is available + const isFirstWord = parts.length <= 1 && !currentInput.includes(" "); + const compgenFlag = isFirstWord ? "-c -f" : "-f"; + + // Escape single quotes in partial for the shell + const escaped = partial.replace(/'/g, "'\\''"); + const cmd = `bash -c 'compgen ${compgenFlag} -- '"'"'${escaped}'"'"' 2>/dev/null | head -20'`; + + const result = await api.runCommand(sandboxId, cmd, cwd); + + if (result.exit_code === 0 && result.stdout.trim()) { + const matches = result.stdout.trim().split("\n").filter(Boolean); + + if (matches.length === 1) { + setInput(prefix + matches[0]); + setTabHint(null); + } else if (matches.length > 1) { + const common = findCommonPrefix(matches); + if (common.length > partial.length) { + setInput(prefix + common); + } + setTabHint( + matches.slice(0, 8).join(" ") + + (matches.length > 8 ? " ..." : ""), + ); + } + } + } catch { + // Tab completion is best-effort + } finally { + tabCompletingRef.current = false; + inputRef.current?.focus(); + } + }, + [sandboxId, api, cwd], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + handleTabComplete(input); + return; + } + + // Clear tab hint on any other key + if (tabHint) setTabHint(null); + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + runCommand(input); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (history.length === 0) return; + const next = + historyIndex < history.length - 1 ? historyIndex + 1 : historyIndex; + setHistoryIndex(next); + setInput(history[history.length - 1 - next]); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (historyIndex <= 0) { + setHistoryIndex(-1); + setInput(""); + } else { + const next = historyIndex - 1; + setHistoryIndex(next); + setInput(history[history.length - 1 - next]); + } + } else if (e.key === "l" && e.ctrlKey) { + e.preventDefault(); + setEntries([]); + } + }, + [input, runCommand, history, historyIndex, handleTabComplete, tabHint], + ); + + const isRunning = entries.some((e) => e.running); + const displayCwd = cwd.replace(/^\/home\/daytona/, "~"); + + return ( +
inputRef.current?.focus()} + onKeyDown={() => {}} + role="presentation" + > + {/* Output area */} + +
+ {entries.length === 0 ? ( +
+ + + Run a command below + + + Tab to complete · Ctrl+L to clear + +
+ ) : ( + entries.map((entry) => ( +
+ {/* Prompt + command */} +
+ + {entry.cwd.replace(/^\/home\/daytona/, "~")} + + + + {entry.command} + + {entry.running && ( + + )} +
+ + {/* Output */} + {entry.result && ( +
+ {entry.result.stdout && ( +
+												{entry.result.stdout}
+											
+ )} + {entry.result.stderr && ( +
+												{entry.result.stderr}
+											
+ )} + {entry.result.exit_code !== 0 && ( +
+ + + exit {entry.result.exit_code} + +
+ )} +
+ )} + + {entry.error && ( +
+
+											{entry.error}
+										
+
+ )} +
+ )) + )} +
+
+ + + {/* Tab completion hints */} + {tabHint && ( +
+ + {tabHint} + +
+ )} + + {/* Input bar */} +
+ + {displayCwd} + + + { + setInput(e.target.value); + if (tabHint) setTabHint(null); + }} + onKeyDown={handleKeyDown} + placeholder={isRunning ? "running..." : ""} + disabled={isRunning} + className={cn( + "flex-1 bg-transparent font-mono text-[11px] text-foreground/80 outline-none placeholder:text-muted-foreground/25", + isRunning && "opacity-50", + )} + /> +
+
+ ); +} + +function findCommonPrefix(strings: string[]): string { + if (strings.length === 0) return ""; + let prefix = strings[0]; + for (let i = 1; i < strings.length; i++) { + while (!strings[i].startsWith(prefix)) { + prefix = prefix.slice(0, -1); + if (!prefix) return ""; + } + } + return prefix; +} diff --git a/apps/web/src/components/sandbox/file-context-menu.tsx b/apps/web/src/components/sandbox/file-context-menu.tsx new file mode 100644 index 0000000..9f98ef0 --- /dev/null +++ b/apps/web/src/components/sandbox/file-context-menu.tsx @@ -0,0 +1,240 @@ +import { useAuth } from "@clerk/clerk-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { createSandboxApi, type SandboxFile } from "../../lib/sandbox-api"; +import { useSandboxPanel } from "../../lib/sandbox-panel-context"; +import { cn } from "../../lib/utils"; + +interface ContextMenuState { + x: number; + y: number; + file: SandboxFile; +} + +interface FileContextMenuProps { + menu: ContextMenuState; + onClose: () => void; + onRefresh: () => void; +} + +export function FileContextMenu({ + menu, + onClose, + onRefresh, +}: FileContextMenuProps) { + const panel = useSandboxPanel(); + const { getToken } = useAuth(); + const menuRef = useRef(null); + const [renaming, setRenaming] = useState(false); + const [newName, setNewName] = useState(menu.file.name); + const [creating, setCreating] = useState<"file" | "folder" | null>(null); + const [createName, setCreateName] = useState(""); + + const api = useMemo(() => createSandboxApi(getToken), [getToken]); + const sandboxId = panel?.sandboxId; + + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + const handleDelete = useCallback(async () => { + if (!sandboxId) return; + const label = menu.file.is_dir ? "directory" : "file"; + if (!window.confirm(`Delete ${label} "${menu.file.name}"?`)) return; + try { + await api.deleteFile(sandboxId, menu.file.path, menu.file.is_dir); + onRefresh(); + } catch (err) { + console.error("Delete failed:", err); + } + onClose(); + }, [sandboxId, api, menu.file, onClose, onRefresh]); + + const handleRename = useCallback(async () => { + if (!sandboxId || !newName.trim() || newName === menu.file.name) { + setRenaming(false); + return; + } + const parentDir = menu.file.path.substring( + 0, + menu.file.path.lastIndexOf("/"), + ); + const destination = `${parentDir}/${newName.trim()}`; + try { + await api.moveFile(sandboxId, menu.file.path, destination); + onRefresh(); + } catch (err) { + console.error("Rename failed:", err); + } + onClose(); + }, [sandboxId, api, menu.file, newName, onClose, onRefresh]); + + const handleCreate = useCallback( + async (type: "file" | "folder") => { + const name = createName.trim(); + if ( + !sandboxId || + !name || + name.includes("/") || + name.includes("\\") || + name.includes("..") + ) { + setCreating(null); + return; + } + const parentPath = menu.file.is_dir + ? menu.file.path + : menu.file.path.substring(0, menu.file.path.lastIndexOf("/")); + const fullPath = `${parentPath}/${name}`; + try { + if (type === "folder") { + await api.createDirectory(sandboxId, fullPath); + } else { + await api.writeFile(sandboxId, fullPath, ""); + } + onRefresh(); + } catch (err) { + console.error("Create failed:", err); + } + onClose(); + }, + [sandboxId, api, menu.file, createName, onClose, onRefresh], + ); + + const handleOpenFile = useCallback(() => { + if (!menu.file.is_dir) { + panel?.openFile(menu.file.path); + } + onClose(); + }, [menu.file, panel, onClose]); + + // Inline rename input + if (renaming) { + return createPortal( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + if (e.key === "Escape") onClose(); + }} + className="w-48 rounded border border-border bg-background px-2 py-1 font-mono text-[11px] text-foreground outline-none focus:border-foreground/30" + /> +
, + document.body, + ); + } + + // Inline create input + if (creating) { + return createPortal( +
+

+ New {creating} +

+ setCreateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(creating); + if (e.key === "Escape") onClose(); + }} + placeholder={creating === "folder" ? "folder name" : "filename.txt"} + className="w-48 rounded border border-border bg-background px-2 py-1 font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/30 focus:border-foreground/30" + /> +
, + document.body, + ); + } + + return createPortal( +
+ {!menu.file.is_dir && Open} + { + setCreating("file"); + setCreateName(""); + }} + > + New File + + { + setCreating("folder"); + setCreateName(""); + }} + > + New Folder + +
+ { + setRenaming(true); + setNewName(menu.file.name); + }} + > + Rename + + + Delete + +
, + document.body, + ); +} + +function MenuItem({ + children, + onClick, + destructive, +}: { + children: React.ReactNode; + onClick: () => void; + destructive?: boolean; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/sandbox/file-explorer.tsx b/apps/web/src/components/sandbox/file-explorer.tsx new file mode 100644 index 0000000..004540f --- /dev/null +++ b/apps/web/src/components/sandbox/file-explorer.tsx @@ -0,0 +1,314 @@ +import { useAuth } from "@clerk/clerk-react"; +import { + ChevronRight, + File, + Folder, + FolderOpen, + Loader2, + RefreshCw, + Search, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createSandboxApi, type SandboxFile } from "../../lib/sandbox-api"; +import { useSandboxPanel } from "../../lib/sandbox-panel-context"; +import { cn } from "../../lib/utils"; +import { ScrollArea } from "../ui/scroll-area"; +import { FileContextMenu } from "./file-context-menu"; +import { FileSearch } from "./file-search"; + +interface DirNode { + files: SandboxFile[]; + loading: boolean; + expanded: boolean; +} + +interface ContextMenuState { + x: number; + y: number; + file: SandboxFile; +} + +export function FileExplorer() { + const panel = useSandboxPanel(); + const { getToken } = useAuth(); + const [dirs, setDirs] = useState>(new Map()); + const [contextMenu, setContextMenu] = useState(null); + const [showSearch, setShowSearch] = useState(false); + + const api = useMemo(() => createSandboxApi(getToken), [getToken]); + const sandboxId = panel?.sandboxId; + const currentDir = panel?.currentDir ?? "/home/daytona"; + + const loadDir = useCallback( + async (path: string) => { + if (!sandboxId) return; + setDirs((prev) => { + const next = new Map(prev); + const existing = next.get(path); + next.set(path, { + files: existing?.files ?? [], + loading: true, + expanded: true, + }); + return next; + }); + try { + const res = await api.listFiles(sandboxId, path); + const sorted = [...res.files].sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + setDirs((prev) => { + const next = new Map(prev); + next.set(path, { files: sorted, loading: false, expanded: true }); + return next; + }); + } catch (err) { + console.error("Failed to list files:", err); + setDirs((prev) => { + const next = new Map(prev); + next.set(path, { files: [], loading: false, expanded: true }); + return next; + }); + } + }, + [sandboxId, api], + ); + + useEffect(() => { + if (sandboxId) { + loadDir(currentDir); + } + }, [sandboxId, currentDir, loadDir]); + + const toggleDir = useCallback( + (path: string) => { + const node = dirs.get(path); + if (node?.expanded) { + setDirs((prev) => { + const next = new Map(prev); + next.set(path, { ...node, expanded: false }); + return next; + }); + } else { + loadDir(path); + } + }, + [dirs, loadDir], + ); + + const handleFileClick = useCallback( + (file: SandboxFile) => { + if (file.is_dir) { + toggleDir(file.path); + } else { + panel?.openFile(file.path); + } + }, + [panel, toggleDir], + ); + + const handleContextMenu = useCallback( + (e: React.MouseEvent, file: SandboxFile) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, file }); + }, + [], + ); + + const handleRefresh = useCallback(() => { + loadDir(currentDir); + }, [currentDir, loadDir]); + + const rootNode = dirs.get(currentDir); + const pathSegments = currentDir.split("/").filter(Boolean); + + if (showSearch) { + return setShowSearch(false)} />; + } + + return ( +
+ {/* Path bar */} +
+ + {pathSegments.length > 2 + ? `.../${pathSegments.slice(-2).join("/")}` + : `/${pathSegments.join("/")}`} + + + +
+ + {/* File tree */} + +
+ {rootNode?.loading && rootNode.files.length === 0 ? ( +
+ +
+ ) : rootNode?.files.length === 0 ? ( +

+ empty directory +

+ ) : ( + rootNode?.files.map((file) => ( + + )) + )} +
+
+ + {/* Footer */} + {rootNode && !rootNode.loading && ( +
+ + {rootNode.files.length} items + +
+ )} + + {/* Context menu */} + {contextMenu && ( + setContextMenu(null)} + onRefresh={handleRefresh} + /> + )} +
+ ); +} + +function FileTreeNode({ + file, + depth, + dirs, + onToggle, + onClick, + onContextMenu, + activeFile, +}: { + file: SandboxFile; + depth: number; + dirs: Map; + onToggle: (path: string) => void; + onClick: (file: SandboxFile) => void; + onContextMenu: (e: React.MouseEvent, file: SandboxFile) => void; + activeFile: string | null; +}) { + const node = file.is_dir ? dirs.get(file.path) : null; + const isExpanded = node?.expanded ?? false; + const isActive = !file.is_dir && file.path === activeFile; + + return ( + <> + + + {file.is_dir && + isExpanded && + node && + (node.loading && node.files.length === 0 ? ( +
+ +
+ ) : ( + node.files.map((child) => ( + + )) + ))} + + ); +} diff --git a/apps/web/src/components/sandbox/file-search.tsx b/apps/web/src/components/sandbox/file-search.tsx new file mode 100644 index 0000000..990d7ca --- /dev/null +++ b/apps/web/src/components/sandbox/file-search.tsx @@ -0,0 +1,134 @@ +import { useAuth } from "@clerk/clerk-react"; +import { FileText, Loader2, Search, X } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { createSandboxApi, type SearchMatch } from "../../lib/sandbox-api"; +import { useSandboxPanel } from "../../lib/sandbox-panel-context"; +import { ScrollArea } from "../ui/scroll-area"; + +export function FileSearch({ onClose }: { onClose: () => void }) { + const panel = useSandboxPanel(); + const { getToken } = useAuth(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + const inputRef = useRef(null); + + const api = useMemo(() => createSandboxApi(getToken), [getToken]); + const sandboxId = panel?.sandboxId; + const searchPath = panel?.currentDir ?? "/home/daytona"; + + const handleSearch = useCallback(async () => { + if (!sandboxId || !query.trim()) return; + setSearching(true); + setSearched(false); + try { + const res = await api.searchFiles(sandboxId, query.trim(), searchPath); + setResults(res.matches); + } catch { + setResults([]); + } finally { + setSearching(false); + setSearched(true); + } + }, [sandboxId, api, query, searchPath]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSearch(); + } + if (e.key === "Escape") { + onClose(); + } + }, + [handleSearch, onClose], + ); + + return ( +
+ {/* Search bar */} +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search in files..." + className="flex-1 bg-transparent font-mono text-[11px] text-foreground/80 outline-none placeholder:text-muted-foreground/30" + /> + {searching && ( + + )} + +
+ + {/* Results */} + +
+ {!searched && results.length === 0 && ( +

+ Enter a pattern and press Enter +

+ )} + {searched && results.length === 0 && ( +

+ No matches found +

+ )} + {results.map((match, i) => ( + + ))} +
+
+ + {/* Footer */} + {searched && results.length > 0 && ( +
+ + {results.length} match{results.length !== 1 ? "es" : ""} + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/sandbox/file-viewer.tsx b/apps/web/src/components/sandbox/file-viewer.tsx new file mode 100644 index 0000000..e2bb8c1 --- /dev/null +++ b/apps/web/src/components/sandbox/file-viewer.tsx @@ -0,0 +1,347 @@ +import { useAuth } from "@clerk/clerk-react"; +import { Check, Copy, Download, Loader2, Pencil, Save, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createSandboxApi } from "../../lib/sandbox-api"; +import { useSandboxPanel } from "../../lib/sandbox-panel-context"; +import { detectLanguage, useHighlighted } from "../../lib/syntax-highlight"; +import { ScrollArea } from "../ui/scroll-area"; + +interface FileCache { + content: string; + loading: boolean; + error: string | null; +} + +export function FileViewer() { + const panel = useSandboxPanel(); + const { getToken } = useAuth(); + const [cache, setCache] = useState>(new Map()); + + const api = useMemo(() => createSandboxApi(getToken), [getToken]); + const sandboxId = panel?.sandboxId; + const activeFile = panel?.activeFile; + + const loadFile = useCallback( + async (path: string) => { + if (!sandboxId) return; + setCache((prev) => { + const next = new Map(prev); + next.set(path, { content: "", loading: true, error: null }); + return next; + }); + try { + const res = await api.readFile(sandboxId, path); + setCache((prev) => { + const next = new Map(prev); + next.set(path, { content: res.content, loading: false, error: null }); + return next; + }); + } catch (err) { + setCache((prev) => { + const next = new Map(prev); + next.set(path, { + content: "", + loading: false, + error: String(err), + }); + return next; + }); + } + }, + [sandboxId, api], + ); + + const updateCachedContent = useCallback((path: string, content: string) => { + setCache((prev) => { + const next = new Map(prev); + const existing = next.get(path); + if (existing) { + next.set(path, { ...existing, content }); + } + return next; + }); + }, []); + + useEffect(() => { + if (activeFile && !cache.has(activeFile)) { + loadFile(activeFile); + } + }, [activeFile, cache, loadFile]); + + if (!activeFile) return null; + + const entry = cache.get(activeFile); + + if (!entry || entry.loading) { + return ( +
+ +
+ ); + } + + if (entry.error) { + return ( +
+

{entry.error}

+
+ ); + } + + return ( + + ); +} + +function FileContent({ + path, + content, + sandboxId, + onContentSaved, +}: { + path: string; + content: string; + sandboxId: string | null; + onContentSaved: (path: string, content: string) => void; +}) { + const { getToken } = useAuth(); + const [copied, setCopied] = useState(false); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(content); + const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState<"saved" | "error" | null>(null); + const textareaRef = useRef(null); + + const language = detectLanguage(path); + const highlighted = useHighlighted(content, language); + const lines = editing ? editContent.split("\n") : content.split("\n"); + + const api = useMemo(() => createSandboxApi(getToken), [getToken]); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(editing ? editContent : content); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [content, editing, editContent]); + + const handleDownload = useCallback(() => { + const fileName = path.split("/").pop() ?? "file"; + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + }, [content, path]); + + const handleEdit = useCallback(() => { + setEditContent(content); + setEditing(true); + setSaveStatus(null); + setTimeout(() => textareaRef.current?.focus(), 50); + }, [content]); + + const handleCancel = useCallback(() => { + setEditing(false); + setEditContent(content); + setSaveStatus(null); + }, [content]); + + const handleSave = useCallback(async () => { + if (!sandboxId) return; + setSaving(true); + setSaveStatus(null); + try { + await api.writeFile(sandboxId, path, editContent); + onContentSaved(path, editContent); + setSaveStatus("saved"); + setEditing(false); + setTimeout(() => setSaveStatus(null), 2000); + } catch { + setSaveStatus("error"); + } finally { + setSaving(false); + } + }, [sandboxId, api, path, editContent, onContentSaved]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Cmd/Ctrl+S to save + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + handleSave(); + } + // Escape to cancel + if (e.key === "Escape") { + handleCancel(); + } + // Tab to indent + if (e.key === "Tab") { + e.preventDefault(); + const textarea = textareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const val = textarea.value; + setEditContent(`${val.substring(0, start)}\t${val.substring(end)}`); + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = start + 1; + }, 0); + } + }, + [handleSave, handleCancel], + ); + + const hasChanges = editing && editContent !== content; + + return ( +
+ {/* File info bar */} +
+
+ + {path} + + {language && ( + + {language} + + )} + {saveStatus === "saved" && ( + + saved + + )} + {saveStatus === "error" && ( + + save failed + + )} +
+
+ {editing ? ( + <> + + + + ) : ( + <> + {sandboxId && ( + + )} + + {sandboxId && ( + + )} + + )} +
+
+ + {/* Code area */} + +
+ {/* Line numbers */} +
+ {lines.map((_, i) => ( +
{i + 1}
+ ))} +
+ + {/* Content */} +
+ {editing ? ( +