diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..26dd101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,9 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +## Cursor Cloud specific instructions + +- **Stack:** `pnpm`, Postgres via `docker compose` (port **55432**), `pnpm setup:dev`, dev server `pnpm dev` → http://localhost:3000. +- **UI:** Dark-first shell with sidebar (`AppShell`), flat `DetailSection` panels on run detail, `cursor-panel` / `cursor-field` utilities in `src/app/globals.css`. Read `node_modules/next/dist/docs/` before changing Next.js APIs. +- **Verify:** `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`. diff --git a/__tests__/cursor/events.test.ts b/__tests__/cursor/events.test.ts new file mode 100644 index 0000000..8ec0a89 --- /dev/null +++ b/__tests__/cursor/events.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + getAssistantMessageText, + getCursorEventMessage, + getCursorEventType, + getUserMessageText, +} from "@/lib/cursor/events"; + +const baseIds = { agent_id: "bc-1", run_id: "run-1" }; + +describe("getCursorEventType", () => { + it("returns SDK message type", () => { + expect( + getCursorEventType({ + type: "status", + ...baseIds, + status: "RUNNING", + }) + ).toBe("status"); + }); + + it("returns unknown for malformed payloads", () => { + expect(getCursorEventType({})).toBe("unknown"); + }); +}); + +describe("getCursorEventMessage", () => { + it("extracts assistant text blocks", () => { + expect( + getCursorEventMessage({ + type: "assistant", + ...baseIds, + message: { + role: "assistant", + content: [ + { type: "text", text: "Hello" }, + { type: "tool_use", id: "t1", name: "shell", input: {} }, + ], + }, + }) + ).toBe("Hello"); + }); + + it("formats thinking with duration", () => { + expect( + getCursorEventMessage({ + type: "thinking", + ...baseIds, + text: "Planning", + thinking_duration_ms: 1200, + }) + ).toBe("Planning (1200ms)"); + }); + + it("formats tool_call lifecycle", () => { + expect( + getCursorEventMessage({ + type: "tool_call", + ...baseIds, + call_id: "c1", + name: "read", + status: "completed", + truncated: { result: true }, + }) + ).toBe("Tool read completed (truncated)"); + }); + + it("formats status with optional message", () => { + expect( + getCursorEventMessage({ + type: "status", + ...baseIds, + status: "CREATING", + message: "Provisioning VM", + }) + ).toBe("CREATING: Provisioning VM"); + }); + + it("formats task milestones", () => { + expect( + getCursorEventMessage({ + type: "task", + ...baseIds, + status: "done", + text: "Summary ready", + }) + ).toBe("done: Summary ready"); + }); + + it("extracts user prompt echo", () => { + expect( + getCursorEventMessage({ + type: "user", + ...baseIds, + message: { + role: "user", + content: [{ type: "text", text: "Fix the bug" }], + }, + }) + ).toBe("Fix the bug"); + }); + + it("handles system init", () => { + expect( + getCursorEventMessage({ + type: "system", + subtype: "init", + ...baseIds, + }) + ).toBe("Run initialized"); + }); +}); + +describe("message helpers", () => { + it("exports typed assistant and user extractors", () => { + const assistant = { + type: "assistant" as const, + ...baseIds, + message: { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Done" }], + }, + }; + + expect(getAssistantMessageText(assistant)).toBe("Done"); + expect( + getUserMessageText({ + type: "user", + ...baseIds, + message: { + role: "user", + content: [{ type: "text", text: "Hi" }], + }, + }) + ).toBe("Hi"); + }); +}); diff --git a/__tests__/cursor/results.test.ts b/__tests__/cursor/results.test.ts index 7fc89d5..68030e8 100644 --- a/__tests__/cursor/results.test.ts +++ b/__tests__/cursor/results.test.ts @@ -4,6 +4,7 @@ import { artifactToRecord, extractGitResultMetadata, extractRunResult, + type ExtractableRunPayload, } from "@/lib/cursor/results"; describe("Cursor result extraction", () => { @@ -31,12 +32,28 @@ describe("Cursor result extraction", () => { id: "run-1", status: "finished", result: "Done", + durationMs: 23000, git: { branches: [] }, - }); + } satisfies ExtractableRunPayload); expect(result.rawCursorStatus).toBe("finished"); expect(result.resultSummary).toBe("Done"); - expect(result.resultRawPayload).toMatchObject({ id: "run-1" }); + expect(result.resultRawPayload).toMatchObject({ + id: "run-1", + durationMs: 23000, + }); + }); + + it("does not summarize partial result while run is still active", () => { + const result = extractRunResult({ + id: "run-1", + status: "running", + result: "Partial output so far", + git: { branches: [] }, + } satisfies ExtractableRunPayload); + + expect(result.resultSummary).toBeUndefined(); + expect(result.rawCursorStatus).toBe("running"); }); it("falls back to pull request URLs and Cursor branches in result text", () => { diff --git a/package.json b/package.json index d2f8222..0d3b8b9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test:watch": "vitest", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "setup:dev": "bash scripts/setup-dev-environment.sh" }, "dependencies": { "@auth/prisma-adapter": "^2.11.2", @@ -29,11 +30,13 @@ "lucide-react": "^1.16.0", "next": "16.2.6", "next-auth": "5.0.0-beta.31", + "next-themes": "^0.4.6", "pg": "^8.21.0", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1", "shadcn": "^4.8.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9240458..71f0cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: next-auth: specifier: 5.0.0-beta.31 version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: specifier: ^8.21.0 version: 8.21.0 @@ -75,6 +78,9 @@ importers: shadcn: specifier: ^4.8.0 version: 4.8.0(@types/node@20.19.41)(typescript@5.9.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -3329,6 +3335,12 @@ packages: nodemailer: optional: true + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.2.6: resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} @@ -3963,6 +3975,12 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -7737,6 +7755,11 @@ snapshots: next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.6 @@ -8535,6 +8558,11 @@ snapshots: smart-buffer: 4.2.0 optional: true + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + source-map-js@1.2.1: {} source-map@0.6.1: {} diff --git a/scripts/setup-dev-environment.sh b/scripts/setup-dev-environment.sh new file mode 100755 index 0000000..af6dbeb --- /dev/null +++ b/scripts/setup-dev-environment.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +compose() { + if docker info >/dev/null 2>&1; then + docker compose "$@" + else + sudo docker compose "$@" + fi +} + +upsert_env() { + local key="$1" + local value="$2" + local escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + if grep -q "^${key}=" .env 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=\"${escaped}\"|" .env + else + echo "${key}=\"${escaped}\"" >> .env + fi +} + +if [[ ! -f .env ]]; then + cp .env.example .env + echo "Created .env from .env.example." +fi + +current_auth_secret="$(grep '^AUTH_SECRET=' .env | sed 's/^AUTH_SECRET=//' | tr -d '"' || true)" +if [[ -z "${current_auth_secret}" || "${current_auth_secret}" == replace-with-a-random-secret* ]]; then + upsert_env AUTH_SECRET "$(openssl rand -base64 32)" +fi + +if grep -q '^CRON_SECRET=""' .env 2>/dev/null || ! grep -q '^CRON_SECRET=' .env; then + upsert_env CRON_SECRET "$(openssl rand -hex 32)" +fi + +if ! grep -q '^ALLOW_DEV_AUTH_BYPASS=' .env; then + upsert_env ALLOW_DEV_AUTH_BYPASS "true" +fi + +# Merge Cursor Cloud Environment secrets when injected into the VM. +[[ -n "${DATABASE_URL:-}" ]] && upsert_env DATABASE_URL "${DATABASE_URL}" +[[ -n "${AUTH_SECRET:-}" && "${AUTH_SECRET}" != replace-with-a-random-secret* ]] && upsert_env AUTH_SECRET "${AUTH_SECRET}" +[[ -n "${CURSOR_API_KEY:-}" && "${CURSOR_API_KEY}" != key_xxx ]] && upsert_env CURSOR_API_KEY "${CURSOR_API_KEY}" +[[ -n "${AUTH_GITHUB_ID:-}" && "${AUTH_GITHUB_ID}" != github-oauth-client-id ]] && upsert_env AUTH_GITHUB_ID "${AUTH_GITHUB_ID}" +[[ -n "${AUTH_GITHUB_SECRET:-}" && "${AUTH_GITHUB_SECRET}" != github-oauth-client-secret ]] && upsert_env AUTH_GITHUB_SECRET "${AUTH_GITHUB_SECRET}" +[[ -n "${CRON_SECRET:-}" ]] && upsert_env CRON_SECRET "${CRON_SECRET}" +[[ -n "${ALLOW_DEV_AUTH_BYPASS:-}" ]] && upsert_env ALLOW_DEV_AUTH_BYPASS "${ALLOW_DEV_AUTH_BYPASS}" + +echo "Starting PostgreSQL..." +compose up -d + +echo "Waiting for Postgres..." +for _ in $(seq 1 30); do + if compose exec -T postgres pg_isready -U chloei -d chloei_code >/dev/null 2>&1; then + break + fi + sleep 1 +done + +echo "Installing dependencies..." +pnpm install --frozen-lockfile + +echo "Applying migrations..." +pnpm exec prisma migrate deploy + +echo "" +echo "Dev stack is ready." +echo " Start app: pnpm dev" +echo " Open: http://localhost:3000/runs" +echo " Health: curl -s http://localhost:3000/api/health" diff --git a/src/app/globals.css b/src/app/globals.css index 7e1e579..319d19b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -48,121 +48,116 @@ --radius-4xl: calc(var(--radius) * 2.6); } -:root { - --background: oklch(0.99 0.003 95); - --foreground: oklch(0.17 0.01 95); - --card: oklch(1 0 0); - --card-foreground: oklch(0.17 0.01 95); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.17 0.01 95); - --primary: oklch(0.27 0.05 185); - --primary-foreground: oklch(0.985 0.003 95); - --secondary: oklch(0.965 0.006 95); - --secondary-foreground: oklch(0.22 0.02 95); - --muted: oklch(0.955 0.005 95); - --muted-foreground: oklch(0.49 0.015 95); - --accent: oklch(0.94 0.025 185); - --accent-foreground: oklch(0.22 0.04 185); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.89 0.006 95); - --input: oklch(0.92 0.006 95); - --ring: oklch(0.62 0.08 185); - --chart-1: oklch(0.62 0.12 185); - --chart-2: oklch(0.6 0.16 145); - --chart-3: oklch(0.7 0.16 75); - --chart-4: oklch(0.59 0.18 25); - --chart-5: oklch(0.52 0.11 285); - --radius: 0.5rem; - --sidebar: oklch(0.985 0.003 95); - --sidebar-foreground: oklch(0.17 0.01 95); - --sidebar-primary: oklch(0.27 0.05 185); - --sidebar-primary-foreground: oklch(0.985 0.003 95); - --sidebar-accent: oklch(0.955 0.005 95); - --sidebar-accent-foreground: oklch(0.22 0.02 95); - --sidebar-border: oklch(0.89 0.006 95); - --sidebar-ring: oklch(0.62 0.08 185); - --control-canvas: oklch(0.972 0.006 95); - --status-creating-bg: oklch(0.95 0.03 250); - --status-creating-border: oklch(0.8 0.08 250); - --status-creating-fg: oklch(0.44 0.13 250); - --status-running-bg: oklch(0.94 0.045 185); - --status-running-border: oklch(0.76 0.09 185); - --status-running-fg: oklch(0.36 0.1 185); - --status-finished-bg: oklch(0.94 0.055 145); - --status-finished-border: oklch(0.77 0.11 145); - --status-finished-fg: oklch(0.36 0.11 145); - --status-error-bg: oklch(0.96 0.04 25); - --status-error-border: oklch(0.78 0.13 25); - --status-error-fg: oklch(0.48 0.17 25); - --status-cancelled-bg: oklch(0.94 0.006 95); - --status-cancelled-border: oklch(0.78 0.01 95); - --status-cancelled-fg: oklch(0.43 0.01 95); - --status-expired-bg: oklch(0.96 0.06 80); - --status-expired-border: oklch(0.78 0.14 80); - --status-expired-fg: oklch(0.48 0.13 80); -} - +/* Cursor Cloud — flat dark, minimal chrome */ +:root, .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); - --control-canvas: oklch(0.17 0.01 95); - --status-creating-bg: oklch(0.26 0.045 250); - --status-creating-border: oklch(0.42 0.09 250); - --status-creating-fg: oklch(0.78 0.08 250); - --status-running-bg: oklch(0.24 0.04 185); - --status-running-border: oklch(0.43 0.08 185); - --status-running-fg: oklch(0.79 0.09 185); - --status-finished-bg: oklch(0.23 0.04 145); - --status-finished-border: oklch(0.42 0.08 145); - --status-finished-fg: oklch(0.78 0.1 145); - --status-error-bg: oklch(0.25 0.05 25); - --status-error-border: oklch(0.48 0.12 25); - --status-error-fg: oklch(0.82 0.12 25); - --status-cancelled-bg: oklch(0.26 0.006 95); - --status-cancelled-border: oklch(0.42 0.01 95); - --status-cancelled-fg: oklch(0.75 0.01 95); - --status-expired-bg: oklch(0.27 0.05 80); - --status-expired-border: oklch(0.5 0.1 80); - --status-expired-fg: oklch(0.82 0.12 80); + --background: oklch(0.12 0.003 285); + --foreground: oklch(0.92 0.003 285); + --card: oklch(0.14 0.004 285); + --card-foreground: oklch(0.92 0.003 285); + --popover: oklch(0.15 0.004 285); + --popover-foreground: oklch(0.92 0.003 285); + --primary: oklch(0.96 0.002 285); + --primary-foreground: oklch(0.12 0.004 285); + --secondary: oklch(0.18 0.006 285); + --secondary-foreground: oklch(0.88 0.004 285); + --muted: oklch(0.17 0.005 285); + --muted-foreground: oklch(0.58 0.01 285); + --accent: oklch(0.19 0.008 285); + --accent-foreground: oklch(0.94 0.003 285); + --destructive: oklch(0.62 0.18 25); + --border: oklch(1 0 0 / 7%); + --input: oklch(1 0 0 / 9%); + --ring: oklch(0.55 0.02 285); + --chart-1: oklch(0.72 0.05 285); + --chart-2: oklch(0.65 0.08 160); + --chart-3: oklch(0.68 0.1 85); + --chart-4: oklch(0.62 0.14 25); + --chart-5: oklch(0.58 0.08 300); + --radius: 0.375rem; + --sidebar: oklch(0.105 0.004 285); + --sidebar-foreground: oklch(0.86 0.005 285); + --sidebar-primary: oklch(0.96 0.002 285); + --sidebar-primary-foreground: oklch(0.12 0.004 285); + --sidebar-accent: oklch(0.17 0.008 285); + --sidebar-accent-foreground: oklch(0.94 0.003 285); + --sidebar-border: oklch(1 0 0 / 6%); + --sidebar-ring: oklch(0.5 0.02 285); + --control-canvas: oklch(0.12 0.003 285); + --status-creating-bg: oklch(0.2 0.03 275); + --status-creating-border: oklch(0.35 0.06 275); + --status-creating-fg: oklch(0.72 0.07 275); + --status-running-bg: oklch(0.19 0.025 285); + --status-running-border: oklch(0.32 0.05 285); + --status-running-fg: oklch(0.82 0.03 285); + --status-finished-bg: oklch(0.18 0.03 155); + --status-finished-border: oklch(0.32 0.06 155); + --status-finished-fg: oklch(0.72 0.08 155); + --status-error-bg: oklch(0.2 0.04 25); + --status-error-border: oklch(0.38 0.1 25); + --status-error-fg: oklch(0.78 0.1 25); + --status-cancelled-bg: oklch(0.17 0.005 285); + --status-cancelled-border: oklch(0.28 0.01 285); + --status-cancelled-fg: oklch(0.62 0.01 285); + --status-expired-bg: oklch(0.19 0.04 75); + --status-expired-border: oklch(0.34 0.08 75); + --status-expired-fg: oklch(0.76 0.09 75); } @layer base { * { @apply border-border outline-ring/50; + scrollbar-width: thin; + scrollbar-color: oklch(0.28 0.01 285) transparent; } - body { - @apply bg-background text-foreground; + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + *::-webkit-scrollbar-thumb { + background: oklch(0.26 0.01 285); + border-radius: 999px; + } + ::selection { + background: oklch(0.32 0.03 285); + color: oklch(0.98 0 0); } html { @apply font-sans; + color-scheme: dark; + } + body { + @apply bg-background text-foreground antialiased; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +@layer utilities { + .cursor-panel { + @apply rounded-md border border-border/60 bg-card/40; + } + .cursor-sidebar-item { + @apply flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors; + } + .cursor-sidebar-item-active { + @apply bg-sidebar-accent text-foreground; + } + .cursor-sidebar-item-inactive { + @apply text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground; + } + .cursor-content { + @apply mx-auto w-full max-w-3xl; + } + .cursor-content-wide { + @apply mx-auto w-full max-w-4xl; + } + .cursor-field { + @apply rounded-md border border-border/60 bg-input/30 shadow-none focus-visible:border-border focus-visible:bg-input/45 focus-visible:ring-1 focus-visible:ring-ring/40; + } + .cursor-composer { + @apply rounded-lg border border-border/60 bg-card/30 focus-within:border-border focus-within:bg-card/50; + } + .cursor-divider { + @apply border-border/50; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d5e84b8..0722dab 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; -import { TooltipProvider } from "@/components/ui/tooltip"; + +import { AppProviders } from "@/components/providers/app-providers"; import "./globals.css"; const geistSans = Geist({ @@ -28,10 +29,11 @@ export default function RootLayout({ return ( - {children} + {children} diff --git a/src/app/runs/[id]/page.tsx b/src/app/runs/[id]/page.tsx index d599e93..32e4503 100644 --- a/src/app/runs/[id]/page.tsx +++ b/src/app/runs/[id]/page.tsx @@ -46,11 +46,11 @@ export default async function RunDetailPage({ : { pullRequest: null, error: null }; return ( - -
+ +
-
-
+
+
-
-
+
); } diff --git a/src/app/runs/new/page.tsx b/src/app/runs/new/page.tsx index fc73903..5e8d24b 100644 --- a/src/app/runs/new/page.tsx +++ b/src/app/runs/new/page.tsx @@ -16,17 +16,8 @@ export default async function NewRunPage() { const runLimits = await getRunCreationLimits(user.id); return ( - -
-
-

New cloud run

-

- Send a repository-scoped coding task to Cursor cloud runtime. The - Cursor API key stays server-side. -

-
- -
+ + ); } diff --git a/src/app/runs/page.tsx b/src/app/runs/page.tsx index 9165fd2..a4db117 100644 --- a/src/app/runs/page.tsx +++ b/src/app/runs/page.tsx @@ -1,9 +1,10 @@ import { AgentRunFilters } from "@/components/agent-runs/agent-run-filters"; import { AgentRunsTable } from "@/components/agent-runs/agent-runs-table"; +import { AgentsHome } from "@/components/agent-runs/agents-home"; import { AppShell } from "@/components/agent-runs/app-shell"; -import { NewAgentRunButton } from "@/components/agent-runs/new-agent-run-button"; import { RefreshButton } from "@/components/agent-runs/refresh-button"; import { SignInPanel } from "@/components/auth/sign-in-panel"; +import { PageHeader } from "@/components/ui/page-header"; import { listRunsForUser } from "@/lib/agent-runs/repository"; import { runStatusFilters } from "@/lib/agent-runs/types"; import { getCurrentUser } from "@/lib/auth"; @@ -23,27 +24,24 @@ export default async function RunsPage({ const { status } = await searchParams; const activeStatus = runStatusFilters.find((item) => item === status); - const runs = await listRunsForUser(user.id, activeStatus); + const allRuns = await listRunsForUser(user.id); + const runs = activeStatus + ? allRuns.filter((run) => run.normalizedStatus === activeStatus) + : allRuns; + + if (!activeStatus) { + return ( + + + + ); + } return ( -
-
-
-

Agent runs

-

- Create, monitor, cancel, retry, and review Cursor cloud coding - runs across connected GitHub repositories. -

-
-
- - -
-
- - -
+ } /> + +
); } diff --git a/src/app/status/page.tsx b/src/app/status/page.tsx index d5887a6..f337e59 100644 --- a/src/app/status/page.tsx +++ b/src/app/status/page.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import { ActivityIcon, CheckCircle2Icon, @@ -8,7 +10,7 @@ import { import { AppShell } from "@/components/agent-runs/app-shell"; import { SignInPanel } from "@/components/auth/sign-in-panel"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PageHeader } from "@/components/ui/page-header"; import { getRunCreationLimits } from "@/lib/agent-runs/limits"; import { getRunHealthStats, @@ -38,14 +40,8 @@ export default async function StatusPage() { return ( -
-
-

Production status

-

- Runtime checks for the control plane, external APIs, and active - Cursor runs. -

-
+
+
@@ -69,118 +65,128 @@ export default async function StatusPage() {
- - - Runtime configuration - - -
- - - 0 - ? `${env.ALLOWED_GITHUB_ORGS.length} configured` - : "Any repository you can access" - } - /> - - - -
-
-
- - - - Refresh activity - - -
- - - - -
-
-
+ +
+ + + 0 + ? `${env.ALLOWED_GITHUB_ORGS.length} configured` + : "Any repository you can access" + } + /> + + + +
+
+ + +
+ + + + +
+
- - - - + + Service checks - - - - - {health.checks.map((check) => ( - - ))} - - -
+ + } + action={} + bodyClassName="divide-y divide-border/50 p-0" + > + {health.checks.map((check) => ( + + ))} + +
); } +function StatusPanel({ + title, + action, + children, + bodyClassName = "p-4", +}: { + title: ReactNode; + action?: ReactNode; + children: ReactNode; + bodyClassName?: string; +}) { + return ( +
+
+

{title}

+ {action} +
+
{children}
+
+ ); +} + function Metric({ label, value }: { label: string; value: number | string }) { return ( - - -

{label}

-

{value}

-
-
+
+

{label}

+

{value}

+
); } function ConfigMetric({ label, value }: { label: string; value: string }) { return (
-
{label}
-
{value}
+
{label}
+
{value}
); } @@ -194,10 +200,12 @@ function HealthRow({ check }: { check: HealthCheck }) { : CircleAlertIcon; return ( -
+
- - {check.name.replaceAll("_", " ")} + + + {check.name.replaceAll("_", " ")} +

{check.message}

diff --git a/src/components/agent-runs/agent-run-actions.tsx b/src/components/agent-runs/agent-run-actions.tsx index dfe18c0..26f3f90 100644 --- a/src/components/agent-runs/agent-run-actions.tsx +++ b/src/components/agent-runs/agent-run-actions.tsx @@ -59,7 +59,8 @@ export function AgentRunActions({
{prUrl ? ( {linkedPrUrl ? ( ) : null} - - - +
+ } + > +
{error ? ( @@ -240,8 +241,8 @@ export function AgentRunPullRequestPanel({
)} - - +
+ ); } diff --git a/src/components/agent-runs/agent-run-result-panel.tsx b/src/components/agent-runs/agent-run-result-panel.tsx index d78ea64..27dadf8 100644 --- a/src/components/agent-runs/agent-run-result-panel.tsx +++ b/src/components/agent-runs/agent-run-result-panel.tsx @@ -1,67 +1,63 @@ import type { AgentRun } from "@prisma/client"; +import { DetailSection } from "@/components/agent-runs/detail-section"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export function AgentRunResultPanel({ run }: { run: AgentRun }) { return ( - - - Result - - + +
{run.errorMessage ? ( - - Error details + + Error {run.errorMessage} ) : null} -
-
-
- Branch -
-
{run.branchName ?? "Not available"}
-
-
-
- Pull request -
-
- {run.prUrl ? ( - - {run.prUrl} - - ) : ( - "Not available" - )} -
-
-
+ {(run.branchName || run.prUrl) && ( +
+ {run.branchName ? ( +
+
+ Branch +
+
{run.branchName}
+
+ ) : null} + {run.prUrl ? ( +
+
+ Pull request +
+
+ + Open PR + +
+
+ ) : null} +
+ )}
-

- Summary -

-

- {run.resultSummary ?? "No final result has been stored yet."} +

+ {run.resultSummary ?? "No result yet."}

{run.resultRawPayload ? ( -
- - Raw result JSON +
+ + Raw JSON -
+            
               {JSON.stringify(run.resultRawPayload, null, 2)}
             
) : null} - - +
+
); } diff --git a/src/components/agent-runs/agent-run-status-badge.tsx b/src/components/agent-runs/agent-run-status-badge.tsx index fdf6acd..177d440 100644 --- a/src/components/agent-runs/agent-run-status-badge.tsx +++ b/src/components/agent-runs/agent-run-status-badge.tsx @@ -7,14 +7,50 @@ import { import { runStatusLabels } from "@/lib/agent-runs/types"; const statusClassName: Record = { - creating: "border-[var(--status-creating-border)] bg-[var(--status-creating-bg)] text-[var(--status-creating-fg)]", - running: "border-[var(--status-running-border)] bg-[var(--status-running-bg)] text-[var(--status-running-fg)]", - finished: "border-[var(--status-finished-border)] bg-[var(--status-finished-bg)] text-[var(--status-finished-fg)]", - error: "border-[var(--status-error-border)] bg-[var(--status-error-bg)] text-[var(--status-error-fg)]", - cancelled: "border-[var(--status-cancelled-border)] bg-[var(--status-cancelled-bg)] text-[var(--status-cancelled-fg)]", - expired: "border-[var(--status-expired-border)] bg-[var(--status-expired-bg)] text-[var(--status-expired-fg)]", + creating: + "border-[var(--status-creating-border)] bg-[var(--status-creating-bg)] text-[var(--status-creating-fg)]", + running: + "border-[var(--status-running-border)] bg-[var(--status-running-bg)] text-[var(--status-running-fg)]", + finished: + "border-[var(--status-finished-border)] bg-[var(--status-finished-bg)] text-[var(--status-finished-fg)]", + error: + "border-[var(--status-error-border)] bg-[var(--status-error-bg)] text-[var(--status-error-fg)]", + cancelled: + "border-[var(--status-cancelled-border)] bg-[var(--status-cancelled-bg)] text-[var(--status-cancelled-fg)]", + expired: + "border-[var(--status-expired-border)] bg-[var(--status-expired-bg)] text-[var(--status-expired-fg)]", }; +const dotClassName: Record = { + creating: "bg-[var(--status-creating-fg)]", + running: "bg-[var(--status-running-fg)] animate-pulse", + finished: "bg-[var(--status-finished-fg)]", + error: "bg-[var(--status-error-fg)]", + cancelled: "bg-[var(--status-cancelled-fg)]", + expired: "bg-[var(--status-expired-fg)]", +}; + +export function AgentStatusDot({ + status, + className, +}: { + status: string; + className?: string; +}) { + const normalized = normalizeCursorStatus(status); + + return ( + + ); +} + export function AgentRunStatusBadge({ status, className, @@ -27,7 +63,12 @@ export function AgentRunStatusBadge({ return ( {runStatusLabels[normalized]} @@ -36,13 +77,5 @@ export function AgentRunStatusBadge({ export function statusRailClassName(status: string) { const normalized = normalizeCursorStatus(status); - - return { - creating: "bg-[var(--status-creating-fg)]", - running: "bg-[var(--status-running-fg)]", - finished: "bg-[var(--status-finished-fg)]", - error: "bg-[var(--status-error-fg)]", - cancelled: "bg-[var(--status-cancelled-fg)]", - expired: "bg-[var(--status-expired-fg)]", - }[normalized]; + return dotClassName[normalized]; } diff --git a/src/components/agent-runs/agent-runs-table.tsx b/src/components/agent-runs/agent-runs-table.tsx index 5dd2737..24769e2 100644 --- a/src/components/agent-runs/agent-runs-table.tsx +++ b/src/components/agent-runs/agent-runs-table.tsx @@ -2,116 +2,83 @@ import Link from "next/link"; import { ExternalLinkIcon } from "lucide-react"; import type { AgentRun } from "@prisma/client"; -import { AgentRunStatusBadge, statusRailClassName } from "@/components/agent-runs/agent-run-status-badge"; -import { Badge } from "@/components/ui/badge"; +import { AgentStatusDot } from "@/components/agent-runs/agent-run-status-badge"; import { Button } from "@/components/ui/button"; -import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { formatDateTime, hostAndRepo } from "@/lib/format"; -import { cn } from "@/lib/utils"; +import { runStatusLabels } from "@/lib/agent-runs/types"; +import type { NormalizedRunStatus } from "@/lib/cursor/status"; +import { formatRelativeTime, hostAndRepo } from "@/lib/format"; export function AgentRunsTable({ runs }: { runs: AgentRun[] }) { if (runs.length === 0) { return ( - - - No runs yet - - Create a Cursor cloud agent run to start tracking work here. - - - - - - +
+

No agents yet.

+ +
); } return ( -
- - - - Task - Repository - Ref - Model - Status - Updated - PR - - - - {runs.map((run) => ( - - - - - {run.taskSummary} - - - Created {formatDateTime(run.createdAt)} +
    + {runs.map((run) => ( +
  • + + +
    +

    + {run.taskSummary} +

    +

    + {hostAndRepo(run.repoUrl)} + · + {run.startingRef} + · + + {formatRelativeTime(run.updatedAt)} - - - {hostAndRepo(run.repoUrl)} - - - {run.startingRef} - - - {run.modelId ?? "Cursor default"} - - -

    - - {run.rawCursorStatus ? ( - - raw: {run.rawCursorStatus} - - ) : null} -
    - - {formatDateTime(run.updatedAt)} - - {run.prUrl ? ( - - ) : ( - None - )} - - - ))} - -
-
+

+
+
+ + {runStatusLabels[run.normalizedStatus as NormalizedRunStatus] ?? run.normalizedStatus} + + {run.prUrl ? ( + + ) : null} +
+ + + ))} + ); } diff --git a/src/components/agent-runs/agents-home.tsx b/src/components/agent-runs/agents-home.tsx new file mode 100644 index 0000000..5e98e4a --- /dev/null +++ b/src/components/agent-runs/agents-home.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { ArrowRightIcon, PlusIcon } from "lucide-react"; +import type { AgentRun } from "@prisma/client"; + +import { Button } from "@/components/ui/button"; +import { formatRelativeTime, hostAndRepo } from "@/lib/format"; + +export function AgentsHome({ runs }: { runs: AgentRun[] }) { + const latest = runs[0]; + + if (!latest) { + return ( +
+

No cloud agents yet.

+ +
+ ); + } + + return ( +
+

+ Select an agent from the sidebar, or continue your latest run. +

+ + + + {latest.taskSummary} + + + {hostAndRepo(latest.repoUrl)} · {formatRelativeTime(latest.updatedAt)} + + + + + +
+ ); +} diff --git a/src/components/agent-runs/app-shell.tsx b/src/components/agent-runs/app-shell.tsx index e02a78d..e3761d5 100644 --- a/src/components/agent-runs/app-shell.tsx +++ b/src/components/agent-runs/app-shell.tsx @@ -1,59 +1,71 @@ import Link from "next/link"; -import { ActivityIcon, BotIcon, PlusIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; +import { SidebarRecentRuns } from "@/components/agent-runs/sidebar-recent-runs"; +import { SidebarStatusLink } from "@/components/agent-runs/sidebar-status-link"; +import { UserMenu } from "@/components/agent-runs/user-menu"; +import { SignOutMenuItem } from "@/components/auth/sign-out-menu-item"; +import { CursorBrandMark } from "@/components/brand/cursor-brand-mark"; +import { Button } from "@/components/ui/button"; +import { listRunsForUser } from "@/lib/agent-runs/repository"; import type { CurrentUser } from "@/lib/auth"; -import { SignOutButton } from "@/components/auth/auth-buttons"; -import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; -export function AppShell({ +export async function AppShell({ user, children, + contentClassName, }: { user: CurrentUser; children: React.ReactNode; + contentClassName?: string; }) { + const sidebarRuns = (await listRunsForUser(user.id)).slice(0, 50); + return ( -
-
-
- - - - - - - Chloei Code - - - Cursor run control plane - - +
+
-
- {children} -
+ +
+ +
+ + + +
+ + } /> +
+ + +
+
+ {children} +
+
); } diff --git a/src/components/agent-runs/detail-section.tsx b/src/components/agent-runs/detail-section.tsx new file mode 100644 index 0000000..03751a5 --- /dev/null +++ b/src/components/agent-runs/detail-section.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/lib/utils"; + +export function DetailSection({ + title, + action, + children, + className, +}: { + title: string; + action?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} diff --git a/src/components/agent-runs/new-agent-run-form.tsx b/src/components/agent-runs/new-agent-run-form.tsx index ea88af2..2401ad8 100644 --- a/src/components/agent-runs/new-agent-run-form.tsx +++ b/src/components/agent-runs/new-agent-run-form.tsx @@ -32,6 +32,7 @@ import { type RepositoryCatalogItem, } from "@/lib/repositories/catalog"; import type { RunCreationLimits } from "@/lib/agent-runs/limit-policy"; +import { cn } from "@/lib/utils"; type CursorModel = { id: string; @@ -68,9 +69,12 @@ type ApiErrorPayload = { export function NewAgentRunForm({ runLimits, + layout = "default", }: { runLimits: RunCreationLimits; + layout?: "default" | "composer"; }) { + const isComposer = layout === "composer"; const router = useRouter(); const [repoUrl, setRepoUrl] = useState(""); const [startingRef, setStartingRef] = useState("main"); @@ -282,105 +286,113 @@ export function NewAgentRunForm({ } } + const optionsGrid = ( +
+ + + +
+ +
+
+ ); + return ( -
-
+ +
- - - +
- + {isComposer ? ( +
+ + + Repository, branch & model + + +
+ {optionsGrid} + +
+
+ ) : ( + <> + {optionsGrid} + + + )} + + {catalogError ? ( + + + Cursor catalog unavailable + {catalogError} + + ) : null} + + {submitError ? ( + + + Run creation failed + {submitError} + + ) : null} + + {!runLimits.canCreateRun ? ( + + + Run creation paused + {runLimits.reasons.join(" ")} + + ) : null} + +
+ + +
); } -function RunSafetyPanel({ runLimits }: { runLimits: RunCreationLimits }) { +function RunSafetyPanel({ runLimits, compact = false }: { runLimits: RunCreationLimits; compact?: boolean }) { const items = [ { label: "Active runs", @@ -405,9 +417,9 @@ function RunSafetyPanel({ runLimits }: { runLimits: RunCreationLimits }) { ]; return ( -
-
-

Run safety

+
+
+

Limits

{runLimits.canCreateRun ? "Available" : "Limit reached"} @@ -604,13 +616,15 @@ export function PromptTextarea({ }) { return ( - Task prompt + + Prompt +