feat: implement web dashboard with attention-zone UI and API routes (#1)
* feat: implement web dashboard with attention-zone UI, API routes, and SSE
Implements the Next.js 15 web dashboard for INT-1332 with:
- Attention-prioritized session cards (urgent/action/warning/ok/done zones)
- 6 API routes: sessions, spawn, send, kill, merge, SSE events
- 5 components: SessionCard, AttentionZone, PRStatus, CIBadge, Terminal
- Session detail page with PR merge readiness, CI checks, unresolved comments
- Tailwind CSS 4 dark theme matching the reference bash dashboard
- Mock data layer covering all attention states for development
- SSE endpoint for real-time lifecycle event streaming
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add vitest test suite for web dashboard (77 tests)
Add comprehensive tests covering API routes, component rendering, and
attention-level classification. Fix merge button visibility when no alerts present.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address all PR review feedback
- Fix SSE memory leak: add cancel() handler to clear intervals on disconnect
- Add X-Accel-Buffering header for reverse proxy compatibility
- Add input validation on all API routes (validateString, validateIdentifier)
- Import core types (SessionStatus, ActivityState, CIStatus, etc.) from
@agent-orchestrator/core instead of redeclaring them
- Fix hydration mismatch: render timestamp client-side only via useEffect
- Add error handling on fetch calls in Dashboard (check response.ok)
- Add cn() utility for conditional class composition
- Fix setTimeout leak: use useRef + useEffect cleanup in SessionCard
- Fix getAttentionLevel edge case: status-based checks outside PR block
- Add NaN check on parseInt in merge route
- Extract duplicated sizeLabel logic into shared getSizeLabel()
- Add TODO guard comment on mock-data.ts for production removal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Codex review feedback (iteration 1)
- Use strict /^\d+$/ validation on PR merge route (reject "432foo")
- Treat exited agents with non-terminal status as urgent (crashed agents)
- Add explicit getAttentionLevel mappings for review_pending, approved, cleanup
- Guard SSE update loop against empty sessions array
- Use encodeURIComponent on session details link
- Tighten mergeScore types to Pick<DashboardPR, ...>
- Add stripControlChars() and apply to send route for shell safety
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: apply ESLint and Prettier formatting after rebase on main
- Add next-env.d.ts to ESLint ignores (triple-slash reference)
- Merge duplicate imports using inline type syntax (no-duplicate-imports)
- Replace non-null assertions with proper null checks
- Apply Prettier formatting across all packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Cursor Bugbot review findings
- Add reviewDecision "none" to warning zone in getAttentionLevel
- Change ACTION zone color from green to orange for proper priority signaling
- Fix SessionDetail date hydration mismatch with ClientDateCard pattern
- Add export const dynamic = "force-dynamic" to SSE route
- Add --color-accent-orange CSS variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Codex review iteration 2 findings
- Gate merge route on PR state (409 if not open) and draft status (422)
- Handle pr.state === "closed" in getAttentionLevel → done zone
- Reject messages that become empty after control char stripping
- Align SSEEvent types with actual emitted events (snapshot + activity)
- Add 6 new tests for edge cases (85 total)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve typecheck error in test helper (NextRequest init type)
Cast RequestInit to NextRequest's ConstructorParameters to fix
type incompatibility between global RequestInit.signal (null allowed)
and Next.js RequestInit.signal (null not allowed).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve remaining review threads (zone colors, draft PRs, ternary)
- WORKING zone uses green (not blue) per design spec
- ACTION zone uses orange, consistent across all components
- Draft PRs with reviewDecision "none" fall to ok (not warning)
- Remove redundant ternary in getAttentionLevel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add Playwright screenshot tooling for visual verification
Agents and developers can now capture headless Chromium screenshots of
the running dashboard. Includes dev server auto-start, default page
specs, and CLI arg parsing. Screenshots committed to branch for PR
visibility.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add session detail screenshot after compact metadata redesign
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add screenshots for session detail redesign + zone reclassification
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address bugbot findings — unused type, kill route validation, draft PR awareness
- Remove unused SSEEvent type alias from types.ts
- Add validateIdentifier() to kill route for session ID validation
- Skip "needs review" alert for draft PRs in SessionCard getAlerts()
- Show "draft" instead of "needs review" in PRTableRow for draft PRs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: session detail redesign + attention zone reclassification
Session Detail:
- Nav bar replacing standalone back link
- Meta chips (project/branch/issue) with GitHub links
- Humanized status labels and relative timestamps
- Unified PR card with stats row, issues list, inline CI checks
- Clickable file paths in unresolved comments
Attention Zones (reordered by human action urgency):
- merge: PRs ready to merge (highest ROI per second)
- respond: agents waiting for input (quick unblock)
- review: CI failures, changes requested, conflicts
- pending: waiting on reviewer or CI
- working: agents doing their thing
- done: merged or terminated
Also fixes:
- Add validateIdentifier() to send route for session ID
- Update all tests for new zone names
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve 3 remaining review threads
- Remove duplicate eslint ignore entry for next-env.d.ts
- Export checkStatusIcon and ciCheckSortOrder from CIBadge, import in
SessionDetail instead of duplicating
- Add restore API route (was untracked)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: restore route validation, merged session guards
- Add validateIdentifier() to restore route (matches kill/send routes)
- Block restoring merged sessions with 409 response
- Hide kill button for merged sessions in top-row and expanded panel
- Expanded panel now shows restore OR terminate (not ternary fallback)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: exclude e2e from tsconfig include (fixes next build)
The e2e directory imports playwright which isn't resolvable during
next build. Since e2e files are standalone tooling (not app code),
exclude them from the main tsconfig include.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: unify CI check rendering via CICheckList layout prop
Add layout prop ("vertical"|"inline"|"expanded") to CICheckList,
remove duplicated InlineCIChecks from SessionDetail in favor of
reusing CICheckList with the appropriate layout mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add isDraft guard to unresolvedThreads pending check
Draft PRs with unresolved threads should fall through to "working",
not "pending". Adds the missing !pr.isDraft guard to match the
reviewDecision check on the next line.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address 9 bugbot findings across e2e, SSE, and mock-data
- server.ts: kill child process on waitForServer failure, drain stdout
to prevent backpressure
- screenshot.ts: validate Number() args for NaN with clear error messages
- events/route.ts: initialize interval variables as undefined
- mock-data.ts: exclude draft PRs from needsReview stat count
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent simultaneous restore and kill buttons in SessionCard
Add !isRestorable guard to kill button condition so the two buttons
are mutually exclusive. Restorable sessions show restore; non-restorable
exited sessions show kill.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: remove unreachable kill button from SessionCard top row
The kill button in the top row was dead code — isRestorable already
covers all activity === "exited" cases, so the !isRestorable && exited
condition could never be true.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: update tests to match SessionCard after kill button removal
Tests referenced "kill session" text that no longer exists. Exited
sessions show "restore session", not "kill session". Updated 4 tests
to use onRestore/restore session instead of onKill/kill session.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7dd7de6249
commit
343c8a65b5
|
|
@ -7,3 +7,4 @@ dist/
|
|||
coverage/
|
||||
*.patch
|
||||
*-context.md
|
||||
packages/web/screenshots/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
interface ScreenshotOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface PageSpec {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function pathToName(path: string): string {
|
||||
if (path === "/") return "dashboard";
|
||||
return path.replace(/^\//, "").replace(/\//g, "-");
|
||||
}
|
||||
|
||||
const DEFAULT_PAGES: PageSpec[] = [
|
||||
{ path: "/", name: "dashboard" },
|
||||
{ path: "/sessions/backend-3", name: "session-detail" },
|
||||
];
|
||||
|
||||
export async function captureScreenshots(
|
||||
baseUrl: string,
|
||||
extraPaths: string[],
|
||||
options: ScreenshotOptions = {},
|
||||
): Promise<string[]> {
|
||||
const { width = 1280, height = 900 } = options;
|
||||
|
||||
const screenshotDir = join(new URL("../../", import.meta.url).pathname, "screenshots");
|
||||
await mkdir(screenshotDir, { recursive: true });
|
||||
|
||||
const pages: PageSpec[] = extraPaths.length > 0
|
||||
? extraPaths.map((p) => ({ path: p, name: pathToName(p) }))
|
||||
: DEFAULT_PAGES;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const savedPaths: string[] = [];
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width, height } });
|
||||
const page = await context.newPage();
|
||||
|
||||
for (const spec of pages) {
|
||||
const url = `${baseUrl}${spec.path}`;
|
||||
console.log(`Navigating to ${url}`);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
// Extra delay for React hydration
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const filePath = join(screenshotDir, `${spec.name}.png`);
|
||||
await page.screenshot({ path: filePath, fullPage: true });
|
||||
savedPaths.push(filePath);
|
||||
console.log(`Saved ${filePath}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return savedPaths;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { request } from "node:http";
|
||||
|
||||
interface ServerHandle {
|
||||
baseUrl: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
function probePort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = request({ hostname: "127.0.0.1", port, path: "/", method: "HEAD", timeout: 2000 }, (res) => {
|
||||
res.resume();
|
||||
resolve(true);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await probePort(port)) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server did not start on port ${port} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export async function ensureServer(port: number): Promise<ServerHandle> {
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
if (await probePort(port)) {
|
||||
console.log(`Reusing existing server on port ${port}`);
|
||||
return { baseUrl, stop: () => {} };
|
||||
}
|
||||
|
||||
console.log(`Starting dev server on port ${port}...`);
|
||||
const child: ChildProcess = spawn("npx", ["next", "dev", "--port", String(port)], {
|
||||
cwd: new URL("../../", import.meta.url).pathname,
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
child.stdout?.resume();
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString().trim();
|
||||
if (line) console.log(`[next] ${line}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, 30_000);
|
||||
} catch (err) {
|
||||
child.kill("SIGTERM");
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.log(`Dev server ready on ${baseUrl}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
stop: () => {
|
||||
child.kill("SIGTERM");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { ensureServer } from "./lib/server.js";
|
||||
import { captureScreenshots } from "./lib/browser.js";
|
||||
|
||||
function parseArgs(argv: string[]): { port: number; width: number; height: number; paths: string[] } {
|
||||
const args = argv.slice(2);
|
||||
let port = 3333;
|
||||
let width = 1280;
|
||||
let height = 900;
|
||||
const paths: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--port" && args[i + 1]) {
|
||||
const n = Number(args[++i]);
|
||||
if (Number.isNaN(n)) throw new Error(`Invalid --port value: ${args[i]}`);
|
||||
port = n;
|
||||
} else if (arg === "--width" && args[i + 1]) {
|
||||
const n = Number(args[++i]);
|
||||
if (Number.isNaN(n)) throw new Error(`Invalid --width value: ${args[i]}`);
|
||||
width = n;
|
||||
} else if (arg === "--height" && args[i + 1]) {
|
||||
const n = Number(args[++i]);
|
||||
if (Number.isNaN(n)) throw new Error(`Invalid --height value: ${args[i]}`);
|
||||
height = n;
|
||||
} else if (arg?.startsWith("/")) {
|
||||
paths.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { port, width, height, paths };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { port, width, height, paths } = parseArgs(process.argv);
|
||||
|
||||
const server = await ensureServer(port);
|
||||
|
||||
try {
|
||||
const saved = await captureScreenshots(server.baseUrl, paths, { width, height });
|
||||
console.log(`\nDone — ${saved.length} screenshot(s) saved.`);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@agent-orchestrator/core"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -9,7 +9,11 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf .next"
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -rf .next",
|
||||
"screenshot": "tsx e2e/screenshot.ts",
|
||||
"screenshot:install": "npx playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-orchestrator/core": "workspace:*",
|
||||
|
|
@ -18,9 +22,17 @@
|
|||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"playwright": "^1.49.0",
|
||||
"tsx": "^4.19.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Import route handlers directly
|
||||
import { GET as sessionsGET } from "@/app/api/sessions/route";
|
||||
import { POST as spawnPOST } from "@/app/api/spawn/route";
|
||||
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
|
||||
import { POST as killPOST } from "@/app/api/sessions/[id]/kill/route";
|
||||
import { POST as restorePOST } from "@/app/api/sessions/[id]/restore/route";
|
||||
import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route";
|
||||
import { GET as eventsGET } from "@/app/api/events/route";
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(url, "http://localhost:3000"),
|
||||
init as ConstructorParameters<typeof NextRequest>[1],
|
||||
);
|
||||
}
|
||||
|
||||
describe("API Routes", () => {
|
||||
// ── GET /api/sessions ──────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/sessions", () => {
|
||||
it("returns sessions array and stats", async () => {
|
||||
const res = await sessionsGET();
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.sessions).toBeDefined();
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(data.sessions.length).toBeGreaterThan(0);
|
||||
expect(data.stats).toBeDefined();
|
||||
expect(data.stats.totalSessions).toBe(data.sessions.length);
|
||||
});
|
||||
|
||||
it("stats include expected fields", async () => {
|
||||
const res = await sessionsGET();
|
||||
const data = await res.json();
|
||||
expect(data.stats).toHaveProperty("totalSessions");
|
||||
expect(data.stats).toHaveProperty("workingSessions");
|
||||
expect(data.stats).toHaveProperty("openPRs");
|
||||
expect(data.stats).toHaveProperty("needsReview");
|
||||
});
|
||||
|
||||
it("sessions have expected shape", async () => {
|
||||
const res = await sessionsGET();
|
||||
const data = await res.json();
|
||||
const session = data.sessions[0];
|
||||
expect(session).toHaveProperty("id");
|
||||
expect(session).toHaveProperty("projectId");
|
||||
expect(session).toHaveProperty("status");
|
||||
expect(session).toHaveProperty("activity");
|
||||
expect(session).toHaveProperty("createdAt");
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/spawn ────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/spawn", () => {
|
||||
it("creates a session with valid input", async () => {
|
||||
const req = makeRequest("/api/spawn", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: "my-app", issueId: "INT-100" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await spawnPOST(req);
|
||||
expect(res.status).toBe(201);
|
||||
const data = await res.json();
|
||||
expect(data.session).toBeDefined();
|
||||
expect(data.session.projectId).toBe("my-app");
|
||||
expect(data.session.issueId).toBe("INT-100");
|
||||
expect(data.session.status).toBe("spawning");
|
||||
});
|
||||
|
||||
it("returns 400 when projectId is missing", async () => {
|
||||
const req = makeRequest("/api/spawn", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await spawnPOST(req);
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/projectId/);
|
||||
});
|
||||
|
||||
it("returns 400 with invalid JSON", async () => {
|
||||
const req = makeRequest("/api/spawn", {
|
||||
method: "POST",
|
||||
body: "not json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await spawnPOST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("handles missing issueId gracefully", async () => {
|
||||
const req = makeRequest("/api/spawn", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId: "my-app" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await spawnPOST(req);
|
||||
expect(res.status).toBe(201);
|
||||
const data = await res.json();
|
||||
expect(data.session.issueId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/sessions/:id/send ────────────────────────────────────
|
||||
|
||||
describe("POST /api/sessions/:id/send", () => {
|
||||
it("sends a message to a valid session", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "Fix the tests" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.message).toBe("Fix the tests");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
const req = makeRequest("/api/sessions/nonexistent/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "hello" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await sendPOST(req, { params: Promise.resolve({ id: "nonexistent" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 when message is missing", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/message/);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid JSON body", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/send", {
|
||||
method: "POST",
|
||||
body: "not json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for control-char-only message", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "\x00\x01\x02" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/sessions/:id/kill ────────────────────────────────────
|
||||
|
||||
describe("POST /api/sessions/:id/kill", () => {
|
||||
it("kills a valid session", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-3/kill", { method: "POST" });
|
||||
const res = await killPOST(req, { params: Promise.resolve({ id: "backend-3" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.sessionId).toBe("backend-3");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
const req = makeRequest("/api/sessions/nonexistent/kill", { method: "POST" });
|
||||
const res = await killPOST(req, { params: Promise.resolve({ id: "nonexistent" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/sessions/:id/restore ─────────────────────────────────
|
||||
|
||||
describe("POST /api/sessions/:id/restore", () => {
|
||||
it("restores a killed session", async () => {
|
||||
const req = makeRequest("/api/sessions/frontend-1/restore", { method: "POST" });
|
||||
const res = await restorePOST(req, { params: Promise.resolve({ id: "frontend-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.sessionId).toBe("frontend-1");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
const req = makeRequest("/api/sessions/nonexistent/restore", { method: "POST" });
|
||||
const res = await restorePOST(req, { params: Promise.resolve({ id: "nonexistent" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 for active session", async () => {
|
||||
const req = makeRequest("/api/sessions/backend-9/restore", { method: "POST" });
|
||||
const res = await restorePOST(req, { params: Promise.resolve({ id: "backend-9" }) });
|
||||
expect(res.status).toBe(409);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/not in a terminal state/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/prs/:id/merge ────────────────────────────────────────
|
||||
|
||||
describe("POST /api/prs/:id/merge", () => {
|
||||
it("merges a mergeable PR", async () => {
|
||||
// PR #432 (backend-7) is mergeable in mock data
|
||||
const req = makeRequest("/api/prs/432/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "432" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.prNumber).toBe(432);
|
||||
});
|
||||
|
||||
it("returns 404 for unknown PR", async () => {
|
||||
const req = makeRequest("/api/prs/99999/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "99999" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 422 for non-mergeable PR", async () => {
|
||||
// PR #428 (backend-5) has failing CI → not mergeable
|
||||
const req = makeRequest("/api/prs/428/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "428" }) });
|
||||
expect(res.status).toBe(422);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/not mergeable/);
|
||||
expect(data.blockers).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric PR id", async () => {
|
||||
const req = makeRequest("/api/prs/abc/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "abc" }) });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/Invalid PR number/);
|
||||
});
|
||||
|
||||
it("returns 422 for draft PR", async () => {
|
||||
// PR #440 (frontend-9) is a draft
|
||||
const req = makeRequest("/api/prs/440/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "440" }) });
|
||||
expect(res.status).toBe(422);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("returns 409 for merged PR", async () => {
|
||||
// PR #410 (backend-1) is merged
|
||||
const req = makeRequest("/api/prs/410/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "410" }) });
|
||||
expect(res.status).toBe(409);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/merged/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /api/events (SSE) ──────────────────────────────────────────
|
||||
|
||||
describe("GET /api/events", () => {
|
||||
it("returns SSE content type", async () => {
|
||||
const res = await eventsGET();
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-cache");
|
||||
});
|
||||
|
||||
it("streams initial snapshot event", async () => {
|
||||
const res = await eventsGET();
|
||||
const reader = res.body!.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.cancel();
|
||||
const text = new TextDecoder().decode(value);
|
||||
expect(text).toContain("data: ");
|
||||
const jsonStr = text.replace("data: ", "").trim();
|
||||
const event = JSON.parse(jsonStr);
|
||||
expect(event.type).toBe("snapshot");
|
||||
expect(Array.isArray(event.sessions)).toBe(true);
|
||||
expect(event.sessions.length).toBeGreaterThan(0);
|
||||
expect(event.sessions[0]).toHaveProperty("id");
|
||||
expect(event.sessions[0]).toHaveProperty("attentionLevel");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CIBadge, CICheckList } from "@/components/CIBadge";
|
||||
import { PRStatus } from "@/components/PRStatus";
|
||||
import { SessionCard } from "@/components/SessionCard";
|
||||
import { AttentionZone } from "@/components/AttentionZone";
|
||||
import { makeSession, makePR } from "./helpers";
|
||||
|
||||
// ── CIBadge ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("CIBadge", () => {
|
||||
it("renders passing status", () => {
|
||||
render(<CIBadge status="passing" />);
|
||||
expect(screen.getByText("CI passing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders failing status with check count", () => {
|
||||
const checks = [
|
||||
{ name: "build", status: "failed" as const },
|
||||
{ name: "test", status: "failed" as const },
|
||||
{ name: "lint", status: "passed" as const },
|
||||
];
|
||||
render(<CIBadge status="failing" checks={checks} />);
|
||||
expect(screen.getByText("2 checks failing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders single failing check without plural", () => {
|
||||
const checks = [
|
||||
{ name: "build", status: "failed" as const },
|
||||
{ name: "lint", status: "passed" as const },
|
||||
];
|
||||
render(<CIBadge status="failing" checks={checks} />);
|
||||
expect(screen.getByText("1 check failing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pending status", () => {
|
||||
render(<CIBadge status="pending" />);
|
||||
expect(screen.getByText("CI pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders em-dash for none status", () => {
|
||||
const { container } = render(<CIBadge status="none" />);
|
||||
expect(container.textContent).toContain("—");
|
||||
});
|
||||
|
||||
it("hides icon in compact mode", () => {
|
||||
const { container } = render(<CIBadge status="passing" compact />);
|
||||
// In compact mode, no icon span before the label
|
||||
const spans = container.querySelectorAll("span > span");
|
||||
// Should only have the label text, no extra icon span
|
||||
expect(spans.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CICheckList ──────────────────────────────────────────────────────
|
||||
|
||||
describe("CICheckList", () => {
|
||||
it("renders all checks", () => {
|
||||
const checks = [
|
||||
{ name: "build", status: "passed" as const },
|
||||
{ name: "test", status: "failed" as const, url: "https://example.com/test" },
|
||||
{ name: "lint", status: "pending" as const },
|
||||
];
|
||||
render(<CICheckList checks={checks} />);
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
expect(screen.getByText("test")).toBeInTheDocument();
|
||||
expect(screen.getByText("lint")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts failed checks first", () => {
|
||||
const checks = [
|
||||
{ name: "lint", status: "passed" as const },
|
||||
{ name: "build", status: "failed" as const },
|
||||
{ name: "test", status: "running" as const },
|
||||
];
|
||||
const { container } = render(<CICheckList checks={checks} />);
|
||||
const names = Array.from(container.querySelectorAll(".truncate")).map((el) => el.textContent);
|
||||
expect(names[0]).toBe("build"); // failed first
|
||||
expect(names[1]).toBe("test"); // running second
|
||||
expect(names[2]).toBe("lint"); // passed last
|
||||
});
|
||||
|
||||
it("renders view links for checks with URLs", () => {
|
||||
const checks = [
|
||||
{ name: "build", status: "passed" as const, url: "https://example.com/build" },
|
||||
{ name: "test", status: "passed" as const },
|
||||
];
|
||||
render(<CICheckList checks={checks} />);
|
||||
const links = screen.getAllByText("view");
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toHaveAttribute("href", "https://example.com/build");
|
||||
});
|
||||
});
|
||||
|
||||
// ── PRStatus ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("PRStatus", () => {
|
||||
it("renders PR number as link", () => {
|
||||
const pr = makePR({ number: 42 });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.getByText("#42")).toBeInTheDocument();
|
||||
expect(screen.getByText("#42").closest("a")).toHaveAttribute("href", pr.url);
|
||||
});
|
||||
|
||||
it("renders size label", () => {
|
||||
const pr = makePR({ additions: 50, deletions: 10 });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.getByText("+50 -10 S")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes XL size label for large PRs", () => {
|
||||
const pr = makePR({ additions: 800, deletions: 300 });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.getByText("+800 -300 XL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows merged badge for merged PRs", () => {
|
||||
const pr = makePR({ state: "merged" });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.getByText("merged")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows draft badge for draft PRs", () => {
|
||||
const pr = makePR({ isDraft: true, state: "open" });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.getByText("draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows approved badge", () => {
|
||||
const pr = makePR({ reviewDecision: "approved", state: "open" });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.getByText("approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show CI badge for draft PRs", () => {
|
||||
const pr = makePR({ isDraft: true, state: "open", ciStatus: "passing" });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.queryByText("CI passing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show CI badge for merged PRs", () => {
|
||||
const pr = makePR({ state: "merged", ciStatus: "passing" });
|
||||
render(<PRStatus pr={pr} />);
|
||||
expect(screen.queryByText("CI passing")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SessionCard ──────────────────────────────────────────────────────
|
||||
|
||||
describe("SessionCard", () => {
|
||||
it("renders session id and summary", () => {
|
||||
const session = makeSession({ id: "backend-1", summary: "Fixing auth" });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("backend-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fixing auth")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows PR title instead of summary when PR exists", () => {
|
||||
const pr = makePR({ title: "feat: add auth" });
|
||||
const session = makeSession({ summary: "Fixing auth", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("feat: add auth")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders branch name", () => {
|
||||
const session = makeSession({ branch: "feat/cool-thing" });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("feat/cool-thing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders terminal link", () => {
|
||||
const session = makeSession({ id: "backend-5" });
|
||||
render(<SessionCard session={session} />);
|
||||
const link = screen.getByText("terminal");
|
||||
expect(link).toHaveAttribute("href", "/sessions/backend-5");
|
||||
});
|
||||
|
||||
it("shows restore button when agent has exited", () => {
|
||||
const session = makeSession({ activity: "exited" });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("restore session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show restore button when agent is active", () => {
|
||||
const session = makeSession({ activity: "active" });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.queryByText("restore session")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRestore when restore button is clicked", () => {
|
||||
const onRestore = vi.fn();
|
||||
const session = makeSession({ id: "backend-1", activity: "exited" });
|
||||
render(<SessionCard session={session} onRestore={onRestore} />);
|
||||
fireEvent.click(screen.getByText("restore session"));
|
||||
expect(onRestore).toHaveBeenCalledWith("backend-1");
|
||||
});
|
||||
|
||||
it("shows merge button when PR is mergeable", () => {
|
||||
const pr = makePR({
|
||||
number: 42,
|
||||
state: "open",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "mergeable", activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("merge PR #42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onMerge when merge button is clicked", () => {
|
||||
const onMerge = vi.fn();
|
||||
const pr = makePR({
|
||||
number: 42,
|
||||
state: "open",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "mergeable", activity: "idle", pr });
|
||||
render(<SessionCard session={session} onMerge={onMerge} />);
|
||||
fireEvent.click(screen.getByText("merge PR #42"));
|
||||
expect(onMerge).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("shows CI failing alert", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
ciStatus: "failing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "failed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("1 CI check failing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows changes requested alert", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("changes requested")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows needs review alert", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("needs review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unresolved comments alert with count", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
unresolvedThreads: 3,
|
||||
unresolvedComments: [
|
||||
{ url: "https://example.com/1", path: "src/a.ts", author: "alice", body: "fix" },
|
||||
{ url: "https://example.com/2", path: "src/b.ts", author: "bob", body: "fix" },
|
||||
{ url: "https://example.com/3", path: "src/c.ts", author: "carol", body: "fix" },
|
||||
],
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("unresolved comments")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows action buttons when agent is idle", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
ciStatus: "failing",
|
||||
ciChecks: [{ name: "test", status: "failed" }],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("ask to fix CI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides action buttons when agent is active", () => {
|
||||
const pr = makePR({
|
||||
state: "open",
|
||||
ciStatus: "failing",
|
||||
ciChecks: [{ name: "test", status: "failed" }],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ activity: "active", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.queryByText("ask to fix CI")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands detail panel on click", () => {
|
||||
const session = makeSession({ id: "test-1", issueId: "INT-100", pr: null });
|
||||
const { container } = render(<SessionCard session={session} />);
|
||||
expect(screen.queryByText("INT-100")).not.toBeInTheDocument();
|
||||
// Click the card (not a button/link)
|
||||
fireEvent.click(container.firstElementChild!);
|
||||
expect(screen.getByText("INT-100")).toBeInTheDocument();
|
||||
expect(screen.getByText("No PR associated with this session.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows terminate session button in expanded view", () => {
|
||||
const session = makeSession({ pr: null });
|
||||
const { container } = render(<SessionCard session={session} />);
|
||||
fireEvent.click(container.firstElementChild!);
|
||||
expect(screen.getByText("terminate session")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── AttentionZone ────────────────────────────────────────────────────
|
||||
|
||||
describe("AttentionZone", () => {
|
||||
it("renders zone label and session count", () => {
|
||||
const sessions = [makeSession({ id: "s1" }), makeSession({ id: "s2" })];
|
||||
render(<AttentionZone level="respond" sessions={sessions} />);
|
||||
expect(screen.getByText("RESPOND")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agents waiting for your input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when sessions array is empty", () => {
|
||||
const { container } = render(<AttentionZone level="respond" sessions={[]} />);
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows session cards when not collapsed", () => {
|
||||
const sessions = [makeSession({ id: "s1" })];
|
||||
render(<AttentionZone level="respond" sessions={sessions} />);
|
||||
// respond is defaultCollapsed: false, so cards should be visible
|
||||
expect(screen.getByText("s1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("working zone is collapsed by default", () => {
|
||||
const sessions = [makeSession({ id: "s1" })];
|
||||
render(<AttentionZone level="working" sessions={sessions} />);
|
||||
// working is defaultCollapsed: true, so session id should not be visible
|
||||
expect(screen.queryByText("s1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("WORKING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("done zone is collapsed by default", () => {
|
||||
const sessions = [makeSession({ id: "s1" })];
|
||||
render(<AttentionZone level="done" sessions={sessions} />);
|
||||
expect(screen.queryByText("s1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("DONE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles collapsed state on click", () => {
|
||||
const sessions = [makeSession({ id: "s1" })];
|
||||
render(<AttentionZone level="working" sessions={sessions} />);
|
||||
expect(screen.queryByText("s1")).not.toBeInTheDocument();
|
||||
|
||||
// Click the zone header to expand
|
||||
fireEvent.click(screen.getByText("WORKING"));
|
||||
expect(screen.getByText("s1")).toBeInTheDocument();
|
||||
|
||||
// Click again to collapse
|
||||
fireEvent.click(screen.getByText("WORKING"));
|
||||
expect(screen.queryByText("s1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes callbacks to SessionCards", () => {
|
||||
const onRestore = vi.fn();
|
||||
const sessions = [makeSession({ id: "s1", activity: "exited" })];
|
||||
render(<AttentionZone level="respond" sessions={sessions} onRestore={onRestore} />);
|
||||
fireEvent.click(screen.getByText("restore session"));
|
||||
expect(onRestore).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getAttentionLevel } from "@/lib/types";
|
||||
import { makeSession, makePR } from "./helpers";
|
||||
|
||||
describe("getAttentionLevel", () => {
|
||||
// ── MERGE (green zone — PRs ready to merge) ────────────────────────
|
||||
|
||||
describe("merge zone", () => {
|
||||
it("returns merge when status is mergeable", () => {
|
||||
const pr = makePR({
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "mergeable", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("merge");
|
||||
});
|
||||
|
||||
it("returns merge when PR mergeability is true", () => {
|
||||
const pr = makePR({
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "pr_open", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("merge");
|
||||
});
|
||||
});
|
||||
|
||||
// ── RESPOND (red zone — agent needs human input) ───────────────────
|
||||
|
||||
describe("respond zone", () => {
|
||||
it("returns respond when activity is waiting_input", () => {
|
||||
const session = makeSession({ activity: "waiting_input" });
|
||||
expect(getAttentionLevel(session)).toBe("respond");
|
||||
});
|
||||
|
||||
it("returns respond when activity is blocked", () => {
|
||||
const session = makeSession({ activity: "blocked" });
|
||||
expect(getAttentionLevel(session)).toBe("respond");
|
||||
});
|
||||
|
||||
it("returns respond when status is needs_input", () => {
|
||||
const session = makeSession({ status: "needs_input", activity: "idle" });
|
||||
expect(getAttentionLevel(session)).toBe("respond");
|
||||
});
|
||||
|
||||
it("returns respond when status is stuck", () => {
|
||||
const session = makeSession({ status: "stuck", activity: "idle" });
|
||||
expect(getAttentionLevel(session)).toBe("respond");
|
||||
});
|
||||
|
||||
it("returns respond when status is errored", () => {
|
||||
const session = makeSession({ status: "errored", activity: "idle" });
|
||||
expect(getAttentionLevel(session)).toBe("respond");
|
||||
});
|
||||
|
||||
it("returns respond when agent has exited unexpectedly (non-terminal status)", () => {
|
||||
const session = makeSession({ status: "working", activity: "exited", pr: null });
|
||||
expect(getAttentionLevel(session)).toBe("respond");
|
||||
});
|
||||
|
||||
it("merge takes priority over respond (mergeable PR + blocked agent)", () => {
|
||||
const pr = makePR({
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "mergeable", activity: "blocked", pr });
|
||||
expect(getAttentionLevel(session)).toBe("merge");
|
||||
});
|
||||
});
|
||||
|
||||
// ── REVIEW (orange zone — needs investigation) ─────────────────────
|
||||
|
||||
describe("review zone", () => {
|
||||
it("returns review when CI is failing", () => {
|
||||
const pr = makePR({
|
||||
ciStatus: "failing",
|
||||
ciChecks: [{ name: "test", status: "failed" }],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: ["CI failing"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("review");
|
||||
});
|
||||
|
||||
it("returns review when changes are requested", () => {
|
||||
const pr = makePR({
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Changes requested"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "changes_requested", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("review");
|
||||
});
|
||||
|
||||
it("returns review when there are merge conflicts", () => {
|
||||
const pr = makePR({
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: false,
|
||||
blockers: ["Merge conflict"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "pr_open", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("review");
|
||||
});
|
||||
});
|
||||
|
||||
// ── PENDING (yellow zone — waiting on external) ────────────────────
|
||||
|
||||
describe("pending zone", () => {
|
||||
it("returns pending when review is pending", () => {
|
||||
const pr = makePR({
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Needs review"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "review_pending", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns pending when review decision is none", () => {
|
||||
const pr = makePR({
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Needs review"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "pr_open", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns pending when there are unresolved threads", () => {
|
||||
const pr = makePR({
|
||||
reviewDecision: "approved",
|
||||
unresolvedThreads: 2,
|
||||
unresolvedComments: [
|
||||
{ url: "https://example.com/1", path: "src/foo.ts", author: "bob", body: "fix this" },
|
||||
{ url: "https://example.com/2", path: "src/bar.ts", author: "bob", body: "also this" },
|
||||
],
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: ["Unresolved comments"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "pr_open", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
// ── WORKING (blue zone — agents doing their thing) ─────────────────
|
||||
|
||||
describe("working zone", () => {
|
||||
it("returns working when actively working with no PR", () => {
|
||||
const session = makeSession({ status: "working", activity: "active", pr: null });
|
||||
expect(getAttentionLevel(session)).toBe("working");
|
||||
});
|
||||
|
||||
it("returns working when spawning", () => {
|
||||
const session = makeSession({ status: "spawning", activity: "active", pr: null });
|
||||
expect(getAttentionLevel(session)).toBe("working");
|
||||
});
|
||||
|
||||
it("returns working for idle session with no PR", () => {
|
||||
const session = makeSession({ status: "working", activity: "idle", pr: null });
|
||||
expect(getAttentionLevel(session)).toBe("working");
|
||||
});
|
||||
|
||||
it("returns working for draft PR with reviewDecision none", () => {
|
||||
const pr = makePR({
|
||||
isDraft: true,
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Draft PR"],
|
||||
},
|
||||
});
|
||||
const session = makeSession({ status: "working", activity: "active", pr });
|
||||
expect(getAttentionLevel(session)).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
// ── DONE (grey zone — archived) ────────────────────────────────────
|
||||
|
||||
describe("done zone", () => {
|
||||
it("returns done when PR is merged", () => {
|
||||
const pr = makePR({ state: "merged" });
|
||||
const session = makeSession({ status: "merged", activity: "exited", pr });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when PR is closed (not merged)", () => {
|
||||
const pr = makePR({ state: "closed" });
|
||||
const session = makeSession({ status: "working", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when session status is merged (even with open PR state)", () => {
|
||||
const pr = makePR({ state: "merged" });
|
||||
const session = makeSession({ status: "merged", activity: "idle", pr });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when session is killed", () => {
|
||||
const session = makeSession({ status: "killed", activity: "exited", pr: null });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns done when agent has exited with cleanup status", () => {
|
||||
const session = makeSession({ status: "cleanup", activity: "exited", pr: null });
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { DashboardSession, DashboardPR } from "@/lib/types";
|
||||
|
||||
/** Create a minimal mock session with overrides */
|
||||
export function makeSession(overrides: Partial<DashboardSession> = {}): DashboardSession {
|
||||
return {
|
||||
id: "test-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/test",
|
||||
issueId: "INT-100",
|
||||
summary: "Test session",
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a minimal mock PR with overrides */
|
||||
export function makePR(overrides: Partial<DashboardPR> = {}): DashboardPR {
|
||||
return {
|
||||
number: 100,
|
||||
url: "https://github.com/acme/app/pull/100",
|
||||
title: "feat: test PR",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 50,
|
||||
deletions: 10,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { mockSessions } from "@/lib/mock-data";
|
||||
import { getAttentionLevel } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/events — SSE stream for real-time lifecycle events
|
||||
*
|
||||
* Sends session state updates to connected clients.
|
||||
* In production, this will be wired to the core EventBus.
|
||||
*/
|
||||
export async function GET(): Promise<Response> {
|
||||
const encoder = new TextEncoder();
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
let updates: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
const initialEvent = {
|
||||
type: "snapshot",
|
||||
sessions: mockSessions.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
activity: s.activity,
|
||||
attentionLevel: getAttentionLevel(s),
|
||||
lastActivityAt: s.lastActivityAt,
|
||||
})),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
|
||||
|
||||
// Send periodic heartbeat
|
||||
heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(updates);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Simulate activity updates every 5 seconds
|
||||
updates = setInterval(() => {
|
||||
try {
|
||||
if (mockSessions.length === 0) return;
|
||||
const randomSession = mockSessions[Math.floor(Math.random() * mockSessions.length)];
|
||||
const event = {
|
||||
type: "session.activity",
|
||||
sessionId: randomSession.id,
|
||||
activity: randomSession.activity,
|
||||
status: randomSession.status,
|
||||
attentionLevel: getAttentionLevel(randomSession),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
clearInterval(updates);
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}, 5000);
|
||||
},
|
||||
cancel() {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(updates);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { mockSessions } from "@/lib/mock-data";
|
||||
|
||||
/** POST /api/prs/:id/merge — Merge a PR */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return NextResponse.json({ error: "Invalid PR number" }, { status: 400 });
|
||||
}
|
||||
const prNumber = Number(id);
|
||||
|
||||
const session = mockSessions.find((s) => s.pr?.number === prNumber);
|
||||
if (!session?.pr) {
|
||||
return NextResponse.json({ error: "PR not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (session.pr.state !== "open") {
|
||||
return NextResponse.json({ error: `PR is ${session.pr.state}, not open` }, { status: 409 });
|
||||
}
|
||||
|
||||
if (session.pr.isDraft) {
|
||||
return NextResponse.json({ error: "Cannot merge a draft PR" }, { status: 422 });
|
||||
}
|
||||
|
||||
if (!session.pr.mergeability.mergeable) {
|
||||
return NextResponse.json(
|
||||
{ error: "PR is not mergeable", blockers: session.pr.mergeability.blockers },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: wire to core SCM.mergePR()
|
||||
return NextResponse.json({ ok: true, prNumber, method: "squash" });
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
|
||||
/** POST /api/sessions/:id/kill — Kill a session */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const idErr = validateIdentifier(id, "id");
|
||||
if (idErr) {
|
||||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getMockSession(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.kill()
|
||||
return NextResponse.json({ ok: true, sessionId: id });
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
|
||||
/** Terminal states that can be restored */
|
||||
const RESTORABLE_STATUSES = new Set(["killed", "cleanup"]);
|
||||
const RESTORABLE_ACTIVITIES = new Set(["exited"]);
|
||||
|
||||
/** Statuses that must never be restored (e.g. already merged) */
|
||||
const NON_RESTORABLE_STATUSES = new Set(["merged"]);
|
||||
|
||||
/** POST /api/sessions/:id/restore — Restore a terminated session */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const idErr = validateIdentifier(id, "id");
|
||||
if (idErr) {
|
||||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getMockSession(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
return NextResponse.json({ error: "Cannot restore a merged session" }, { status: 409 });
|
||||
}
|
||||
|
||||
const isTerminal =
|
||||
RESTORABLE_STATUSES.has(session.status) || RESTORABLE_ACTIVITIES.has(session.activity);
|
||||
|
||||
if (!isTerminal) {
|
||||
return NextResponse.json({ error: "Session is not in a terminal state" }, { status: 409 });
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.restore()
|
||||
return NextResponse.json({ ok: true, sessionId: id });
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { validateIdentifier, validateString, stripControlChars } from "@/lib/validation";
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 10_000;
|
||||
|
||||
/** POST /api/sessions/:id/send — Send a message to a session */
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const idErr = validateIdentifier(id, "id");
|
||||
if (idErr) {
|
||||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getMockSession(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
const messageErr = validateString(body?.message, "message", MAX_MESSAGE_LENGTH);
|
||||
if (messageErr) {
|
||||
return NextResponse.json({ error: messageErr }, { status: 400 });
|
||||
}
|
||||
|
||||
// Strip control characters to prevent injection when passed to shell-based runtimes
|
||||
const message = stripControlChars(String(body?.message ?? ""));
|
||||
|
||||
// Re-validate after stripping — a control-char-only message becomes empty
|
||||
if (message.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "message must not be empty after sanitization" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.send()
|
||||
return NextResponse.json({ ok: true, sessionId: id, message });
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { mockSessions, getMockStats } from "@/lib/mock-data";
|
||||
|
||||
/** GET /api/sessions — List all sessions with full state */
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
sessions: mockSessions,
|
||||
stats: getMockStats(),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
|
||||
/** POST /api/spawn — Spawn a new session */
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const projectErr = validateIdentifier(body.projectId, "projectId");
|
||||
if (projectErr) {
|
||||
return NextResponse.json({ error: projectErr }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.issueId !== undefined && body.issueId !== null) {
|
||||
const issueErr = validateIdentifier(body.issueId, "issueId");
|
||||
if (issueErr) {
|
||||
return NextResponse.json({ error: issueErr }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.spawn()
|
||||
const mockSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
projectId: body.projectId as string,
|
||||
issueId: (body.issueId as string) ?? null,
|
||||
status: "spawning",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
summary: `Spawning session for ${(body.issueId as string) ?? body.projectId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
return NextResponse.json({ session: mockSession }, { status: 201 });
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: #0d1117;
|
||||
--color-bg-secondary: #161b22;
|
||||
--color-bg-tertiary: #1c2128;
|
||||
--color-border-default: #30363d;
|
||||
--color-border-muted: #21262d;
|
||||
--color-border-emphasis: #484f58;
|
||||
--color-text-primary: #e6edf3;
|
||||
--color-text-secondary: #7d8590;
|
||||
--color-text-muted: #484f58;
|
||||
--color-accent-blue: #58a6ff;
|
||||
--color-accent-purple: #bc8cff;
|
||||
--color-accent-green: #3fb950;
|
||||
--color-accent-yellow: #d29922;
|
||||
--color-accent-orange: #d18616;
|
||||
--color-accent-red: #f85149;
|
||||
--color-accent-violet: #a371f7;
|
||||
--font-mono: "SF Mono", "Menlo", "Consolas", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", system-ui, sans-serif;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
// Web dashboard layout — to be implemented
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agent Orchestrator",
|
||||
description: "Dashboard for managing parallel AI coding agents",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" className="dark">
|
||||
<body className="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// Dashboard home — to be implemented
|
||||
import { Dashboard } from "@/components/Dashboard";
|
||||
import { mockSessions, getMockStats } from "@/lib/mock-data";
|
||||
|
||||
export default function Home() {
|
||||
return <div>Agent Orchestrator Dashboard</div>;
|
||||
return <Dashboard sessions={mockSessions} stats={getMockStats()} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { SessionDetail } from "@/components/SessionDetail";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SessionPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const session = getMockSession(id);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <SessionDetail session={session} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { DashboardSession, AttentionLevel } from "@/lib/types";
|
||||
import { SessionCard } from "./SessionCard";
|
||||
|
||||
interface AttentionZoneProps {
|
||||
level: AttentionLevel;
|
||||
sessions: DashboardSession[];
|
||||
onSend?: (sessionId: string, message: string) => void;
|
||||
onKill?: (sessionId: string) => void;
|
||||
onMerge?: (prNumber: number) => void;
|
||||
onRestore?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const zoneConfig: Record<
|
||||
AttentionLevel,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
color: string;
|
||||
defaultCollapsed: boolean;
|
||||
}
|
||||
> = {
|
||||
merge: {
|
||||
label: "MERGE",
|
||||
description: "PRs ready to merge",
|
||||
color: "var(--color-accent-green)",
|
||||
defaultCollapsed: false,
|
||||
},
|
||||
respond: {
|
||||
label: "RESPOND",
|
||||
description: "Agents waiting for your input",
|
||||
color: "var(--color-accent-red)",
|
||||
defaultCollapsed: false,
|
||||
},
|
||||
review: {
|
||||
label: "REVIEW",
|
||||
description: "CI failures, changes requested, conflicts",
|
||||
color: "var(--color-accent-orange)",
|
||||
defaultCollapsed: false,
|
||||
},
|
||||
pending: {
|
||||
label: "PENDING",
|
||||
description: "Waiting on reviewer or CI",
|
||||
color: "var(--color-accent-yellow)",
|
||||
defaultCollapsed: false,
|
||||
},
|
||||
working: {
|
||||
label: "WORKING",
|
||||
description: "Agents working normally",
|
||||
color: "var(--color-accent-blue)",
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
done: {
|
||||
label: "DONE",
|
||||
description: "Merged or terminated",
|
||||
color: "var(--color-text-muted)",
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function AttentionZone({
|
||||
level,
|
||||
sessions,
|
||||
onSend,
|
||||
onKill,
|
||||
onMerge,
|
||||
onRestore,
|
||||
}: AttentionZoneProps) {
|
||||
const config = zoneConfig[level];
|
||||
const [collapsed, setCollapsed] = useState(config.defaultCollapsed);
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
className="mb-2 flex w-full items-center gap-3 px-1 text-left"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{config.description}</span>
|
||||
<span
|
||||
className="ml-auto rounded-full px-2 py-0.5 text-xs font-bold"
|
||||
style={{
|
||||
color: config.color,
|
||||
background: `color-mix(in srgb, ${config.color} 10%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{sessions.length}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{collapsed ? "\u25B6" : "\u25BC"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onSend={onSend}
|
||||
onKill={onKill}
|
||||
onMerge={onMerge}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import type { CIStatus, DashboardCICheck } from "@/lib/types";
|
||||
|
||||
interface CIBadgeProps {
|
||||
status: CIStatus;
|
||||
checks?: DashboardCICheck[];
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<CIStatus, { label: string; className: string; icon: string }> = {
|
||||
passing: {
|
||||
label: "CI passing",
|
||||
className: "bg-[rgba(63,185,80,0.1)] text-[var(--color-accent-green)]",
|
||||
icon: "\u2713",
|
||||
},
|
||||
failing: {
|
||||
label: "CI failing",
|
||||
className: "bg-[rgba(248,81,73,0.15)] text-[var(--color-accent-red)]",
|
||||
icon: "\u2717",
|
||||
},
|
||||
pending: {
|
||||
label: "CI pending",
|
||||
className: "bg-[rgba(210,153,34,0.1)] text-[var(--color-accent-yellow)]",
|
||||
icon: "\u25CF",
|
||||
},
|
||||
none: {
|
||||
label: "\u2014",
|
||||
className: "text-[var(--color-text-muted)]",
|
||||
icon: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function CIBadge({ status, checks, compact }: CIBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
const failedCount = checks?.filter((c) => c.status === "failed").length ?? 0;
|
||||
|
||||
if (status === "none") {
|
||||
return <span className="text-xs text-[var(--color-text-muted)]">—</span>;
|
||||
}
|
||||
|
||||
const label =
|
||||
status === "failing" && failedCount > 0
|
||||
? `${failedCount} check${failedCount > 1 ? "s" : ""} failing`
|
||||
: config.label;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide ${config.className}`}
|
||||
>
|
||||
{!compact && <span>{config.icon}</span>}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CICheckListProps {
|
||||
checks: DashboardCICheck[];
|
||||
/** "vertical" (default): stacked list. "inline": horizontal wrap. "expanded": stacked with linked names. */
|
||||
layout?: "vertical" | "inline" | "expanded";
|
||||
}
|
||||
|
||||
export const checkStatusIcon: Record<DashboardCICheck["status"], { icon: string; color: string }> = {
|
||||
passed: { icon: "\u2713", color: "var(--color-accent-green)" },
|
||||
failed: { icon: "\u2717", color: "var(--color-accent-red)" },
|
||||
running: { icon: "\u25CF", color: "var(--color-accent-yellow)" },
|
||||
pending: { icon: "\u25CB", color: "var(--color-text-muted)" },
|
||||
skipped: { icon: "\u25CB", color: "var(--color-text-muted)" },
|
||||
};
|
||||
|
||||
/** Sort order for CI checks: failures first, then running, pending, passed, skipped. */
|
||||
export const ciCheckSortOrder: Record<DashboardCICheck["status"], number> = {
|
||||
failed: 0,
|
||||
running: 1,
|
||||
pending: 2,
|
||||
passed: 3,
|
||||
skipped: 4,
|
||||
};
|
||||
|
||||
export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) {
|
||||
const sorted = [...checks].sort((a, b) => ciCheckSortOrder[a.status] - ciCheckSortOrder[b.status]);
|
||||
|
||||
if (layout === "inline") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{sorted.map((check) => {
|
||||
const { icon, color } = checkStatusIcon[check.status];
|
||||
const inner = (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<span style={{ color }}>{icon}</span>
|
||||
<span className="text-[var(--color-text-secondary)]">{check.name}</span>
|
||||
</span>
|
||||
);
|
||||
return check.url ? (
|
||||
<a
|
||||
key={check.name}
|
||||
href={check.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:no-underline"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<span key={check.name}>{inner}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === "expanded") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sorted.map((check) => {
|
||||
const { icon, color } = checkStatusIcon[check.status];
|
||||
const inner = (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
<span style={{ color }}>{icon}</span>
|
||||
<span className="text-[var(--color-text-secondary)]">{check.name}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div key={check.name} className="flex items-center gap-2">
|
||||
{check.url ? (
|
||||
<a href={check.url} target="_blank" rel="noopener noreferrer" className="hover:no-underline">
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
{check.url && (
|
||||
<a
|
||||
href={check.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-[var(--color-accent-blue)] hover:underline"
|
||||
>
|
||||
view
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sorted.map((check) => {
|
||||
const { icon, color } = checkStatusIcon[check.status];
|
||||
return (
|
||||
<div key={check.name} className="flex items-center gap-2 text-xs">
|
||||
<span style={{ color }} className="w-3.5 shrink-0 text-center">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-text-secondary)]">
|
||||
{check.name}
|
||||
</span>
|
||||
{check.url && (
|
||||
<a
|
||||
href={check.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 text-[11px] text-[var(--color-accent-blue)] hover:underline"
|
||||
>
|
||||
view
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import {
|
||||
type DashboardSession,
|
||||
type DashboardStats,
|
||||
type DashboardPR,
|
||||
type AttentionLevel,
|
||||
getAttentionLevel,
|
||||
} from "@/lib/types";
|
||||
import { AttentionZone } from "./AttentionZone";
|
||||
import { PRTableRow } from "./PRStatus";
|
||||
|
||||
interface DashboardProps {
|
||||
sessions: DashboardSession[];
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
export function Dashboard({ sessions, stats }: DashboardProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const zones: Record<AttentionLevel, DashboardSession[]> = {
|
||||
merge: [],
|
||||
respond: [],
|
||||
review: [],
|
||||
pending: [],
|
||||
working: [],
|
||||
done: [],
|
||||
};
|
||||
for (const session of sessions) {
|
||||
zones[getAttentionLevel(session)].push(session);
|
||||
}
|
||||
return zones;
|
||||
}, [sessions]);
|
||||
|
||||
const openPRs = useMemo(() => {
|
||||
return sessions
|
||||
.filter((s): s is DashboardSession & { pr: DashboardPR } => s.pr?.state === "open")
|
||||
.map((s) => s.pr)
|
||||
.sort((a, b) => mergeScore(a) - mergeScore(b));
|
||||
}, [sessions]);
|
||||
|
||||
const handleSend = async (sessionId: string, message: string) => {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to send message to ${sessionId}:`, await res.text());
|
||||
}
|
||||
};
|
||||
|
||||
const handleKill = async (sessionId: string) => {
|
||||
if (!confirm(`Kill session ${sessionId}?`)) return;
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/kill`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to kill ${sessionId}:`, await res.text());
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async (prNumber: number) => {
|
||||
if (!confirm(`Merge PR #${prNumber}?`)) return;
|
||||
const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to merge PR #${prNumber}:`, await res.text());
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (sessionId: string) => {
|
||||
if (!confirm(`Restore session ${sessionId}?`)) return;
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/restore`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to restore ${sessionId}:`, await res.text());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1100px] px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-7 flex items-baseline justify-between">
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
<span className="text-[#7c8aff]">Agent</span> Orchestrator
|
||||
</h1>
|
||||
<ClientTimestamp />
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="mb-9 flex gap-8 px-1">
|
||||
<Stat value={stats.totalSessions} label="sessions" color="var(--color-accent-blue)" />
|
||||
<Stat value={stats.workingSessions} label="working" color="var(--color-accent-green)" />
|
||||
<Stat value={stats.openPRs} label="open PRs" color="var(--color-accent-violet)" />
|
||||
<Stat value={stats.needsReview} label="need review" color="var(--color-accent-yellow)" />
|
||||
</div>
|
||||
|
||||
{/* Attention zones */}
|
||||
<div className="mb-9">
|
||||
<h2 className="mb-3 px-1 text-[13px] font-semibold uppercase tracking-widest text-[var(--color-text-muted)]">
|
||||
Sessions
|
||||
</h2>
|
||||
{(["merge", "respond", "review", "pending", "working", "done"] as AttentionLevel[]).map((level) => (
|
||||
<AttentionZone
|
||||
key={level}
|
||||
level={level}
|
||||
sessions={grouped[level]}
|
||||
onSend={handleSend}
|
||||
onKill={handleKill}
|
||||
onMerge={handleMerge}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* PR Table */}
|
||||
{openPRs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 px-1 text-[13px] font-semibold uppercase tracking-widest text-[var(--color-text-muted)]">
|
||||
Pull Requests
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--color-border-default)]">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--color-border-muted)]">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
PR
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
CI
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Review
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Unresolved
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{openPRs.map((pr) => (
|
||||
<PRTableRow key={pr.number} pr={pr} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders timestamp client-side only to avoid hydration mismatch. */
|
||||
function ClientTimestamp() {
|
||||
const [time, setTime] = useState<string>("");
|
||||
useEffect(() => {
|
||||
setTime(new Date().toLocaleString());
|
||||
}, []);
|
||||
return <span className="text-xs text-[var(--color-text-muted)]">{time}</span>;
|
||||
}
|
||||
|
||||
function Stat({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[28px] font-bold" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeScore(
|
||||
pr: Pick<DashboardPR, "ciStatus" | "reviewDecision" | "mergeability" | "unresolvedThreads">,
|
||||
): number {
|
||||
let score = 0;
|
||||
if (!pr.mergeability.noConflicts) score += 40;
|
||||
if (pr.ciStatus === "failing") score += 30;
|
||||
else if (pr.ciStatus === "pending") score += 5;
|
||||
if (pr.reviewDecision === "changes_requested") score += 20;
|
||||
else if (pr.reviewDecision !== "approved") score += 10;
|
||||
score += pr.unresolvedThreads * 5;
|
||||
return score;
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import type { DashboardPR } from "@/lib/types";
|
||||
import { CIBadge } from "./CIBadge";
|
||||
|
||||
function getSizeLabel(additions: number, deletions: number): string {
|
||||
const size = additions + deletions;
|
||||
return size > 1000 ? "XL" : size > 500 ? "L" : size > 200 ? "M" : size > 50 ? "S" : "XS";
|
||||
}
|
||||
|
||||
interface PRStatusProps {
|
||||
pr: DashboardPR;
|
||||
}
|
||||
|
||||
export function PRStatus({ pr }: PRStatusProps) {
|
||||
const sizeLabel = getSizeLabel(pr.additions, pr.deletions);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* PR number */}
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center rounded-full bg-[rgba(88,166,255,0.1)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-accent-blue)] hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{pr.number}
|
||||
</a>
|
||||
|
||||
{/* Size */}
|
||||
<span className="inline-flex items-center rounded-full bg-[rgba(125,133,144,0.08)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-text-muted)]">
|
||||
+{pr.additions} -{pr.deletions} {sizeLabel}
|
||||
</span>
|
||||
|
||||
{/* Merged badge */}
|
||||
{pr.state === "merged" && (
|
||||
<span className="inline-flex items-center rounded-full bg-[rgba(163,113,247,0.1)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-accent-violet)]">
|
||||
merged
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Draft badge */}
|
||||
{pr.isDraft && pr.state === "open" && (
|
||||
<span className="inline-flex items-center rounded-full bg-[rgba(125,133,144,0.08)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-text-muted)]">
|
||||
draft
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* CI status (only for open PRs) */}
|
||||
{pr.state === "open" && !pr.isDraft && <CIBadge status={pr.ciStatus} checks={pr.ciChecks} />}
|
||||
|
||||
{/* Review decision (only for open PRs) */}
|
||||
{pr.state === "open" && pr.reviewDecision === "approved" && (
|
||||
<span className="inline-flex items-center rounded-full bg-[rgba(63,185,80,0.1)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-accent-green)]">
|
||||
approved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PRTableRowProps {
|
||||
pr: DashboardPR;
|
||||
}
|
||||
|
||||
export function PRTableRow({ pr }: PRTableRowProps) {
|
||||
const sizeLabel = getSizeLabel(pr.additions, pr.deletions);
|
||||
|
||||
const reviewLabel =
|
||||
pr.isDraft
|
||||
? "draft"
|
||||
: pr.reviewDecision === "approved"
|
||||
? "approved"
|
||||
: pr.reviewDecision === "changes_requested"
|
||||
? "changes requested"
|
||||
: "needs review";
|
||||
|
||||
const reviewClass =
|
||||
pr.isDraft
|
||||
? "text-[var(--color-text-muted)]"
|
||||
: pr.reviewDecision === "approved"
|
||||
? "text-[var(--color-accent-green)]"
|
||||
: pr.reviewDecision === "changes_requested"
|
||||
? "text-[var(--color-accent-red)]"
|
||||
: "text-[var(--color-accent-yellow)]";
|
||||
|
||||
return (
|
||||
<tr className="border-b border-[var(--color-border-muted)] hover:bg-[rgba(88,166,255,0.03)]">
|
||||
<td className="px-3 py-2.5 text-sm">
|
||||
<a href={pr.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
#{pr.number}
|
||||
</a>
|
||||
</td>
|
||||
<td className="max-w-[420px] truncate px-3 py-2.5 text-sm font-medium">{pr.title}</td>
|
||||
<td className="px-3 py-2.5 text-sm">
|
||||
<span className="text-[var(--color-accent-green)]">+{pr.additions}</span>{" "}
|
||||
<span className="text-[var(--color-accent-red)]">-{pr.deletions}</span>{" "}
|
||||
<span className="text-[var(--color-text-muted)]">{sizeLabel}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<CIBadge status={pr.ciStatus} checks={pr.ciChecks} compact />
|
||||
</td>
|
||||
<td className={`px-3 py-2.5 text-xs font-semibold ${reviewClass}`}>{reviewLabel}</td>
|
||||
<td
|
||||
className={`px-3 py-2.5 text-center text-sm font-bold ${pr.unresolvedThreads > 0 ? "text-[var(--color-accent-red)]" : "text-[var(--color-border-default)]"}`}
|
||||
>
|
||||
{pr.unresolvedThreads}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { type DashboardSession, type AttentionLevel, getAttentionLevel } from "@/lib/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PRStatus } from "./PRStatus";
|
||||
import { CICheckList } from "./CIBadge";
|
||||
|
||||
interface SessionCardProps {
|
||||
session: DashboardSession;
|
||||
onSend?: (sessionId: string, message: string) => void;
|
||||
onKill?: (sessionId: string) => void;
|
||||
onMerge?: (prNumber: number) => void;
|
||||
onRestore?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const activityIcon: Record<string, string> = {
|
||||
active: "\u26A1",
|
||||
idle: "\uD83D\uDCA4",
|
||||
waiting_input: "\u2753",
|
||||
blocked: "\uD83D\uDEA7",
|
||||
exited: "\uD83D\uDC80",
|
||||
};
|
||||
|
||||
const borderColorByLevel: Record<AttentionLevel, string> = {
|
||||
merge: "border-l-[var(--color-accent-green)]",
|
||||
respond: "border-l-[var(--color-accent-red)]",
|
||||
review: "border-l-[var(--color-accent-orange)]",
|
||||
pending: "border-l-[var(--color-accent-yellow)]",
|
||||
working: "border-l-[var(--color-accent-blue)]",
|
||||
done: "border-l-[var(--color-border-default)]",
|
||||
};
|
||||
|
||||
export function SessionCard({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [sendingAction, setSendingAction] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const level = getAttentionLevel(session);
|
||||
const pr = session.pr;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAction = async (action: string, message: string) => {
|
||||
setSendingAction(action);
|
||||
onSend?.(session.id, message);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setSendingAction(null), 2000);
|
||||
};
|
||||
|
||||
const alerts = getAlerts(session);
|
||||
const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open";
|
||||
const isRestorable =
|
||||
(session.status === "killed" ||
|
||||
session.status === "cleanup" ||
|
||||
session.activity === "exited") &&
|
||||
session.status !== "merged";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer border border-[var(--color-border-default)] border-l-[3px] bg-[var(--color-bg-secondary)] transition-colors hover:border-[var(--color-border-emphasis)]",
|
||||
borderColorByLevel[level],
|
||||
expanded && "border-[var(--color-border-emphasis)]",
|
||||
isReadyToMerge && "border-[rgba(63,185,80,0.5)]",
|
||||
pr?.state === "merged" && "opacity-75",
|
||||
)}
|
||||
style={{ borderRadius: 10 }}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className="flex items-center gap-2.5 px-4 pt-3 pb-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-sm",
|
||||
session.activity === "active" && "animate-[pulse_2s_ease-in-out_infinite]",
|
||||
)}
|
||||
>
|
||||
{activityIcon[session.activity] ?? "\u2753"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[13px] font-semibold text-[var(--color-text-secondary)]">
|
||||
{session.id}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{pr?.title ?? session.summary ?? session.status}
|
||||
</span>
|
||||
{isRestorable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore?.(session.id);
|
||||
}}
|
||||
className="shrink-0 rounded-md border border-[rgba(88,166,255,0.4)] px-2.5 py-0.5 text-[11px] text-[var(--color-accent-blue)] transition-colors hover:bg-[rgba(88,166,255,0.15)]"
|
||||
>
|
||||
restore session
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 rounded-md border border-[var(--color-border-default)] px-2.5 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
|
||||
>
|
||||
terminal
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Meta row: branch + PR pills */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 pb-2 pl-[42px]">
|
||||
{session.branch && (
|
||||
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-muted)]">
|
||||
{session.branch}
|
||||
</span>
|
||||
)}
|
||||
{session.branch && pr && (
|
||||
<span className="text-[10px] text-[var(--color-border-default)]">·</span>
|
||||
)}
|
||||
{pr && <PRStatus pr={pr} />}
|
||||
</div>
|
||||
|
||||
{/* Alert tags */}
|
||||
{(alerts.length > 0 || isReadyToMerge) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-2 pl-[42px]">
|
||||
{isReadyToMerge && pr ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMerge?.(pr.number);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[rgba(63,185,80,0.4)] bg-[rgba(63,185,80,0.2)] px-3 py-1 text-[13px] font-bold text-[var(--color-accent-green)] hover:brightness-125"
|
||||
>
|
||||
merge PR #{pr.number}
|
||||
</button>
|
||||
) : (
|
||||
alerts.map((alert) => (
|
||||
<span key={alert.key}>
|
||||
<a
|
||||
href={alert.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-3 py-0.5 text-xs font-bold hover:brightness-125",
|
||||
alert.className,
|
||||
)}
|
||||
>
|
||||
{alert.count !== undefined && (
|
||||
<span className="text-sm font-extrabold">{alert.count}</span>
|
||||
)}
|
||||
{alert.label}
|
||||
</a>
|
||||
{alert.actionLabel && session.activity !== "active" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(alert.key, alert.actionMessage ?? "");
|
||||
}}
|
||||
disabled={sendingAction === alert.key}
|
||||
className="ml-1.5 rounded-md border border-[rgba(88,166,255,0.3)] px-2.5 py-0.5 text-[11px] text-[var(--color-accent-blue)] transition-colors hover:bg-[rgba(88,166,255,0.1)] disabled:opacity-50"
|
||||
>
|
||||
{sendingAction === alert.key ? "sent!" : alert.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable detail panel */}
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border-muted)] px-4 py-3 pl-[42px]">
|
||||
{session.summary && pr?.title && session.summary !== pr.title && (
|
||||
<DetailSection label="Summary">
|
||||
<p className="text-xs leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{session.summary}
|
||||
</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{session.issueId && (
|
||||
<DetailSection label="Issue">
|
||||
<span className="text-xs text-[var(--color-accent-blue)]">{session.issueId}</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{pr && pr.ciChecks.length > 0 && (
|
||||
<DetailSection label="CI Checks">
|
||||
<CICheckList checks={pr.ciChecks} />
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{pr && pr.unresolvedComments.length > 0 && (
|
||||
<DetailSection label="Unresolved Comments">
|
||||
<div className="space-y-1">
|
||||
{pr.unresolvedComments.map((c) => (
|
||||
<div key={c.url} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-3.5 shrink-0 text-center text-[var(--color-accent-red)]">
|
||||
{"\u25CF"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-text-secondary)]">
|
||||
{c.path}
|
||||
</span>
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 text-[11px] text-[var(--color-accent-blue)] hover:underline"
|
||||
>
|
||||
go to comment
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{pr && (
|
||||
<DetailSection label="PR Details">
|
||||
<p className="text-xs text-[var(--color-text-secondary)]">
|
||||
<a href={pr.url} target="_blank" rel="noopener noreferrer">
|
||||
{pr.title}
|
||||
</a>
|
||||
<br />
|
||||
<span className="text-[var(--color-accent-green)]">+{pr.additions}</span>{" "}
|
||||
<span className="text-[var(--color-accent-red)]">-{pr.deletions}</span>
|
||||
{" \u00B7 "}mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
|
||||
{" \u00B7 "}review: {pr.reviewDecision}
|
||||
</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{!pr && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
No PR associated with this session.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex gap-2 border-t border-[var(--color-border-muted)] pt-3">
|
||||
{isRestorable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore?.(session.id);
|
||||
}}
|
||||
className="rounded-md border border-[rgba(88,166,255,0.4)] px-2.5 py-0.5 text-[11px] text-[var(--color-accent-blue)] transition-colors hover:bg-[rgba(88,166,255,0.15)]"
|
||||
>
|
||||
restore session
|
||||
</button>
|
||||
)}
|
||||
{!isRestorable && session.status !== "merged" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill?.(session.id);
|
||||
}}
|
||||
className="rounded-md border border-[rgba(248,81,73,0.4)] px-2.5 py-0.5 text-[11px] text-[var(--color-accent-red)] transition-colors hover:bg-[rgba(248,81,73,0.15)]"
|
||||
>
|
||||
terminate session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Alert {
|
||||
key: string;
|
||||
label: string;
|
||||
className: string;
|
||||
url: string;
|
||||
count?: number;
|
||||
actionLabel?: string;
|
||||
actionMessage?: string;
|
||||
}
|
||||
|
||||
function getAlerts(session: DashboardSession): Alert[] {
|
||||
const pr = session.pr;
|
||||
if (!pr || pr.state !== "open") return [];
|
||||
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// CI failing
|
||||
if (pr.ciStatus === "failing") {
|
||||
const failedCheck = pr.ciChecks.find((c) => c.status === "failed");
|
||||
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
|
||||
alerts.push({
|
||||
key: "ci-fail",
|
||||
label: `${failCount} CI check${failCount > 1 ? "s" : ""} failing`,
|
||||
className:
|
||||
"border-[rgba(248,81,73,0.3)] bg-[rgba(248,81,73,0.15)] text-[var(--color-accent-red)]",
|
||||
url: failedCheck?.url ?? pr.url + "/checks",
|
||||
actionLabel: "ask to fix CI",
|
||||
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Changes requested
|
||||
if (pr.reviewDecision === "changes_requested") {
|
||||
alerts.push({
|
||||
key: "changes",
|
||||
label: "changes requested",
|
||||
className:
|
||||
"border-[rgba(248,81,73,0.3)] bg-[rgba(248,81,73,0.15)] text-[var(--color-accent-red)]",
|
||||
url: pr.url,
|
||||
});
|
||||
} else if (
|
||||
!pr.isDraft &&
|
||||
(pr.reviewDecision === "pending" || pr.reviewDecision === "none")
|
||||
) {
|
||||
alerts.push({
|
||||
key: "review",
|
||||
label: "needs review",
|
||||
className:
|
||||
"border-[rgba(210,153,34,0.3)] bg-[rgba(210,153,34,0.15)] text-[var(--color-accent-yellow)]",
|
||||
url: pr.url,
|
||||
actionLabel: "ask to post for review",
|
||||
actionMessage: `Post ${pr.url} on slack asking for a review.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge conflict
|
||||
if (!pr.mergeability.noConflicts) {
|
||||
alerts.push({
|
||||
key: "conflict",
|
||||
label: "merge conflict",
|
||||
className:
|
||||
"border-[rgba(248,81,73,0.3)] bg-[rgba(248,81,73,0.15)] text-[var(--color-accent-red)]",
|
||||
url: pr.url,
|
||||
actionLabel: "ask to fix conflicts",
|
||||
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
|
||||
});
|
||||
}
|
||||
|
||||
// Unresolved comments
|
||||
if (pr.unresolvedThreads > 0) {
|
||||
const firstUrl = pr.unresolvedComments[0]?.url ?? pr.url + "/files";
|
||||
alerts.push({
|
||||
key: "comments",
|
||||
label: "unresolved comments",
|
||||
count: pr.unresolvedThreads,
|
||||
className:
|
||||
"border-[rgba(248,81,73,0.3)] bg-[rgba(248,81,73,0.15)] text-[var(--color-accent-red)]",
|
||||
url: firstUrl,
|
||||
actionLabel: "ask to resolve",
|
||||
actionMessage: `Please address all unresolved review comments on ${pr.url}`,
|
||||
});
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
|
@ -0,0 +1,480 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
type DashboardSession,
|
||||
type DashboardPR,
|
||||
getAttentionLevel,
|
||||
} from "@/lib/types";
|
||||
import { CICheckList } from "./CIBadge";
|
||||
import { Terminal } from "./Terminal";
|
||||
|
||||
interface SessionDetailProps {
|
||||
session: DashboardSession;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const activityLabel: Record<string, { label: string; color: string }> = {
|
||||
active: { label: "Active", color: "var(--color-accent-green)" },
|
||||
idle: { label: "Idle", color: "var(--color-text-muted)" },
|
||||
waiting_input: { label: "Waiting for input", color: "var(--color-accent-yellow)" },
|
||||
blocked: { label: "Blocked", color: "var(--color-accent-red)" },
|
||||
exited: { label: "Exited", color: "var(--color-accent-red)" },
|
||||
};
|
||||
|
||||
function levelColor(level: string): string {
|
||||
switch (level) {
|
||||
case "merge":
|
||||
return "var(--color-accent-green)";
|
||||
case "respond":
|
||||
return "var(--color-accent-red)";
|
||||
case "review":
|
||||
return "var(--color-accent-orange)";
|
||||
case "pending":
|
||||
return "var(--color-accent-yellow)";
|
||||
case "working":
|
||||
return "var(--color-accent-blue)";
|
||||
default:
|
||||
return "var(--color-text-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts snake_case status enum to Title Case display string. */
|
||||
function humanizeStatus(status: string): string {
|
||||
return status
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\bci\b/gi, "CI")
|
||||
.replace(/\bpr\b/gi, "PR")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Converts ISO date string to relative time like "3h ago", "2m ago". Client-side only. */
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/** Builds a GitHub branch URL from PR owner/repo/branch. */
|
||||
function buildGitHubBranchUrl(pr: DashboardPR): string {
|
||||
return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`;
|
||||
}
|
||||
|
||||
/** Builds a GitHub repo URL from PR owner/repo. */
|
||||
function buildGitHubRepoUrl(pr: DashboardPR): string {
|
||||
return `https://github.com/${pr.owner}/${pr.repo}`;
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────
|
||||
|
||||
export function SessionDetail({ session }: SessionDetailProps) {
|
||||
const pr = session.pr;
|
||||
const level = getAttentionLevel(session);
|
||||
const activity = activityLabel[session.activity] ?? {
|
||||
label: session.activity,
|
||||
color: "var(--color-text-muted)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Nav bar */}
|
||||
<nav className="border-b border-[var(--color-border-muted)] bg-[var(--color-bg-secondary)]">
|
||||
<div className="mx-auto flex max-w-[900px] items-center px-8 py-2">
|
||||
<a
|
||||
href="/"
|
||||
className="text-xs font-medium tracking-wide text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:no-underline"
|
||||
>
|
||||
← Agent Orchestrator
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="mx-auto max-w-[900px] px-8 py-6">
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div className="mb-6">
|
||||
{/* Session ID + badges */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{session.id}</h1>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
style={{
|
||||
color: activity.color,
|
||||
background: `color-mix(in srgb, ${activity.color} 15%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{activity.label}
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-semibold uppercase"
|
||||
style={{
|
||||
color: levelColor(level),
|
||||
background: `color-mix(in srgb, ${levelColor(level)} 10%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{session.summary && (
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">{session.summary}</p>
|
||||
)}
|
||||
|
||||
{/* Meta chips: project · branch · issue */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
{pr ? (
|
||||
<a
|
||||
href={buildGitHubRepoUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md bg-[var(--color-bg-tertiary)] px-2 py-0.5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:no-underline"
|
||||
>
|
||||
{session.projectId}
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-md bg-[var(--color-bg-tertiary)] px-2 py-0.5 text-[var(--color-text-secondary)]">
|
||||
{session.projectId}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{session.branch && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]">·</span>
|
||||
{pr ? (
|
||||
<a
|
||||
href={buildGitHubBranchUrl(pr)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md bg-[var(--color-bg-tertiary)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:no-underline"
|
||||
>
|
||||
{session.branch}
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-md bg-[var(--color-bg-tertiary)] px-2 py-0.5 font-[var(--font-mono)] text-[11px] text-[var(--color-text-secondary)]">
|
||||
{session.branch}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{session.issueId && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]">·</span>
|
||||
<span className="rounded-md bg-[var(--color-bg-tertiary)] px-2 py-0.5 text-[var(--color-text-secondary)]">
|
||||
{session.issueId}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status · timestamps */}
|
||||
<ClientTimestamps
|
||||
status={session.status}
|
||||
createdAt={session.createdAt}
|
||||
lastActivityAt={session.lastActivityAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── PR Card ────────────────────────────────────────────── */}
|
||||
{pr && <PRCard pr={pr} />}
|
||||
|
||||
{/* ── Terminal ───────────────────────────────────────────── */}
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-3 text-[13px] font-semibold uppercase tracking-widest text-[var(--color-text-muted)]">
|
||||
Terminal
|
||||
</h3>
|
||||
<Terminal sessionId={session.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client-side timestamps (avoids hydration mismatch) ───────────────
|
||||
|
||||
function ClientTimestamps({
|
||||
status,
|
||||
createdAt,
|
||||
lastActivityAt,
|
||||
}: {
|
||||
status: string;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
}) {
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
const [lastActive, setLastActive] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCreated(relativeTime(createdAt));
|
||||
setLastActive(relativeTime(lastActivityAt));
|
||||
}, [createdAt, lastActivityAt]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span>{humanizeStatus(status)}</span>
|
||||
{created && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Created {created}</span>
|
||||
</>
|
||||
)}
|
||||
{lastActive && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Active {lastActive}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PR Card ──────────────────────────────────────────────────────────
|
||||
|
||||
function PRCard({ pr }: { pr: DashboardPR }) {
|
||||
const allGreen =
|
||||
pr.mergeability.mergeable &&
|
||||
pr.mergeability.ciPassing &&
|
||||
pr.mergeability.approved &&
|
||||
pr.mergeability.noConflicts;
|
||||
|
||||
const failedChecks = pr.ciChecks.filter((c) => c.status === "failed");
|
||||
const hasFailures = failedChecks.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-secondary)]">
|
||||
{/* Title row */}
|
||||
<div className="border-b border-[var(--color-border-muted)] px-4 py-3">
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-[var(--color-text-primary)] hover:text-[var(--color-accent-blue)]"
|
||||
>
|
||||
PR #{pr.number}: {pr.title}
|
||||
</a>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
<span className="text-[var(--color-accent-green)]">+{pr.additions}</span>{" "}
|
||||
<span className="text-[var(--color-accent-red)]">-{pr.deletions}</span>
|
||||
</span>
|
||||
|
||||
{pr.isDraft && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]">·</span>
|
||||
<span className="font-semibold text-[var(--color-text-muted)]">Draft</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pr.state === "merged" && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]">·</span>
|
||||
<span className="font-semibold text-[var(--color-accent-violet)]">Merged</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pr.state === "open" && (
|
||||
<>
|
||||
<span className="text-[var(--color-text-muted)]">·</span>
|
||||
<CIStatusInline status={pr.ciStatus} failedCount={failedChecks.length} />
|
||||
<span className="text-[var(--color-text-muted)]">·</span>
|
||||
<ReviewStatusInline decision={pr.reviewDecision} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3">
|
||||
{/* Ready to merge or issues list */}
|
||||
{allGreen ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--color-accent-green)]">{"\u2713"}</span>
|
||||
<span className="font-semibold text-[var(--color-accent-green)]">Ready to merge</span>
|
||||
</div>
|
||||
) : (
|
||||
<IssuesList pr={pr} />
|
||||
)}
|
||||
|
||||
{/* CI Checks — inline row */}
|
||||
{pr.ciChecks.length > 0 && (
|
||||
<div className="mt-3 border-t border-[var(--color-border-muted)] pt-3">
|
||||
<CICheckList checks={pr.ciChecks} layout={hasFailures ? "expanded" : "inline"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unresolved Comments */}
|
||||
{pr.unresolvedComments.length > 0 && (
|
||||
<div className="mt-3 border-t border-[var(--color-border-muted)] pt-3">
|
||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Unresolved Comments ({pr.unresolvedThreads})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{pr.unresolvedComments.map((c) => (
|
||||
<div
|
||||
key={c.url}
|
||||
className="rounded-md border border-[var(--color-border-muted)] bg-[var(--color-bg-primary)] p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-semibold text-[var(--color-text-secondary)]">
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-muted)]">on</span>
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-[var(--font-mono)] text-[11px] text-[var(--color-accent-blue)] hover:underline"
|
||||
>
|
||||
{c.path}
|
||||
</a>
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto shrink-0 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-accent-blue)]"
|
||||
>
|
||||
view →
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-1.5 border-l-2 border-[var(--color-border-default)] pl-2.5 text-xs leading-relaxed text-[var(--color-text-secondary)] italic">
|
||||
{c.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issues List (replaces merge readiness grid + blockers) ───────────
|
||||
|
||||
function IssuesList({ pr }: { pr: DashboardPR }) {
|
||||
const issues: Array<{ icon: string; color: string; text: string }> = [];
|
||||
|
||||
if (pr.ciStatus === "failing") {
|
||||
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
|
||||
issues.push({
|
||||
icon: "\u2717",
|
||||
color: "var(--color-accent-red)",
|
||||
text: `CI failing \u2014 ${failCount} check${failCount !== 1 ? "s" : ""} failed`,
|
||||
});
|
||||
} else if (pr.ciStatus === "pending") {
|
||||
issues.push({
|
||||
icon: "\u25CF",
|
||||
color: "var(--color-accent-yellow)",
|
||||
text: "CI pending",
|
||||
});
|
||||
}
|
||||
|
||||
if (pr.reviewDecision === "changes_requested") {
|
||||
issues.push({
|
||||
icon: "\u2717",
|
||||
color: "var(--color-accent-red)",
|
||||
text: "Changes requested",
|
||||
});
|
||||
} else if (!pr.mergeability.approved) {
|
||||
issues.push({
|
||||
icon: "\u25CB",
|
||||
color: "var(--color-text-muted)",
|
||||
text: "Not approved \u2014 awaiting reviewer",
|
||||
});
|
||||
}
|
||||
|
||||
if (!pr.mergeability.noConflicts) {
|
||||
issues.push({
|
||||
icon: "\u2717",
|
||||
color: "var(--color-accent-red)",
|
||||
text: "Merge conflicts",
|
||||
});
|
||||
}
|
||||
|
||||
if (!pr.mergeability.mergeable && issues.length === 0) {
|
||||
issues.push({
|
||||
icon: "\u25CB",
|
||||
color: "var(--color-text-muted)",
|
||||
text: "Not mergeable",
|
||||
});
|
||||
}
|
||||
|
||||
if (pr.unresolvedThreads > 0) {
|
||||
issues.push({
|
||||
icon: "\u25CF",
|
||||
color: "var(--color-accent-yellow)",
|
||||
text: `${pr.unresolvedThreads} unresolved comment${pr.unresolvedThreads !== 1 ? "s" : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (pr.isDraft) {
|
||||
issues.push({
|
||||
icon: "\u25CB",
|
||||
color: "var(--color-text-muted)",
|
||||
text: "Draft PR",
|
||||
});
|
||||
}
|
||||
|
||||
if (issues.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Issues
|
||||
</h4>
|
||||
{issues.map((issue) => (
|
||||
<div key={issue.text} className="flex items-center gap-2 text-xs">
|
||||
<span style={{ color: issue.color }}>{issue.icon}</span>
|
||||
<span className="text-[var(--color-text-secondary)]">{issue.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline status pills ──────────────────────────────────────────────
|
||||
|
||||
function CIStatusInline({ status, failedCount }: { status: string; failedCount: number }) {
|
||||
if (status === "passing") {
|
||||
return <span className="font-semibold text-[var(--color-accent-green)]">{"\u2713"} CI passing</span>;
|
||||
}
|
||||
if (status === "failing") {
|
||||
return (
|
||||
<span className="font-semibold text-[var(--color-accent-red)]">
|
||||
{"\u2717"} {failedCount} check{failedCount !== 1 ? "s" : ""} failing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "pending") {
|
||||
return <span className="font-semibold text-[var(--color-accent-yellow)]">{"\u25CF"} CI pending</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ReviewStatusInline({ decision }: { decision: string }) {
|
||||
if (decision === "approved") {
|
||||
return <span className="font-semibold text-[var(--color-accent-green)]">{"\u2713"} Approved</span>;
|
||||
}
|
||||
if (decision === "changes_requested") {
|
||||
return (
|
||||
<span className="font-semibold text-[var(--color-accent-red)]">{"\u2717"} Changes requested</span>
|
||||
);
|
||||
}
|
||||
if (decision === "pending") {
|
||||
return (
|
||||
<span className="font-semibold text-[var(--color-accent-yellow)]">
|
||||
{"\u23F3"} Pending review
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-[var(--color-text-muted)]">No review</span>;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface TerminalProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal embed placeholder.
|
||||
* Future: integrate xterm.js via the terminal-web plugin.
|
||||
*/
|
||||
export function Terminal({ sessionId }: TerminalProps) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border border-[var(--color-border-default)] bg-black",
|
||||
fullscreen && "fixed inset-0 z-50 rounded-none border-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border-default)] bg-[var(--color-bg-tertiary)] px-3 py-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[#f85149]" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[#d29922]" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[#3fb950]" />
|
||||
</div>
|
||||
<span className="font-[var(--font-mono)] text-xs text-[var(--color-text-muted)]">
|
||||
{sessionId}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="ml-auto rounded px-2 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{fullscreen ? "exit fullscreen" : "fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
fullscreen ? "h-[calc(100vh-36px)]" : "h-64",
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Terminal embed</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
xterm.js integration coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/** Concatenate class names, filtering out falsy values. */
|
||||
export function cn(...classes: (string | false | null | undefined)[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
|
@ -0,0 +1,538 @@
|
|||
import type { DashboardSession, DashboardStats } from "./types";
|
||||
|
||||
/**
|
||||
* Mock data for the dashboard.
|
||||
* Covers all attention zones: urgent, action, warning, ok, done.
|
||||
*
|
||||
* TODO: Remove this file when wiring to real data sources. These fixtures
|
||||
* are imported by API routes and pages, so they end up in the server bundle.
|
||||
* Consider moving to __fixtures__/ or gating behind an env check once
|
||||
* real SessionManager integration is in place.
|
||||
*/
|
||||
|
||||
const now = new Date();
|
||||
const ago = (minutes: number) => new Date(now.getTime() - minutes * 60000).toISOString();
|
||||
|
||||
export const mockSessions: DashboardSession[] = [
|
||||
// URGENT: needs human input — agent is blocked asking a question
|
||||
{
|
||||
id: "backend-3",
|
||||
projectId: "my-app",
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
branch: "feat/INT-1280-auth-refactor",
|
||||
issueId: "INT-1280",
|
||||
summary: "Refactoring authentication to use JWT — blocked on key rotation strategy",
|
||||
createdAt: ago(180),
|
||||
lastActivityAt: ago(2),
|
||||
pr: {
|
||||
number: 421,
|
||||
url: "https://github.com/acme/my-app/pull/421",
|
||||
title: "feat: refactor auth to use JWT with key rotation",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1280-auth-refactor",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 342,
|
||||
deletions: 187,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed", url: "https://github.com/acme/my-app/actions/runs/1" },
|
||||
{ name: "test", status: "passed", url: "https://github.com/acme/my-app/actions/runs/2" },
|
||||
{ name: "lint", status: "passed", url: "https://github.com/acme/my-app/actions/runs/3" },
|
||||
],
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// URGENT: CI failing
|
||||
{
|
||||
id: "backend-5",
|
||||
projectId: "my-app",
|
||||
status: "ci_failed",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1295-rate-limiting",
|
||||
issueId: "INT-1295",
|
||||
summary: "Adding rate limiting middleware — CI failing on integration tests",
|
||||
createdAt: ago(120),
|
||||
lastActivityAt: ago(15),
|
||||
pr: {
|
||||
number: 428,
|
||||
url: "https://github.com/acme/my-app/pull/428",
|
||||
title: "feat: add rate limiting middleware with Redis backend",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1295-rate-limiting",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 567,
|
||||
deletions: 23,
|
||||
ciStatus: "failing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed", url: "https://github.com/acme/my-app/actions/runs/10" },
|
||||
{
|
||||
name: "unit-tests",
|
||||
status: "passed",
|
||||
url: "https://github.com/acme/my-app/actions/runs/11",
|
||||
},
|
||||
{
|
||||
name: "integration-tests",
|
||||
status: "failed",
|
||||
url: "https://github.com/acme/my-app/actions/runs/12",
|
||||
},
|
||||
{ name: "lint", status: "passed", url: "https://github.com/acme/my-app/actions/runs/13" },
|
||||
],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["CI checks failing", "Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// URGENT: changes requested + unresolved comments
|
||||
{
|
||||
id: "frontend-2",
|
||||
projectId: "my-app",
|
||||
status: "changes_requested",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1301-search-ui",
|
||||
issueId: "INT-1301",
|
||||
summary: "Implementing search UI — changes requested by reviewer",
|
||||
createdAt: ago(300),
|
||||
lastActivityAt: ago(45),
|
||||
pr: {
|
||||
number: 415,
|
||||
url: "https://github.com/acme/my-app/pull/415",
|
||||
title: "feat: add full-text search with autocomplete",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1301-search-ui",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 891,
|
||||
deletions: 156,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
{ name: "e2e", status: "passed" },
|
||||
],
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Changes requested"],
|
||||
},
|
||||
unresolvedThreads: 3,
|
||||
unresolvedComments: [
|
||||
{
|
||||
url: "https://github.com/acme/my-app/pull/415#discussion_r1",
|
||||
path: "src/components/Search.tsx",
|
||||
author: "reviewer1",
|
||||
body: "This should debounce the API calls",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/acme/my-app/pull/415#discussion_r2",
|
||||
path: "src/hooks/useSearch.ts",
|
||||
author: "reviewer1",
|
||||
body: "Missing error handling for network failures",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/acme/my-app/pull/415#discussion_r3",
|
||||
path: "src/components/SearchResults.tsx",
|
||||
author: "reviewer2",
|
||||
body: "Accessibility: needs aria-live region for results",
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// ACTION: ready to merge
|
||||
{
|
||||
id: "backend-7",
|
||||
projectId: "my-app",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1310-health-check",
|
||||
issueId: "INT-1310",
|
||||
summary: "Added health check endpoint with dependency status",
|
||||
createdAt: ago(90),
|
||||
lastActivityAt: ago(30),
|
||||
pr: {
|
||||
number: 432,
|
||||
url: "https://github.com/acme/my-app/pull/432",
|
||||
title: "feat: add /health endpoint with dependency checks",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1310-health-check",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 145,
|
||||
deletions: 8,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// ACTION: ready to merge (another one)
|
||||
{
|
||||
id: "frontend-4",
|
||||
projectId: "my-app",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
branch: "fix/INT-1315-date-picker",
|
||||
issueId: "INT-1315",
|
||||
summary: "Fixed date picker timezone handling",
|
||||
createdAt: ago(60),
|
||||
lastActivityAt: ago(20),
|
||||
pr: {
|
||||
number: 435,
|
||||
url: "https://github.com/acme/my-app/pull/435",
|
||||
title: "fix: date picker timezone conversion for UTC users",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "fix/INT-1315-date-picker",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 47,
|
||||
deletions: 12,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// WARNING: PR open, needs review
|
||||
{
|
||||
id: "backend-8",
|
||||
projectId: "my-app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1318-websocket",
|
||||
issueId: "INT-1318",
|
||||
summary: "WebSocket support for real-time notifications",
|
||||
createdAt: ago(240),
|
||||
lastActivityAt: ago(60),
|
||||
pr: {
|
||||
number: 430,
|
||||
url: "https://github.com/acme/my-app/pull/430",
|
||||
title: "feat: WebSocket server for real-time push notifications",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1318-websocket",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 723,
|
||||
deletions: 45,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
{ name: "integration-tests", status: "passed" },
|
||||
],
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// WARNING: CI pending
|
||||
{
|
||||
id: "frontend-5",
|
||||
projectId: "my-app",
|
||||
status: "pr_open",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1322-dark-mode",
|
||||
issueId: "INT-1322",
|
||||
summary: "Implementing dark mode theme toggle",
|
||||
createdAt: ago(45),
|
||||
lastActivityAt: ago(10),
|
||||
pr: {
|
||||
number: 438,
|
||||
url: "https://github.com/acme/my-app/pull/438",
|
||||
title: "feat: dark mode with system preference detection",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1322-dark-mode",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 312,
|
||||
deletions: 89,
|
||||
ciStatus: "pending",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "running" },
|
||||
{ name: "test", status: "pending" },
|
||||
{ name: "lint", status: "passed" },
|
||||
],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["CI checks pending", "Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: actively working
|
||||
{
|
||||
id: "backend-9",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/INT-1325-caching",
|
||||
issueId: "INT-1325",
|
||||
summary: "Implementing Redis caching layer for API responses",
|
||||
createdAt: ago(30),
|
||||
lastActivityAt: ago(0),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: actively working with PR
|
||||
{
|
||||
id: "frontend-6",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/INT-1328-notifications",
|
||||
issueId: "INT-1328",
|
||||
summary: "Building notification center component",
|
||||
createdAt: ago(55),
|
||||
lastActivityAt: ago(0),
|
||||
pr: {
|
||||
number: 440,
|
||||
url: "https://github.com/acme/my-app/pull/440",
|
||||
title: "feat: notification center with mark-as-read",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1328-notifications",
|
||||
baseBranch: "main",
|
||||
isDraft: true,
|
||||
state: "open",
|
||||
additions: 234,
|
||||
deletions: 0,
|
||||
ciStatus: "pending",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "running" },
|
||||
{ name: "test", status: "pending" },
|
||||
],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Draft PR", "CI pending"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: actively working — has merge conflict on PR
|
||||
{
|
||||
id: "backend-11",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/INT-1335-email-templates",
|
||||
issueId: "INT-1335",
|
||||
summary: "Implementing email template engine with MJML",
|
||||
createdAt: ago(100),
|
||||
lastActivityAt: ago(1),
|
||||
pr: {
|
||||
number: 442,
|
||||
url: "https://github.com/acme/my-app/pull/442",
|
||||
title: "feat: email template engine with MJML support",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1335-email-templates",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 489,
|
||||
deletions: 32,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed", url: "https://github.com/acme/my-app/actions/runs/20" },
|
||||
{ name: "test", status: "passed", url: "https://github.com/acme/my-app/actions/runs/21" },
|
||||
{ name: "lint", status: "passed", url: "https://github.com/acme/my-app/actions/runs/22" },
|
||||
],
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: false,
|
||||
blockers: ["Merge conflict", "Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: spawning
|
||||
{
|
||||
id: "backend-10",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
issueId: "INT-1330",
|
||||
summary: "Setting up workspace for database migration task",
|
||||
createdAt: ago(1),
|
||||
lastActivityAt: ago(0),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// DONE: merged
|
||||
{
|
||||
id: "backend-1",
|
||||
projectId: "my-app",
|
||||
status: "merged",
|
||||
activity: "exited",
|
||||
branch: "feat/INT-1260-logging",
|
||||
issueId: "INT-1260",
|
||||
summary: "Structured logging with correlation IDs",
|
||||
createdAt: ago(480),
|
||||
lastActivityAt: ago(120),
|
||||
pr: {
|
||||
number: 410,
|
||||
url: "https://github.com/acme/my-app/pull/410",
|
||||
title: "feat: structured logging with request correlation",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1260-logging",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "merged",
|
||||
additions: 456,
|
||||
deletions: 234,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// DONE: killed
|
||||
{
|
||||
id: "frontend-1",
|
||||
projectId: "my-app",
|
||||
status: "killed",
|
||||
activity: "exited",
|
||||
branch: "feat/INT-1270-table",
|
||||
issueId: "INT-1270",
|
||||
summary: "Data table component (superseded by INT-1301)",
|
||||
createdAt: ago(600),
|
||||
lastActivityAt: ago(300),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
export function getMockStats(): DashboardStats {
|
||||
const sessions = mockSessions;
|
||||
return {
|
||||
totalSessions: sessions.length,
|
||||
workingSessions: sessions.filter((s) => s.activity === "active").length,
|
||||
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
|
||||
needsReview: sessions.filter(
|
||||
(s) =>
|
||||
s.pr &&
|
||||
!s.pr.isDraft &&
|
||||
(s.pr.reviewDecision === "pending" || s.pr.reviewDecision === "none"),
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockSession(id: string): DashboardSession | null {
|
||||
return mockSessions.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Dashboard-specific types for the web UI.
|
||||
*
|
||||
* Core types (SessionStatus, ActivityState, CIStatus, ReviewDecision, etc.)
|
||||
* are re-exported from @agent-orchestrator/core. Dashboard-specific types
|
||||
* extend/flatten the core types for client-side rendering (e.g. DashboardPR
|
||||
* flattens core PRInfo + MergeReadiness + CICheck[] + ReviewComment[]).
|
||||
*/
|
||||
|
||||
// Re-export core types used directly by the dashboard
|
||||
export type {
|
||||
SessionStatus,
|
||||
ActivityState,
|
||||
CIStatus,
|
||||
ReviewDecision,
|
||||
MergeReadiness,
|
||||
PRState,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
import type {
|
||||
CICheck as CoreCICheck,
|
||||
MergeReadiness,
|
||||
CIStatus,
|
||||
SessionStatus,
|
||||
ActivityState,
|
||||
ReviewDecision,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
/**
|
||||
* Attention zone priority level, ordered by human action urgency:
|
||||
*
|
||||
* 1. merge — PR approved + CI green. One click to clear. Highest ROI.
|
||||
* 2. respond — Agent waiting for human input. Quick unblock, agent resumes.
|
||||
* 3. review — CI failed, changes requested, conflicts. Needs investigation.
|
||||
* 4. pending — Waiting on external (reviewer, CI). Nothing to do right now.
|
||||
* 5. working — Agents doing their thing. Don't interrupt.
|
||||
* 6. done — Merged or terminated. Archive.
|
||||
*/
|
||||
export type AttentionLevel = "merge" | "respond" | "review" | "pending" | "working" | "done";
|
||||
|
||||
/**
|
||||
* Flattened session for dashboard rendering.
|
||||
* Maps to core Session but uses string dates (JSON-serializable for SSR/client boundary)
|
||||
* and inlines PR state.
|
||||
*
|
||||
* TODO: When wiring to real data, add a serialization layer that converts
|
||||
* core Session (Date objects) → DashboardSession (string dates).
|
||||
*/
|
||||
export interface DashboardSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
status: SessionStatus;
|
||||
activity: ActivityState;
|
||||
branch: string | null;
|
||||
issueId: string | null;
|
||||
summary: string | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
pr: DashboardPR | null;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened PR for dashboard rendering.
|
||||
* Aggregates core PRInfo + PRState + CICheck[] + MergeReadiness + ReviewComment[].
|
||||
*/
|
||||
export interface DashboardPR {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
isDraft: boolean;
|
||||
state: "open" | "merged" | "closed";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
ciStatus: CIStatus;
|
||||
ciChecks: DashboardCICheck[];
|
||||
reviewDecision: ReviewDecision;
|
||||
mergeability: DashboardMergeability;
|
||||
unresolvedThreads: number;
|
||||
unresolvedComments: DashboardUnresolvedComment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors core CICheck but omits Date fields (not JSON-serializable).
|
||||
* Core CICheck also has conclusion, startedAt, completedAt.
|
||||
*/
|
||||
export interface DashboardCICheck {
|
||||
name: string;
|
||||
status: CoreCICheck["status"];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same shape as core MergeReadiness — re-exported for convenience.
|
||||
*/
|
||||
export type DashboardMergeability = MergeReadiness;
|
||||
|
||||
export interface DashboardUnresolvedComment {
|
||||
url: string;
|
||||
path: string;
|
||||
author: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalSessions: number;
|
||||
workingSessions: number;
|
||||
openPRs: number;
|
||||
needsReview: number;
|
||||
}
|
||||
|
||||
/** SSE snapshot event from /api/events */
|
||||
export interface SSESnapshotEvent {
|
||||
type: "snapshot";
|
||||
sessions: Array<{
|
||||
id: string;
|
||||
status: SessionStatus;
|
||||
activity: ActivityState;
|
||||
attentionLevel: AttentionLevel;
|
||||
lastActivityAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** SSE activity update event from /api/events */
|
||||
export interface SSEActivityEvent {
|
||||
type: "session.activity";
|
||||
sessionId: string;
|
||||
activity: ActivityState;
|
||||
status: SessionStatus;
|
||||
attentionLevel: AttentionLevel;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Determines which attention zone a session belongs to */
|
||||
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
|
||||
// ── Done: terminal states ─────────────────────────────────────────
|
||||
if (session.status === "merged" || session.status === "killed" || session.status === "cleanup") {
|
||||
return "done";
|
||||
}
|
||||
if (session.pr) {
|
||||
if (session.pr.state === "merged" || session.pr.state === "closed") {
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge: PR is ready — one click to clear ───────────────────────
|
||||
// Check this early: if the PR is mergeable, that's the most valuable
|
||||
// action for the human regardless of agent activity.
|
||||
if (session.status === "mergeable" || session.status === "approved") {
|
||||
return "merge";
|
||||
}
|
||||
if (session.pr?.mergeability.mergeable) {
|
||||
return "merge";
|
||||
}
|
||||
|
||||
// ── Respond: agent is waiting for human input ─────────────────────
|
||||
if (session.activity === "waiting_input" || session.activity === "blocked") {
|
||||
return "respond";
|
||||
}
|
||||
if (
|
||||
session.status === "needs_input" ||
|
||||
session.status === "stuck" ||
|
||||
session.status === "errored"
|
||||
) {
|
||||
return "respond";
|
||||
}
|
||||
// Exited agent with non-terminal status = crashed, needs human attention
|
||||
if (session.activity === "exited") {
|
||||
return "respond";
|
||||
}
|
||||
|
||||
// ── Review: problems that need investigation ──────────────────────
|
||||
if (session.status === "ci_failed" || session.status === "changes_requested") {
|
||||
return "review";
|
||||
}
|
||||
if (session.pr) {
|
||||
const pr = session.pr;
|
||||
if (pr.ciStatus === "failing") return "review";
|
||||
if (pr.reviewDecision === "changes_requested") return "review";
|
||||
if (!pr.mergeability.noConflicts) return "review";
|
||||
}
|
||||
|
||||
// ── Pending: waiting on external (reviewer, CI) ───────────────────
|
||||
if (session.status === "review_pending") {
|
||||
return "pending";
|
||||
}
|
||||
if (session.pr) {
|
||||
const pr = session.pr;
|
||||
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
|
||||
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Working: agents doing their thing ─────────────────────────────
|
||||
return "working";
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/** Validate that a value is a non-empty string within a max length. Returns error message or null. */
|
||||
export function validateString(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
maxLength: number,
|
||||
): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return `${fieldName} is required`;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return `${fieldName} must be a string`;
|
||||
}
|
||||
if (value.trim().length === 0) {
|
||||
return `${fieldName} must not be empty`;
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
return `${fieldName} must be at most ${maxLength} characters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Validate that a value matches a safe identifier pattern (alphanumeric, hyphens, underscores). */
|
||||
export function validateIdentifier(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
maxLength = 128,
|
||||
): string | null {
|
||||
const strErr = validateString(value, fieldName, maxLength);
|
||||
if (strErr) return strErr;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value as string)) {
|
||||
return `${fieldName} must match [a-zA-Z0-9_-]+`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control characters (U+0000–U+001F, U+007F–U+009F) from a string.
|
||||
* Critical for messages that may be passed to shell-based runtimes (tmux send-keys, etc.)
|
||||
* to prevent command injection via control sequences.
|
||||
*/
|
||||
export function stripControlChars(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "preserve",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"verbatimModuleSyntax": false
|
||||
},
|
||||
"include": ["src", "next-env.d.ts", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "src", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/__tests__/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
1944
pnpm-lock.yaml
1944
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue