From c8cea6a2e73e4259c85bcee71e84a42880980070 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 18:03:32 +0000 Subject: [PATCH 01/14] docs: add Cursor Cloud development environment instructions Co-authored-by: Chloei --- AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..17d506f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,37 @@ 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 + +### Services + +| Service | Purpose | Start | +|---------|---------|--------| +| PostgreSQL 16 | Prisma datastore | `sudo docker compose up -d` (host port **55432**) | +| Next.js dev server | App + API | `pnpm dev` → http://localhost:3000 | + +CI runs lint/typecheck/test/build **without** Postgres; local E2E needs the database. + +### First-time setup (after `pnpm install`) + +1. `cp .env.example .env` and set at least `AUTH_SECRET` (e.g. `openssl rand -base64 32`). +2. For local UI without GitHub OAuth: `ALLOW_DEV_AUTH_BYPASS="true"` (non-production only). +3. `CRON_SECRET` must be **omitted or non-empty** — `CRON_SECRET=""` fails Zod validation and breaks `/api/health`. +4. `pnpm exec prisma migrate deploy` (non-interactive) or `pnpm db:migrate` (interactive). +5. Start Postgres before migrations. + +### Commands (from `package.json`) + +- Lint: `pnpm lint` +- Types: `pnpm typecheck` +- Tests: `pnpm test` (mocked DB/SDK; no Docker required) +- Build: `pnpm build` +- Dev: `pnpm dev` + +### Gotchas + +- **Docker**: In this VM, `docker` often requires `sudo` (socket permissions). Prefer `sudo docker compose up -d`. +- **Node**: CI uses Node 24; Node 22+ works for local dev. +- **Cursor / GitHub APIs**: Placeholder `CURSOR_API_KEY` and no GitHub OAuth token are fine for UI and DB flows; `/api/health/deep` will report `cursor_api` / `github_api` errors until real credentials are configured. +- **Next.js docs**: See `node_modules/next/dist/docs/` for this repo’s Next.js 16 APIs (differs from older training data). From 9c08c22bfe77d5f0f0dbe6908101a42042fa6ff9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 18:09:01 +0000 Subject: [PATCH 02/14] chore: add pnpm setup:dev bootstrap for local cloud development Co-authored-by: Chloei --- package.json | 3 +- scripts/setup-dev-environment.sh | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100755 scripts/setup-dev-environment.sh diff --git a/package.json b/package.json index d2f8222..5025b98 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", 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" From 65ba6c5d8a739bf07a492cc9ea560b9f501ce9a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 18:09:12 +0000 Subject: [PATCH 03/14] docs: mention pnpm setup:dev in AGENTS.md Co-authored-by: Chloei --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 17d506f..657093d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,9 @@ CI runs lint/typecheck/test/build **without** Postgres; local E2E needs the data ### First-time setup (after `pnpm install`) +One-shot bootstrap: `pnpm setup:dev` (Postgres, deps, migrations, `.env` secrets merge). + + 1. `cp .env.example .env` and set at least `AUTH_SECRET` (e.g. `openssl rand -base64 32`). 2. For local UI without GitHub OAuth: `ALLOW_DEV_AUTH_BYPASS="true"` (non-production only). 3. `CRON_SECRET` must be **omitted or non-empty** — `CRON_SECRET=""` fails Zod validation and breaks `/api/health`. @@ -33,7 +36,7 @@ CI runs lint/typecheck/test/build **without** Postgres; local E2E needs the data ### Gotchas -- **Docker**: In this VM, `docker` often requires `sudo` (socket permissions). Prefer `sudo docker compose up -d`. +- **Docker**: In this VM, `docker` often requires `sudo` (socket permissions). Use `docker compose up -d` (user in `docker` group) or `sudo` if needed. - **Node**: CI uses Node 24; Node 22+ works for local dev. - **Cursor / GitHub APIs**: Placeholder `CURSOR_API_KEY` and no GitHub OAuth token are fine for UI and DB flows; `/api/health/deep` will report `cursor_api` / `github_api` errors until real credentials are configured. - **Next.js docs**: See `node_modules/next/dist/docs/` for this repo’s Next.js 16 APIs (differs from older training data). From 5e9bbbfca7955a322b73c1874b1124748f228051 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 18:52:00 +0000 Subject: [PATCH 04/14] fix(cursor): align event and result helpers with @cursor/sdk types Co-authored-by: Chloei --- __tests__/cursor/events.test.ts | 138 +++++++++++++++++++++++++++++++ __tests__/cursor/results.test.ts | 21 ++++- src/lib/cursor/events.ts | 136 +++++++++++++++++++++++------- src/lib/cursor/results.ts | 58 +++++++++---- 4 files changed, 307 insertions(+), 46 deletions(-) create mode 100644 __tests__/cursor/events.test.ts 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/src/lib/cursor/events.ts b/src/lib/cursor/events.ts index aa5dc70..e494984 100644 --- a/src/lib/cursor/events.ts +++ b/src/lib/cursor/events.ts @@ -1,46 +1,122 @@ -import type { SDKMessage } from "@cursor/sdk"; +import type { + SDKAssistantMessage, + SDKMessage, + SDKStatusMessage, + SDKTaskMessage, + SDKThinkingMessage, + SDKToolUseMessage, + SDKUserMessageEvent, + TextBlock, +} from "@cursor/sdk"; -export function getCursorEventType(event: SDKMessage | Record) { - return typeof event.type === "string" ? event.type : "unknown"; +export function getCursorEventType( + event: SDKMessage | Record +): string { + if (typeof event.type === "string") { + return event.type; + } + + return "unknown"; } +/** + * Human-readable line for the event log. Aligns with SDKMessage variants from + * @see https://cursor.com/docs/sdk/typescript#stream-events + */ export function getCursorEventMessage( event: SDKMessage | Record -) { - if ("message" in event && typeof event.message === "string") { - return event.message; +): string | undefined { + if (!isSdkMessageShape(event)) { + return undefined; } - if ("text" in event && typeof event.text === "string") { - return event.text; + switch (event.type) { + case "assistant": + return extractTextFromContent(event.message.content); + case "thinking": + return formatThinkingMessage(event); + case "tool_call": + return formatToolCallMessage(event); + case "status": + return formatStatusMessage(event); + case "task": + return formatTaskMessage(event); + case "user": + return extractTextFromContent(event.message.content); + case "system": + return event.subtype === "init" + ? "Run initialized" + : "System event"; + case "request": + return "Awaiting user input or approval"; + default: + return undefined; } +} - if ( - event.type === "assistant" && - "message" in event && - typeof event.message === "object" && - event.message !== null && - "content" in event.message && - Array.isArray(event.message.content) - ) { - return event.message.content - .filter((block) => block?.type === "text" && typeof block.text === "string") - .map((block) => block.text) - .join("\n") - .trim(); +function isSdkMessageShape( + event: SDKMessage | Record +): event is SDKMessage { + return typeof event === "object" && event !== null && typeof event.type === "string"; +} + +function extractTextFromContent( + content: Array | undefined +) { + if (!content?.length) { + return undefined; } - if (event.type === "tool_call" && "name" in event) { - const status = - "status" in event && typeof event.status === "string" - ? ` ${event.status}` - : ""; - return `Tool ${String(event.name)}${status}`; + const text = content + .filter((block): block is TextBlock => block?.type === "text") + .map((block) => block.text) + .join("\n") + .trim(); + + return text || undefined; +} + +function formatThinkingMessage(event: SDKThinkingMessage) { + const text = event.text.trim(); + if (!text) { + return undefined; } - if (event.type === "status" && "status" in event) { - return String(event.status); + if (event.thinking_duration_ms != null) { + return `${text} (${event.thinking_duration_ms}ms)`; } - return undefined; + return text; +} + +function formatToolCallMessage(event: SDKToolUseMessage) { + const truncated = + event.truncated?.args || event.truncated?.result ? " (truncated)" : ""; + return `Tool ${event.name} ${event.status}${truncated}`.trim(); +} + +function formatStatusMessage(event: SDKStatusMessage) { + if (event.message?.trim()) { + return `${event.status}: ${event.message.trim()}`; + } + + return event.status; +} + +function formatTaskMessage(event: SDKTaskMessage) { + const parts = [event.status, event.text].filter( + (part): part is string => typeof part === "string" && part.trim().length > 0 + ); + + return parts.length > 0 ? parts.join(": ") : undefined; +} + +/** @internal Exported for tests */ +export function getAssistantMessageText(event: SDKAssistantMessage) { + return extractTextFromContent(event.message.content); +} + +/** @internal Exported for tests */ +export function getUserMessageText(event: SDKUserMessageEvent) { + return extractTextFromContent(event.message.content); } diff --git a/src/lib/cursor/results.ts b/src/lib/cursor/results.ts index 181583e..4412fcf 100644 --- a/src/lib/cursor/results.ts +++ b/src/lib/cursor/results.ts @@ -1,4 +1,6 @@ -import type { Run, RunResult, SDKArtifact } from "@cursor/sdk"; +import type { Run, RunResult, RunStatus, RunResultStatus, SDKArtifact } from "@cursor/sdk"; + +import { isTerminalStatus } from "@/lib/cursor/status"; export type ExtractedGitMetadata = { prUrl?: string; @@ -15,32 +17,60 @@ const githubPullRequestUrlPattern = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/i; const cursorBranchPattern = /\bcursor\/[A-Za-z0-9._/-]+/i; +/** + * Prefer structured git metadata from RunResult / Run (SDK RunGitInfo). + * @see https://cursor.com/docs/sdk/typescript#waiting-without-streaming + */ export function extractGitResultMetadata( result: Pick | Pick ): ExtractedGitMetadata { const firstBranch = result.git?.branches?.[0]; + if (firstBranch?.prUrl || firstBranch?.branch) { + return { + prUrl: firstBranch.prUrl, + branchName: firstBranch.branch, + }; + } + return { - prUrl: firstBranch?.prUrl ?? findFirstStringMatch(result, (value) => { + prUrl: findFirstStringMatch(result, (value) => { return value.match(githubPullRequestUrlPattern)?.[0]; }), - branchName: - firstBranch?.branch ?? - findFirstStringMatch(result, (value, key) => { - const directBranch = - key && ["branch", "branchName", "headRef"].includes(key) - ? sanitizeCursorBranch(value) - : undefined; - - return directBranch ?? sanitizeCursorBranch(value); - }), + branchName: findFirstStringMatch(result, (value, key) => { + const directBranch = + key && ["branch", "branchName", "headRef"].includes(key) + ? sanitizeCursorBranch(value) + : undefined; + + return directBranch ?? sanitizeCursorBranch(value); + }), }; } -export function extractRunResult(result: RunResult | Run): ExtractedRunResult { +/** + * Maps SDK Run / RunResult into persisted fields. Final assistant text lives on + * `result` (string) after the run completes — not in stream events alone. + */ +export type ExtractableRunPayload = Pick< + RunResult, + "result" | "git" | "durationMs" +> & { + id?: string; + status: RunStatus | RunResultStatus; +}; + +export function extractRunResult( + result: ExtractableRunPayload | RunResult | Run +): ExtractedRunResult { + const shouldSummarize = + Boolean(result.result) && isTerminalStatus(result.status); + return { ...extractGitResultMetadata(result), - resultSummary: result.result ? summarizeResult(result.result) : undefined, + resultSummary: shouldSummarize + ? summarizeResult(result.result!) + : undefined, resultRawPayload: result, rawCursorStatus: result.status, }; From 0268c5f6b3e7c49fd419bc7088f8483668e43776 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 19:02:59 +0000 Subject: [PATCH 05/14] feat(ui): improve UX with shadcn navigation, toasts, and run flows Co-authored-by: Chloei --- package.json | 2 + pnpm-lock.yaml | 28 +++ src/app/layout.tsx | 6 +- src/app/runs/new/page.tsx | 16 +- src/app/runs/page.tsx | 26 ++- src/app/status/page.tsx | 52 ++++-- .../agent-runs/agent-run-actions.tsx | 105 ++++++----- .../agent-runs/agent-run-event-log.tsx | 105 +++++++---- .../agent-runs/agent-run-filters.tsx | 50 +++--- .../agent-runs/agent-run-header.tsx | 6 +- .../agent-runs/agent-runs-table.tsx | 11 +- src/components/agent-runs/app-nav-links.tsx | 47 +++++ src/components/agent-runs/app-shell.tsx | 57 +++--- .../agent-runs/new-agent-run-form.tsx | 84 ++++++--- src/components/agent-runs/refresh-button.tsx | 9 +- src/components/agent-runs/run-breadcrumbs.tsx | 48 +++++ src/components/agent-runs/user-menu.tsx | 70 ++++++++ src/components/auth/auth-buttons.tsx | 4 +- src/components/auth/sign-in-panel.tsx | 44 +++-- src/components/auth/sign-out-menu-item.tsx | 23 +++ src/components/providers/app-providers.tsx | 15 ++ src/components/ui/avatar.tsx | 109 ++++++++++++ src/components/ui/breadcrumb.tsx | 125 +++++++++++++ src/components/ui/collapsible.tsx | 21 +++ src/components/ui/navigation-menu.tsx | 168 ++++++++++++++++++ src/components/ui/page-header.tsx | 36 ++++ src/components/ui/progress.tsx | 83 +++++++++ src/components/ui/scroll-area.tsx | 55 ++++++ src/components/ui/sonner.tsx | 49 +++++ 29 files changed, 1233 insertions(+), 221 deletions(-) create mode 100644 src/components/agent-runs/app-nav-links.tsx create mode 100644 src/components/agent-runs/run-breadcrumbs.tsx create mode 100644 src/components/agent-runs/user-menu.tsx create mode 100644 src/components/auth/sign-out-menu-item.tsx create mode 100644 src/components/providers/app-providers.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/navigation-menu.tsx create mode 100644 src/components/ui/page-header.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/sonner.tsx diff --git a/package.json b/package.json index 5025b98..0d3b8b9 100644 --- a/package.json +++ b/package.json @@ -30,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/src/app/layout.tsx b/src/app/layout.tsx index d5e84b8..d97667e 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({ @@ -29,9 +30,10 @@ export default function RootLayout({ - {children} + {children} diff --git a/src/app/runs/new/page.tsx b/src/app/runs/new/page.tsx index fc73903..a6153d6 100644 --- a/src/app/runs/new/page.tsx +++ b/src/app/runs/new/page.tsx @@ -1,6 +1,8 @@ import { AppShell } from "@/components/agent-runs/app-shell"; import { NewAgentRunForm } from "@/components/agent-runs/new-agent-run-form"; +import { NewRunBreadcrumbs } from "@/components/agent-runs/run-breadcrumbs"; import { SignInPanel } from "@/components/auth/sign-in-panel"; +import { PageHeader } from "@/components/ui/page-header"; import { getRunCreationLimits } from "@/lib/agent-runs/limits"; import { getCurrentUser } from "@/lib/auth"; @@ -17,13 +19,13 @@ export default async function NewRunPage() { 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..3b8a009 100644 --- a/src/app/runs/page.tsx +++ b/src/app/runs/page.tsx @@ -4,6 +4,7 @@ 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"; @@ -27,20 +28,17 @@ export default async function RunsPage({ 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..e2b0a09 100644 --- a/src/app/status/page.tsx +++ b/src/app/status/page.tsx @@ -9,6 +9,8 @@ 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 { Progress, ProgressTrack } from "@/components/ui/progress"; import { getRunCreationLimits } from "@/lib/agent-runs/limits"; import { getRunHealthStats, @@ -39,13 +41,10 @@ export default async function StatusPage() { return (
-
-

Production status

-

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

-
+
@@ -58,6 +57,10 @@ export default async function StatusPage() { runLimits.activeRuns, runLimits.activeLimit )} + percent={limitPercent( + runLimits.activeRuns, + runLimits.activeLimit + )} />
@@ -165,17 +172,40 @@ export default async function StatusPage() { ); } -function Metric({ label, value }: { label: string; value: number | string }) { +function Metric({ + label, + value, + percent, +}: { + label: string; + value: number | string; + percent?: number | null; +}) { return ( - -

{label}

-

{value}

+ +
+

{label}

+

{value}

+
+ {percent != null ? ( + + + + ) : null}
); } +function limitPercent(count: number, limit: number | null) { + if (limit == null || limit <= 0) { + return null; + } + + return Math.min(100, Math.round((count / limit) * 100)); +} + function ConfigMetric({ label, value }: { label: string; value: string }) { return (
diff --git a/src/components/agent-runs/agent-run-actions.tsx b/src/components/agent-runs/agent-run-actions.tsx index dfe18c0..d788f14 100644 --- a/src/components/agent-runs/agent-run-actions.tsx +++ b/src/components/agent-runs/agent-run-actions.tsx @@ -9,6 +9,7 @@ import { RotateCcwIcon, SquareIcon, } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { isTerminalStatus } from "@/lib/cursor/status"; @@ -26,12 +27,10 @@ export function AgentRunActions({ }) { const router = useRouter(); const [isPending, setIsPending] = useState(null); - const [error, setError] = useState(null); const terminal = isTerminalStatus(status); async function mutate(action: "refresh" | "cancel" | "retry") { setIsPending(action); - setError(null); try { const response = await fetch(`/api/agent-runs/${runId}/${action}`, { @@ -43,66 +42,76 @@ export function AgentRunActions({ throw new Error(payload.error ?? `Unable to ${action} run.`); } - if (action === "retry") { + if (action === "refresh") { + toast.success("Run refreshed"); + } else if (action === "cancel") { + toast.success("Cancellation requested"); + } else if (action === "retry") { + toast.success("Retry started"); router.push(`/runs/${payload.run.id}`); } router.refresh(); } catch (error) { - setError(error instanceof Error ? error.message : "Action failed."); + toast.error( + error instanceof Error ? error.message : "Action failed." + ); } finally { setIsPending(null); } } + async function copyPrompt() { + try { + await navigator.clipboard.writeText(prompt); + toast.success("Prompt copied"); + } catch { + toast.error("Unable to copy prompt"); + } + } + return ( -
-
- - - +
+ + + + + {prUrl ? ( - {prUrl ? ( - - ) : null} -
- {error ?

{error}

: null} + ) : null}
); } diff --git a/src/components/agent-runs/agent-run-event-log.tsx b/src/components/agent-runs/agent-run-event-log.tsx index 9708429..3b34e47 100644 --- a/src/components/agent-runs/agent-run-event-log.tsx +++ b/src/components/agent-runs/agent-run-event-log.tsx @@ -2,12 +2,19 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { CableIcon, UnplugIcon } from "lucide-react"; +import { CableIcon, ChevronDownIcon, UnplugIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { formatDateTime } from "@/lib/format"; import { isTerminalStatus } from "@/lib/cursor/status"; +import { cn } from "@/lib/utils"; export type EventLogItem = { id: string; @@ -104,9 +111,15 @@ export function AgentRunEventLog({ Event log - + {connected ? ( - + ) : ( )} @@ -119,39 +132,71 @@ export function AgentRunEventLog({ No events have been persisted yet.

) : ( -
    - {orderedEvents.map((event) => ( -
  1. - -
    - #{event.sequenceNumber} - {event.eventType} - - {formatDateTime(event.createdAt)} - -
    - {event.messageText ? ( -

    - {event.messageText} -

    - ) : null} -
    - - Raw event JSON - -
    -                    {JSON.stringify(event.rawPayload, null, 2)}
    -                  
    -
    -
  2. - ))} -
+ +
    + {orderedEvents.map((event) => ( +
  1. + +
    + + #{event.sequenceNumber} + + + {event.eventType} + + + {formatDateTime(event.createdAt)} + +
    + {event.messageText ? ( +

    + {event.messageText} +

    + ) : null} + + + + Raw JSON + + +
    +                        {JSON.stringify(event.rawPayload, null, 2)}
    +                      
    +
    +
    +
  2. + ))} +
+
)}
); } +function eventTypeDotClass(eventType: string) { + switch (eventType) { + case "assistant": + return "bg-primary"; + case "thinking": + return "bg-chart-5"; + case "tool_call": + return "bg-chart-3"; + case "status": + return "bg-chart-2"; + case "error": + case "app.stream_error": + return "bg-destructive"; + default: + return "bg-muted-foreground"; + } +} + function mergeEvents(current: EventLogItem[], incoming: EventLogItem[]) { const byId = new Map(current.map((event) => [event.id, event])); diff --git a/src/components/agent-runs/agent-run-filters.tsx b/src/components/agent-runs/agent-run-filters.tsx index 143c882..a3b08a0 100644 --- a/src/components/agent-runs/agent-run-filters.tsx +++ b/src/components/agent-runs/agent-run-filters.tsx @@ -1,35 +1,29 @@ -import Link from "next/link"; +"use client"; + +import { useRouter } from "next/navigation"; import { runStatusFilters, runStatusLabels } from "@/lib/agent-runs/types"; -import { buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; export function AgentRunFilters({ activeStatus }: { activeStatus?: string }) { + const router = useRouter(); + const value = activeStatus ?? "all"; + return ( - + { + router.push(next === "all" ? "/runs" : `/runs?status=${next}`); + }} + > + + All + {runStatusFilters.map((status) => ( + + {runStatusLabels[status]} + + ))} + + ); } diff --git a/src/components/agent-runs/agent-run-header.tsx b/src/components/agent-runs/agent-run-header.tsx index 57337d3..ba9bd86 100644 --- a/src/components/agent-runs/agent-run-header.tsx +++ b/src/components/agent-runs/agent-run-header.tsx @@ -1,6 +1,7 @@ import type { AgentRun } from "@prisma/client"; import { AgentRunActions } from "@/components/agent-runs/agent-run-actions"; +import { RunBreadcrumbs } from "@/components/agent-runs/run-breadcrumbs"; import { AgentRunStatusBadge } from "@/components/agent-runs/agent-run-status-badge"; import { Badge } from "@/components/ui/badge"; import { formatDateTime, hostAndRepo } from "@/lib/format"; @@ -8,8 +9,9 @@ import { formatDateTime, hostAndRepo } from "@/lib/format"; export function AgentRunHeader({ run }: { run: AgentRun }) { return (
-
-
+
+ +
{run.runtime} {run.retryOfRunId ? Retry : null} diff --git a/src/components/agent-runs/agent-runs-table.tsx b/src/components/agent-runs/agent-runs-table.tsx index 5dd2737..5867c74 100644 --- a/src/components/agent-runs/agent-runs-table.tsx +++ b/src/components/agent-runs/agent-runs-table.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { ExternalLinkIcon } from "lucide-react"; +import { BotIcon, ExternalLinkIcon } from "lucide-react"; import type { AgentRun } from "@prisma/client"; import { AgentRunStatusBadge, statusRailClassName } from "@/components/agent-runs/agent-run-status-badge"; @@ -20,9 +20,12 @@ import { cn } from "@/lib/utils"; export function AgentRunsTable({ runs }: { runs: AgentRun[] }) { if (runs.length === 0) { return ( - + - No runs yet +
+ +
+ No runs yet Create a Cursor cloud agent run to start tracking work here. @@ -52,7 +55,7 @@ export function AgentRunsTable({ runs }: { runs: AgentRun[] }) { {runs.map((run) => ( - + path === "/runs" || path.startsWith("/runs/") && path !== "/runs/new" }, + { href: "/runs/new", label: "New run", icon: PlusIcon, match: (path: string) => path === "/runs/new" }, + { href: "/status", label: "Status", icon: ActivityIcon, match: (path: string) => path === "/status" }, +] as const; + +export function AppNavLinks() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/agent-runs/app-shell.tsx b/src/components/agent-runs/app-shell.tsx index e02a78d..81b3644 100644 --- a/src/components/agent-runs/app-shell.tsx +++ b/src/components/agent-runs/app-shell.tsx @@ -1,9 +1,10 @@ import Link from "next/link"; -import { ActivityIcon, BotIcon, PlusIcon } from "lucide-react"; +import { BotIcon } from "lucide-react"; +import { AppNavLinks } from "@/components/agent-runs/app-nav-links"; +import { UserMenu } from "@/components/agent-runs/user-menu"; +import { SignOutMenuItem } from "@/components/auth/sign-out-menu-item"; import type { CurrentUser } from "@/lib/auth"; -import { SignOutButton } from "@/components/auth/auth-buttons"; -import { buttonVariants } from "@/components/ui/button"; export function AppShell({ user, @@ -14,46 +15,28 @@ export function AppShell({ }) { return (
-
-
- - - - - - - Chloei Code +
+
+
+ + + - - Cursor run control plane + + + Chloei Code + + + Cursor run control plane + - - -
- - - Status - - - New run - - - {user.email ?? user.name ?? user.id} - - + } />
+
-
- {children} -
+
{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..ca8c13b 100644 --- a/src/components/agent-runs/new-agent-run-form.tsx +++ b/src/components/agent-runs/new-agent-run-form.tsx @@ -25,8 +25,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Progress, ProgressTrack } from "@/components/ui/progress"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { toast } from "sonner"; import { mergeRepositoryCatalogs, type RepositoryCatalogItem, @@ -271,6 +273,7 @@ export function NewAgentRunForm({ throw new Error(payload.error ?? "Unable to create run."); } + toast.success("Cloud run started"); router.push(`/runs/${payload.run.id}`); router.refresh(); } catch (error) { @@ -381,26 +384,18 @@ export function NewAgentRunForm({ } function RunSafetyPanel({ runLimits }: { runLimits: RunCreationLimits }) { - const items = [ + const limitRows = [ { label: "Active runs", - value: formatLimit( - runLimits.activeRuns, - runLimits.activeLimit, - runLimits.remainingActiveRuns - ), + count: runLimits.activeRuns, + limit: runLimits.activeLimit, + remaining: runLimits.remainingActiveRuns, }, { label: "Runs in 24h", - value: formatLimit( - runLimits.runsLast24Hours, - runLimits.dailyLimit, - runLimits.remainingRunsLast24Hours - ), - }, - { - label: "Per-minute actions", - value: `${runLimits.perMinuteLimit.toLocaleString()} / user`, + count: runLimits.runsLast24Hours, + limit: runLimits.dailyLimit, + remaining: runLimits.remainingRunsLast24Hours, }, ]; @@ -408,25 +403,64 @@ function RunSafetyPanel({ runLimits }: { runLimits: RunCreationLimits }) {

Run safety

- + {runLimits.canCreateRun ? "Available" : "Limit reached"}
-
- {items.map((item) => ( -
-
{item.label}
-
{item.value}
-
+
+ {limitRows.map((row) => ( + ))} +
+
Per-minute actions
+
+ {runLimits.perMinuteLimit.toLocaleString()} / user +
+
); } +function LimitRow({ + label, + count, + limit, + remaining, +}: { + label: string; + count: number; + limit: number | null; + remaining: number | null; +}) { + const percent = + limit != null && limit > 0 + ? Math.min(100, Math.round((count / limit) * 100)) + : null; + + return ( +
+
+
{label}
+
+ {formatLimit(count, limit, remaining)} +
+
+ {percent != null ? ( + + + + ) : null} +
+ ); +} + function formatLimit(count: number, limit: number | null, remaining: number | null) { if (limit === null) { return `${count.toLocaleString()} / off`; diff --git a/src/components/agent-runs/refresh-button.tsx b/src/components/agent-runs/refresh-button.tsx index 1e31d86..076c6a8 100644 --- a/src/components/agent-runs/refresh-button.tsx +++ b/src/components/agent-runs/refresh-button.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { RefreshCwIcon } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -9,7 +10,13 @@ export function RefreshButton({ label = "Refresh" }: { label?: string }) { const router = useRouter(); return ( - diff --git a/src/components/agent-runs/run-breadcrumbs.tsx b/src/components/agent-runs/run-breadcrumbs.tsx new file mode 100644 index 0000000..d950551 --- /dev/null +++ b/src/components/agent-runs/run-breadcrumbs.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +export function RunBreadcrumbs({ + current, +}: { + current: string; +}) { + return ( + + + + }>Runs + + + + + {current} + + + + + ); +} + +export function NewRunBreadcrumbs() { + return ( + + + + }>Runs + + + + New cloud run + + + + ); +} diff --git a/src/components/agent-runs/user-menu.tsx b/src/components/agent-runs/user-menu.tsx new file mode 100644 index 0000000..4f97977 --- /dev/null +++ b/src/components/agent-runs/user-menu.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { CurrentUser } from "@/lib/auth"; + +function initials(user: CurrentUser) { + const source = user.name ?? user.email ?? user.id; + const parts = source.trim().split(/\s+/).filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0]![0]}${parts[1]![0]}`.toUpperCase(); + } + + return source.slice(0, 2).toUpperCase(); +} + +export function UserMenu({ + user, + menuItems, +}: { + user: CurrentUser; + menuItems: React.ReactNode; +}) { + return ( + + + } + > + + {user.image ? : null} + + {initials(user)} + + + + {user.email ?? user.name ?? "Account"} + + + + +

+ {user.name ?? "Signed in"} +

+ {user.email ? ( +

+ {user.email} +

+ ) : null} +
+ + {menuItems} +
+
+ ); +} diff --git a/src/components/auth/auth-buttons.tsx b/src/components/auth/auth-buttons.tsx index 965f815..b2f5f9c 100644 --- a/src/components/auth/auth-buttons.tsx +++ b/src/components/auth/auth-buttons.tsx @@ -11,9 +11,9 @@ export function SignInButton() { await signIn("github", { redirectTo: "/runs" }); }} > - ); diff --git a/src/components/auth/sign-in-panel.tsx b/src/components/auth/sign-in-panel.tsx index d466b18..74bbd96 100644 --- a/src/components/auth/sign-in-panel.tsx +++ b/src/components/auth/sign-in-panel.tsx @@ -1,8 +1,15 @@ -import { AlertCircleIcon } from "lucide-react"; +import { AlertCircleIcon, BotIcon } from "lucide-react"; import { SignInButton } from "@/components/auth/auth-buttons"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; export function SignInPanel() { const hasProvider = Boolean( @@ -10,17 +17,34 @@ export function SignInPanel() { ); return ( -
- - - Chloei Code +
+ + +
+ +
+ Chloei Code + + Sign in to create, monitor, and review Cursor cloud agent runs on + your GitHub repositories. +
-

- Sign in to create and monitor Cursor cloud agent runs. -

{hasProvider ? ( - + <> + +
+ + + GitHub OAuth + + +
+

+ Requests repository access so runs can target your repos. Your + token never leaves the server. +

+ ) : ( diff --git a/src/components/auth/sign-out-menu-item.tsx b/src/components/auth/sign-out-menu-item.tsx new file mode 100644 index 0000000..831e023 --- /dev/null +++ b/src/components/auth/sign-out-menu-item.tsx @@ -0,0 +1,23 @@ +import { LogOutIcon } from "lucide-react"; + +import { signOut } from "../../../auth"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; + +export function SignOutMenuItem() { + return ( +
{ + "use server"; + await signOut({ redirectTo: "/runs" }); + }} + > + } + > + + Sign out + +
+ ); +} diff --git a/src/components/providers/app-providers.tsx b/src/components/providers/app-providers.tsx new file mode 100644 index 0000000..ad27421 --- /dev/null +++ b/src/components/providers/app-providers.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { ThemeProvider } from "next-themes"; + +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +export function AppProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..e4fed86 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..3d85c18 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,125 @@ +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" + +function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { + return ( +
); } diff --git a/src/app/runs/page.tsx b/src/app/runs/page.tsx index 3b8a009..fbbb235 100644 --- a/src/app/runs/page.tsx +++ b/src/app/runs/page.tsx @@ -1,7 +1,6 @@ import { AgentRunFilters } from "@/components/agent-runs/agent-run-filters"; import { AgentRunsTable } from "@/components/agent-runs/agent-runs-table"; 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"; @@ -28,20 +27,15 @@ export default async function RunsPage({ return ( -
+
- - - - } + description="Monitor cloud agents working on your repositories — same runtime as Cursor." + actions={} /> -
+
); } diff --git a/src/app/status/page.tsx b/src/app/status/page.tsx index e2b0a09..6f54f00 100644 --- a/src/app/status/page.tsx +++ b/src/app/status/page.tsx @@ -10,7 +10,6 @@ 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 { Progress, ProgressTrack } from "@/components/ui/progress"; import { getRunCreationLimits } from "@/lib/agent-runs/limits"; import { getRunHealthStats, @@ -40,10 +39,10 @@ export default async function StatusPage() { return ( -
+
@@ -57,10 +56,6 @@ export default async function StatusPage() { runLimits.activeRuns, runLimits.activeLimit )} - percent={limitPercent( - runLimits.activeRuns, - runLimits.activeLimit - )} />
@@ -167,45 +158,22 @@ export default async function StatusPage() { ))} -
+
); } -function Metric({ - label, - value, - percent, -}: { - label: string; - value: number | string; - percent?: number | null; -}) { +function Metric({ label, value }: { label: string; value: number | string }) { return ( - -
-

{label}

-

{value}

-
- {percent != null ? ( - - - - ) : null} + +

{label}

+

{value}

); } -function limitPercent(count: number, limit: number | null) { - if (limit == null || limit <= 0) { - return null; - } - - return Math.min(100, Math.round((count / limit) * 100)); -} - function ConfigMetric({ label, value }: { label: string; value: string }) { return (
diff --git a/src/components/agent-runs/agent-run-event-log.tsx b/src/components/agent-runs/agent-run-event-log.tsx index 3b34e47..cc5d38f 100644 --- a/src/components/agent-runs/agent-run-event-log.tsx +++ b/src/components/agent-runs/agent-run-event-log.tsx @@ -108,7 +108,7 @@ export function AgentRunEventLog({ ); return ( - + Event log - - All + + + All + {runStatusFilters.map((status) => ( - + {runStatusLabels[status]} ))} diff --git a/src/components/agent-runs/agent-run-header.tsx b/src/components/agent-runs/agent-run-header.tsx index ba9bd86..6a2b248 100644 --- a/src/components/agent-runs/agent-run-header.tsx +++ b/src/components/agent-runs/agent-run-header.tsx @@ -1,25 +1,23 @@ import type { AgentRun } from "@prisma/client"; import { AgentRunActions } from "@/components/agent-runs/agent-run-actions"; -import { RunBreadcrumbs } from "@/components/agent-runs/run-breadcrumbs"; import { AgentRunStatusBadge } from "@/components/agent-runs/agent-run-status-badge"; import { Badge } from "@/components/ui/badge"; import { formatDateTime, hostAndRepo } from "@/lib/format"; export function AgentRunHeader({ run }: { run: AgentRun }) { return ( -
-
- -
+
+
+
{run.runtime} {run.retryOfRunId ? Retry : null}
-

+

{run.taskSummary}

-

+

{hostAndRepo(run.repoUrl)} from {run.startingRef} · created{" "} {formatDateTime(run.createdAt)}

diff --git a/src/components/agent-runs/agent-runs-table.tsx b/src/components/agent-runs/agent-runs-table.tsx index 5867c74..1003f53 100644 --- a/src/components/agent-runs/agent-runs-table.tsx +++ b/src/components/agent-runs/agent-runs-table.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { BotIcon, ExternalLinkIcon } from "lucide-react"; +import { ExternalLinkIcon } from "lucide-react"; import type { AgentRun } from "@prisma/client"; import { AgentRunStatusBadge, statusRailClassName } from "@/components/agent-runs/agent-run-status-badge"; @@ -20,12 +20,9 @@ import { cn } from "@/lib/utils"; export function AgentRunsTable({ runs }: { runs: AgentRun[] }) { if (runs.length === 0) { return ( - + -
- -
- No runs yet + No runs yet Create a Cursor cloud agent run to start tracking work here. @@ -40,7 +37,7 @@ export function AgentRunsTable({ runs }: { runs: AgentRun[] }) { } return ( -
+
@@ -55,7 +52,7 @@ export function AgentRunsTable({ runs }: { runs: AgentRun[] }) { {runs.map((run) => ( - + path === "/runs" || path.startsWith("/runs/") && path !== "/runs/new" }, - { href: "/runs/new", label: "New run", icon: PlusIcon, match: (path: string) => path === "/runs/new" }, - { href: "/status", label: "Status", icon: ActivityIcon, match: (path: string) => path === "/status" }, + { + href: "/runs", + label: "Agent runs", + icon: ListIcon, + match: (path: string) => + path === "/runs" || + (path.startsWith("/runs/") && path !== "/runs/new"), + }, + { + href: "/runs/new", + label: "New agent", + icon: PlusIcon, + match: (path: string) => path === "/runs/new", + }, + { + href: "/status", + label: "Status", + icon: ActivityIcon, + match: (path: string) => path === "/status", + }, ] as const; -export function AppNavLinks() { +export function AppNavLinks({ layout = "sidebar" }: { layout?: "sidebar" | "bar" }) { const pathname = usePathname(); + if (layout === "bar") { + return ( + + ); + } + return ( -
- - - Task - Repository - Ref - Model - Status - Updated - PR - - - - {runs.map((run) => ( - - - - - {run.taskSummary} - - - Created {formatDateTime(run.createdAt)} - - - +
+
+ Task + Repository + Branch + Status + PR + Open +
+
    + {runs.map((run) => ( +
  • + +
    + +

    + {run.taskSummary} +

    +

    + + {formatRelativeTime(run.updatedAt)} + + · + created {formatDateTime(run.createdAt)} +

    + +

    {hostAndRepo(run.repoUrl)} - - - {run.startingRef} - - - {run.modelId ?? "Cursor default"} - - -

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

    +
    + + {run.startingRef} + +
    +
    + +
    +
    {run.prUrl ? ( ) : ( - None + )} - - - ))} - -
+
+ + + +
+ + ))} +
); } diff --git a/src/components/agent-runs/app-nav-links.tsx b/src/components/agent-runs/app-nav-links.tsx index 958d505..65f033c 100644 --- a/src/components/agent-runs/app-nav-links.tsx +++ b/src/components/agent-runs/app-nav-links.tsx @@ -2,25 +2,19 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ActivityIcon, ListIcon, PlusIcon } from "lucide-react"; +import { ActivityIcon, ListIcon } from "lucide-react"; import { cn } from "@/lib/utils"; const links = [ { href: "/runs", - label: "Agent runs", + label: "Agents", icon: ListIcon, match: (path: string) => path === "/runs" || (path.startsWith("/runs/") && path !== "/runs/new"), }, - { - href: "/runs/new", - label: "New agent", - icon: PlusIcon, - match: (path: string) => path === "/runs/new", - }, { href: "/status", label: "Status", @@ -57,30 +51,45 @@ export function AppNavLinks({ layout = "sidebar" }: { layout?: "sidebar" | "bar" ); })} + + New agent + ); } return ( -