From 61ef52787b36804b31268edcd3096adc7d28677f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 10 Mar 2026 23:02:45 -0700 Subject: [PATCH 01/15] refactor: extract shared server infra and theme CSS Extract startServer() into serve.ts to deduplicate port detection, retry logic, and remote session handling across plan/review/annotate servers. Move theme CSS variables from review-editor into shared packages/ui/styles/theme.css. Add shared timeFormat utility. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 41 +++- package.json | 6 +- packages/review-editor/index.css | 123 +----------- packages/server/annotate.ts | 159 ++++++--------- packages/server/index.ts | 58 +----- packages/server/package.json | 1 + packages/server/review.ts | 328 ++++++++++++++----------------- packages/server/serve.ts | 84 ++++++++ packages/server/sessions.ts | 2 +- packages/shared/package.json | 3 +- packages/ui/styles/theme.css | 129 ++++++++++++ packages/ui/utils/timeFormat.ts | 13 ++ 12 files changed, 487 insertions(+), 460 deletions(-) create mode 100644 packages/server/serve.ts create mode 100644 packages/ui/styles/theme.css create mode 100644 packages/ui/utils/timeFormat.ts diff --git a/bun.lock b/bun.lock index a5be0522..51a44249 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,27 @@ "happy-dom": "^20.5.0", }, }, + "apps/checklist": { + "name": "@plannotator/checklist", + "version": "0.0.1", + "dependencies": { + "@plannotator/checklist-editor": "workspace:*", + "@plannotator/server": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@tailwindcss/vite": "^4.1.18", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + }, + }, "apps/hook": { "name": "@plannotator/hooks", "version": "0.0.1", @@ -53,7 +74,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.11.2", + "version": "0.11.4", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -74,7 +95,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.11.2", + "version": "0.11.4", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -129,6 +150,16 @@ "typescript": "^5.0.0", }, }, + "packages/checklist-editor": { + "name": "@plannotator/checklist-editor", + "version": "0.0.1", + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + }, + }, "packages/editor": { "name": "@plannotator/editor", "version": "0.0.1", @@ -154,7 +185,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.11.2", + "version": "0.11.4", "dependencies": { "@plannotator/shared": "workspace:*", }, @@ -579,6 +610,10 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@plannotator/checklist": ["@plannotator/checklist@workspace:apps/checklist"], + + "@plannotator/checklist-editor": ["@plannotator/checklist-editor@workspace:packages/checklist-editor"], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], diff --git a/package.json b/package.json index 6cb8ec54..57f3da45 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,15 @@ "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", + "dev:checklist": "bun run --cwd apps/checklist dev", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", "build:opencode": "bun run --cwd apps/opencode-plugin build", "build:review": "bun run --cwd apps/review build", - "build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build", - "build": "bun run build:hook && bun run build:opencode", + "build:checklist": "bun run --cwd apps/checklist build", + "build:pi": "bun run build:review && bun run build:checklist && bun run build:hook && bun run --cwd apps/pi-extension build", + "build": "bun run build:checklist && bun run build:hook && bun run build:opencode", "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index b9dd71a5..877e768c 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -6,128 +6,7 @@ @source "./*.tsx"; @source "./components/**/*.tsx"; -:root { - --background: oklch(0.15 0.02 260); - --foreground: oklch(0.90 0.01 260); - --card: oklch(0.22 0.02 260); - --card-foreground: oklch(0.90 0.01 260); - --popover: oklch(0.28 0.025 260); - --popover-foreground: oklch(0.90 0.01 260); - --primary: oklch(0.75 0.18 280); - --primary-foreground: oklch(0.15 0.02 260); - --secondary: oklch(0.65 0.15 180); - --secondary-foreground: oklch(0.15 0.02 260); - --muted: oklch(0.26 0.02 260); - --muted-foreground: oklch(0.72 0.02 260); - --accent: oklch(0.70 0.20 60); - --accent-foreground: oklch(0.15 0.02 260); - --destructive: oklch(0.65 0.20 25); - --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.35 0.02 260); - --input: oklch(0.26 0.02 260); - --ring: oklch(0.75 0.18 280); - --success: oklch(0.72 0.17 150); - --success-foreground: oklch(0.15 0.02 260); - --warning: oklch(0.75 0.15 85); - --warning-foreground: oklch(0.20 0.02 260); - - --font-sans: 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; - --radius: 0.75rem; -} - -.light { - --background: oklch(0.97 0.005 260); - --foreground: oklch(0.18 0.02 260); - --card: oklch(1 0 0); - --card-foreground: oklch(0.18 0.02 260); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.18 0.02 260); - --primary: oklch(0.50 0.25 280); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.50 0.18 180); - --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.92 0.01 260); - --muted-foreground: oklch(0.40 0.02 260); - --accent: oklch(0.60 0.22 50); - --accent-foreground: oklch(0.18 0.02 260); - --destructive: oklch(0.50 0.25 25); - --destructive-foreground: oklch(1 0 0); - --border: oklch(0.88 0.01 260); - --input: oklch(0.92 0.01 260); - --ring: oklch(0.50 0.25 280); - --success: oklch(0.45 0.20 150); - --success-foreground: oklch(1 0 0); - --warning: oklch(0.55 0.18 85); - --warning-foreground: oklch(0.18 0.02 260); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -* { - border-color: var(--border); -} - -body { - font-family: var(--font-sans); - background: var(--background); - color: var(--foreground); - font-feature-settings: "ss01", "ss02", "cv01"; -} - -/* Custom scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } - -/* Selection */ -::selection { - background: oklch(0.75 0.18 280 / 0.3); -} - -/* Smooth transitions */ -* { - transition-property: color, background-color, border-color, box-shadow, opacity, transform; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -/* Focus states */ -:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} +@import "../ui/styles/theme.css"; /* ===================================================== Code Review Specific Styles diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 28a380e5..b11b14ee 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,7 +11,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { startServer } from "./serve"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; @@ -58,9 +58,6 @@ export interface AnnotateServerResult { // --- Server Implementation --- -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - /** * Start the Annotate server * @@ -82,8 +79,6 @@ export async function startAnnotateServer( onReady, } = options; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); const draftKey = contentHash(markdown); // Detect repo info (cached for this session) @@ -101,114 +96,78 @@ export async function startAnnotateServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - port: configuredPort, - - async fetch(req) { - const url = new URL(req.url); - - // API: Get plan content (reuse /api/plan so the plan editor UI works) - if (url.pathname === "/api/plan" && req.method === "GET") { - return Response.json({ - plan: markdown, - origin, - mode: "annotate", - filePath, - sharingEnabled, - shareBaseUrl, - repoInfo, - }); - } - - // API: Serve images (local paths or temp uploads) - if (url.pathname === "/api/image") { - return handleImage(req); - } - - // API: Upload image -> save to temp -> return path - if (url.pathname === "/api/upload" && req.method === "POST") { - return handleUpload(req); - } - - // API: Annotation draft persistence - if (url.pathname === "/api/draft") { - if (req.method === "POST") return handleDraftSave(req, draftKey); - if (req.method === "DELETE") return handleDraftDelete(draftKey); - return handleDraftLoad(draftKey); - } - - // API: Submit annotation feedback - if (url.pathname === "/api/feedback" && req.method === "POST") { - try { - const body = (await req.json()) as { - feedback: string; - annotations: unknown[]; - }; - - deleteDraft(draftKey); - resolveDecision({ - feedback: body.feedback || "", - annotations: body.annotations || [], - }); - - return Response.json({ ok: true }); - } catch (err) { - const message = - err instanceof Error - ? err.message - : "Failed to process feedback"; - return Response.json({ error: message }, { status: 500 }); - } - } - - // Serve embedded HTML for all other routes (SPA) - return new Response(htmlContent, { - headers: { "Content-Type": "text/html" }, - }); - }, - }); + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); + + // API: Get plan content (reuse /api/plan so the plan editor UI works) + if (url.pathname === "/api/plan" && req.method === "GET") { + return Response.json({ + plan: markdown, + origin, + mode: "annotate", + filePath, + sharingEnabled, + shareBaseUrl, + repoInfo, + }); + } - break; // Success, exit retry loop - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + return handleImage(req); + } - if (isAddressInUse && attempt < MAX_RETRIES) { - await Bun.sleep(RETRY_DELAY_MS); - continue; + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req); } - if (isAddressInUse) { - const hint = isRemote - ? " (set PLANNOTATOR_PORT to use different port)" - : ""; - throw new Error( - `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}` - ); + // API: Annotation draft persistence + if (url.pathname === "/api/draft") { + if (req.method === "POST") return handleDraftSave(req, draftKey); + if (req.method === "DELETE") return handleDraftDelete(draftKey); + return handleDraftLoad(draftKey); } - throw err; - } - } + // API: Submit annotation feedback + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as { + feedback: string; + annotations: unknown[]; + }; + + deleteDraft(draftKey); + resolveDecision({ + feedback: body.feedback || "", + annotations: body.annotations || [], + }); - if (!server) { - throw new Error("Failed to start server"); - } + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to process feedback"; + return Response.json({ error: message }, { status: 500 }); + } + } - const serverUrl = `http://localhost:${server.port}`; + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/index.ts b/packages/server/index.ts index e8fe38d7..35bd78ad 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -10,7 +10,7 @@ */ import { resolve } from "path"; -import { isRemoteSession, getServerPort } from "./remote"; +import { startServer } from "./serve"; import { openEditorDiff } from "./ide"; import { saveToObsidian, @@ -90,9 +90,6 @@ export interface ServerResult { // --- Server Implementation --- -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - /** * Start the Plannotator server * @@ -107,8 +104,6 @@ export async function startPlannotatorServer( ): Promise { const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady } = options; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); const draftKey = contentHash(plan); const editorAnnotations = createEditorAnnotationHandler(); @@ -132,7 +127,6 @@ export async function startPlannotatorServer( project, }; - // Decision promise let resolveDecision: (result: { approved: boolean; @@ -151,19 +145,12 @@ export async function startPlannotatorServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - port: configuredPort, + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); - async fetch(req) { - const url = new URL(req.url); - - // API: Get a specific plan version from history - if (url.pathname === "/api/plan/version") { + // API: Get a specific plan version from history + if (url.pathname === "/api/plan/version") { const vParam = url.searchParams.get("v"); if (!vParam) { return new Response("Missing v parameter", { status: 400 }); @@ -426,41 +413,16 @@ export async function startPlannotatorServer( return new Response(htmlContent, { headers: { "Content-Type": "text/html" }, }); - }, - }); - - break; // Success, exit retry loop - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); - - if (isAddressInUse && attempt < MAX_RETRIES) { - await Bun.sleep(RETRY_DELAY_MS); - continue; - } - - if (isAddressInUse) { - const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : ""; - throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`); - } - - throw err; - } - } - - if (!server) { - throw new Error("Failed to start server"); - } - - const serverUrl = `http://localhost:${server.port}`; + }, + }); // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/package.json b/packages/server/package.json index d6addea8..6b98a73c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -9,6 +9,7 @@ ".": "./index.ts", "./review": "./review.ts", "./annotate": "./annotate.ts", + "./checklist": "./checklist.ts", "./remote": "./remote.ts", "./browser": "./browser.ts", "./storage": "./storage.ts", diff --git a/packages/server/review.ts b/packages/server/review.ts index bb16c9f9..9452fcc9 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,7 +9,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { startServer } from "./serve"; import { type DiffType, type GitContext, runGitDiff, getFileContentsForDiff, gitAddFile, gitResetFile, parseWorktreeDiffType, validateFilePath } from "./git"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers"; @@ -68,9 +68,6 @@ export interface ReviewServerResult { // --- Server Implementation --- -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - /** * Start the Code Review server * @@ -93,9 +90,6 @@ export async function startReviewServer( let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); - // Detect repo info (cached for this session) const repoInfo = await getRepoInfo(); @@ -113,211 +107,179 @@ export async function startReviewServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - port: configuredPort, - - async fetch(req) { - const url = new URL(req.url); - - // API: Get diff content - if (url.pathname === "/api/diff" && req.method === "GET") { - return Response.json({ - rawPatch: currentPatch, - gitRef: currentGitRef, - origin, - diffType: currentDiffType, - gitContext, - sharingEnabled, - shareBaseUrl, - repoInfo, - ...(currentError && { error: currentError }), - }); - } + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); + + // API: Get diff content + if (url.pathname === "/api/diff" && req.method === "GET") { + return Response.json({ + rawPatch: currentPatch, + gitRef: currentGitRef, + origin, + diffType: currentDiffType, + gitContext, + sharingEnabled, + shareBaseUrl, + repoInfo, + ...(currentError && { error: currentError }), + }); + } - // API: Switch diff type - if (url.pathname === "/api/diff/switch" && req.method === "POST") { - try { - const body = (await req.json()) as { diffType: DiffType }; - let newDiffType = body.diffType; - - if (!newDiffType) { - return Response.json( - { error: "Missing diffType" }, - { status: 400 } - ); - } - - const defaultBranch = gitContext?.defaultBranch || "main"; - - // Run the new diff - const result = await runGitDiff(newDiffType, defaultBranch); - - // Update state - currentPatch = result.patch; - currentGitRef = result.label; - currentDiffType = newDiffType; - currentError = result.error; - - return Response.json({ - rawPatch: currentPatch, - gitRef: currentGitRef, - diffType: currentDiffType, - ...(currentError && { error: currentError }), - }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to switch diff"; - return Response.json({ error: message }, { status: 500 }); - } - } + // API: Switch diff type + if (url.pathname === "/api/diff/switch" && req.method === "POST") { + try { + const body = (await req.json()) as { diffType: DiffType }; + let newDiffType = body.diffType; - // API: Get file content for expandable diff context - if (url.pathname === "/api/file-content" && req.method === "GET") { - const filePath = url.searchParams.get("path"); - if (!filePath) { - return Response.json({ error: "Missing path" }, { status: 400 }); - } - try { validateFilePath(filePath); } catch { - return Response.json({ error: "Invalid path" }, { status: 400 }); - } - const oldPath = url.searchParams.get("oldPath") || undefined; - if (oldPath) { - try { validateFilePath(oldPath); } catch { - return Response.json({ error: "Invalid path" }, { status: 400 }); - } - } - const defaultBranch = gitContext?.defaultBranch || "main"; - const result = await getFileContentsForDiff( - currentDiffType, - defaultBranch, - filePath, - oldPath, + if (!newDiffType) { + return Response.json( + { error: "Missing diffType" }, + { status: 400 } ); - return Response.json(result); } - // API: Git add / reset (stage / unstage) a file - if (url.pathname === "/api/git-add" && req.method === "POST") { - try { - const body = (await req.json()) as { filePath: string; undo?: boolean }; - if (!body.filePath) { - return Response.json({ error: "Missing filePath" }, { status: 400 }); - } - - // Determine cwd for worktree support - let cwd: string | undefined; - if (currentDiffType.startsWith("worktree:")) { - const parsed = parseWorktreeDiffType(currentDiffType); - if (parsed) cwd = parsed.path; - } - - if (body.undo) { - await gitResetFile(body.filePath, cwd); - } else { - await gitAddFile(body.filePath, cwd); - } - - return Response.json({ ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to git add"; - return Response.json({ error: message }, { status: 500 }); - } - } + const defaultBranch = gitContext?.defaultBranch || "main"; - // API: Serve images (local paths or temp uploads) - if (url.pathname === "/api/image") { - return handleImage(req); - } + // Run the new diff + const result = await runGitDiff(newDiffType, defaultBranch); + + // Update state + currentPatch = result.patch; + currentGitRef = result.label; + currentDiffType = newDiffType; + currentError = result.error; + + return Response.json({ + rawPatch: currentPatch, + gitRef: currentGitRef, + diffType: currentDiffType, + ...(currentError && { error: currentError }), + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to switch diff"; + return Response.json({ error: message }, { status: 500 }); + } + } - // API: Upload image -> save to temp -> return path - if (url.pathname === "/api/upload" && req.method === "POST") { - return handleUpload(req); + // API: Get file content for expandable diff context + if (url.pathname === "/api/file-content" && req.method === "GET") { + const filePath = url.searchParams.get("path"); + if (!filePath) { + return Response.json({ error: "Missing path" }, { status: 400 }); + } + try { validateFilePath(filePath); } catch { + return Response.json({ error: "Invalid path" }, { status: 400 }); + } + const oldPath = url.searchParams.get("oldPath") || undefined; + if (oldPath) { + try { validateFilePath(oldPath); } catch { + return Response.json({ error: "Invalid path" }, { status: 400 }); } + } + const defaultBranch = gitContext?.defaultBranch || "main"; + const result = await getFileContentsForDiff( + currentDiffType, + defaultBranch, + filePath, + oldPath, + ); + return Response.json(result); + } - // API: Get available agents (OpenCode only) - if (url.pathname === "/api/agents") { - return handleAgents(options.opencodeClient); + // API: Git add / reset (stage / unstage) a file + if (url.pathname === "/api/git-add" && req.method === "POST") { + try { + const body = (await req.json()) as { filePath: string; undo?: boolean }; + if (!body.filePath) { + return Response.json({ error: "Missing filePath" }, { status: 400 }); } - // API: Annotation draft persistence - if (url.pathname === "/api/draft") { - if (req.method === "POST") return handleDraftSave(req, draftKey); - if (req.method === "DELETE") return handleDraftDelete(draftKey); - return handleDraftLoad(draftKey); + // Determine cwd for worktree support + let cwd: string | undefined; + if (currentDiffType.startsWith("worktree:")) { + const parsed = parseWorktreeDiffType(currentDiffType); + if (parsed) cwd = parsed.path; } - // API: Editor annotations (VS Code extension) - const editorResponse = await editorAnnotations.handle(req, url); - if (editorResponse) return editorResponse; - - // API: Submit review feedback - if (url.pathname === "/api/feedback" && req.method === "POST") { - try { - const body = (await req.json()) as { - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - }; - - deleteDraft(draftKey); - resolveDecision({ - feedback: body.feedback || "", - annotations: body.annotations || [], - agentSwitch: body.agentSwitch, - }); - - return Response.json({ ok: true }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to process feedback"; - return Response.json({ error: message }, { status: 500 }); - } + if (body.undo) { + await gitResetFile(body.filePath, cwd); + } else { + await gitAddFile(body.filePath, cwd); } - // Serve embedded HTML for all other routes (SPA) - return new Response(htmlContent, { - headers: { "Content-Type": "text/html" }, - }); - }, - }); + return Response.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to git add"; + return Response.json({ error: message }, { status: 500 }); + } + } - break; // Success, exit retry loop - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + return handleImage(req); + } - if (isAddressInUse && attempt < MAX_RETRIES) { - await Bun.sleep(RETRY_DELAY_MS); - continue; + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req); } - if (isAddressInUse) { - const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : ""; - throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`); + // API: Get available agents (OpenCode only) + if (url.pathname === "/api/agents") { + return handleAgents(options.opencodeClient); } - throw err; - } - } + // API: Annotation draft persistence + if (url.pathname === "/api/draft") { + if (req.method === "POST") return handleDraftSave(req, draftKey); + if (req.method === "DELETE") return handleDraftDelete(draftKey); + return handleDraftLoad(draftKey); + } - if (!server) { - throw new Error("Failed to start server"); - } + // API: Editor annotations (VS Code extension) + const editorResponse = await editorAnnotations.handle(req, url); + if (editorResponse) return editorResponse; + + // API: Submit review feedback + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as { + feedback: string; + annotations: unknown[]; + agentSwitch?: string; + }; + + deleteDraft(draftKey); + resolveDecision({ + feedback: body.feedback || "", + annotations: body.annotations || [], + agentSwitch: body.agentSwitch, + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to process feedback"; + return Response.json({ error: message }, { status: 500 }); + } + } - const serverUrl = `http://localhost:${server.port}`; + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/serve.ts b/packages/server/serve.ts new file mode 100644 index 00000000..f1566c3b --- /dev/null +++ b/packages/server/serve.ts @@ -0,0 +1,84 @@ +/** + * Shared Bun.serve() wrapper with port-conflict retry logic. + * + * Every Plannotator server (plan, review, annotate, checklist) needs the same + * bootstrap: try a port, retry on EADDRINUSE, give up after N attempts. + * This module extracts that boilerplate so each server only supplies its fetch handler. + */ + +import { isRemoteSession, getServerPort } from "./remote"; + +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 500; + +export interface StartServerOptions { + /** The request handler — the only thing that varies between servers. */ + fetch: (req: Request) => Response | Promise; +} + +export interface StartServerResult { + /** The underlying Bun server instance. */ + server: ReturnType; + /** The port the server is listening on. */ + port: number; + /** Full URL (http://localhost:{port}). */ + url: string; + /** Whether running in remote/devcontainer mode. */ + isRemote: boolean; +} + +/** + * Start a Bun HTTP server with automatic port-conflict retries. + * + * Retries up to 5 times with 500ms delay when the port is in use. + * Uses the standard Plannotator port logic (random locally, fixed in remote mode). + */ +export async function startServer( + options: StartServerOptions, +): Promise { + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + port: configuredPort, + fetch: options.fetch, + }); + + break; // Success + } catch (err: unknown) { + const isAddressInUse = + err instanceof Error && err.message.includes("EADDRINUSE"); + + if (isAddressInUse && attempt < MAX_RETRIES) { + await Bun.sleep(RETRY_DELAY_MS); + continue; + } + + if (isAddressInUse) { + const hint = isRemote + ? " (set PLANNOTATOR_PORT to use different port)" + : ""; + throw new Error( + `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`, + ); + } + + throw err; + } + } + + if (!server) { + throw new Error("Failed to start server"); + } + + return { + server, + port: server.port, + url: `http://localhost:${server.port}`, + isRemote, + }; +} diff --git a/packages/server/sessions.ts b/packages/server/sessions.ts index 78fddc8e..bc2ee120 100644 --- a/packages/server/sessions.ts +++ b/packages/server/sessions.ts @@ -20,7 +20,7 @@ export interface SessionInfo { pid: number; port: number; url: string; - mode: "plan" | "review" | "annotate"; + mode: "plan" | "review" | "annotate" | "checklist"; project: string; startedAt: string; label: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8101468c..d1e32ce6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,6 +4,7 @@ "private": true, "exports": { "./compress": "./compress.ts", - "./crypto": "./crypto.ts" + "./crypto": "./crypto.ts", + "./checklist-types": "./checklist-types.ts" } } diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css new file mode 100644 index 00000000..ecc994a9 --- /dev/null +++ b/packages/ui/styles/theme.css @@ -0,0 +1,129 @@ +/** + * Plannotator shared design tokens and global styles. + * + * Imported by each editor's index.css after @import "tailwindcss" and @source directives. + * Contains: OKLch color tokens (dark + light), Tailwind @theme mappings, and global resets. + */ + +:root { + --background: oklch(0.15 0.02 260); + --foreground: oklch(0.90 0.01 260); + --card: oklch(0.22 0.02 260); + --card-foreground: oklch(0.90 0.01 260); + --popover: oklch(0.28 0.025 260); + --popover-foreground: oklch(0.90 0.01 260); + --primary: oklch(0.75 0.18 280); + --primary-foreground: oklch(0.15 0.02 260); + --secondary: oklch(0.65 0.15 180); + --secondary-foreground: oklch(0.15 0.02 260); + --muted: oklch(0.26 0.02 260); + --muted-foreground: oklch(0.72 0.02 260); + --accent: oklch(0.70 0.20 60); + --accent-foreground: oklch(0.15 0.02 260); + --destructive: oklch(0.65 0.20 25); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.35 0.02 260); + --input: oklch(0.26 0.02 260); + --ring: oklch(0.75 0.18 280); + --success: oklch(0.72 0.17 150); + --success-foreground: oklch(0.15 0.02 260); + --warning: oklch(0.75 0.15 85); + --warning-foreground: oklch(0.20 0.02 260); + + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --radius: 0.75rem; +} + +.light { + --background: oklch(0.97 0.005 260); + --foreground: oklch(0.18 0.02 260); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 260); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.18 0.02 260); + --primary: oklch(0.50 0.25 280); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.50 0.18 180); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.92 0.01 260); + --muted-foreground: oklch(0.40 0.02 260); + --accent: oklch(0.60 0.22 50); + --accent-foreground: oklch(0.18 0.02 260); + --destructive: oklch(0.50 0.25 25); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.88 0.01 260); + --input: oklch(0.92 0.01 260); + --ring: oklch(0.50 0.25 280); + --success: oklch(0.45 0.20 150); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.55 0.18 85); + --warning-foreground: oklch(0.18 0.02 260); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +* { + border-color: var(--border); +} + +body { + font-family: var(--font-sans); + background: var(--background); + color: var(--foreground); + font-feature-settings: "ss01", "ss02", "cv01"; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +/* Selection */ +::selection { + background: oklch(0.75 0.18 280 / 0.3); +} + +/* Smooth transitions */ +* { + transition-property: color, background-color, border-color, box-shadow, opacity, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Focus states */ +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} diff --git a/packages/ui/utils/timeFormat.ts b/packages/ui/utils/timeFormat.ts new file mode 100644 index 00000000..01e0de13 --- /dev/null +++ b/packages/ui/utils/timeFormat.ts @@ -0,0 +1,13 @@ +/** + * Format a timestamp as a human-readable relative time string. + */ +export function formatTimeAgo(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days !== 1 ? 's' : ''} ago`; +} From dc05cb4766f44471e23be13753b7772c44720443 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 10 Mar 2026 23:04:56 -0700 Subject: [PATCH 02/15] feat: add QA checklist skill with full-stack UI Add a new checklist feature that lets AI agents generate QA checklists for manual developer verification of code changes. Includes: - Checklist editor UI with expandable items, inline notes, progress bar, category grouping with compact collapse, and annotation panel - PR/MR integration supporting GitHub, GitLab, and Azure DevOps with provider-specific icons, automation toggles, and CLI command output - Server-side validation, formatting, and session management - Harness integrations for Claude Code, OpenCode, and Pi - Skill instructions (.agents/skills/checklist/SKILL.md) with PR detection and --file flag for large JSON payloads Co-Authored-By: Claude Opus 4.6 --- .agents/skills/checklist/SKILL.md | 153 +++++ apps/checklist/index.html | 18 + apps/checklist/index.tsx | 16 + apps/checklist/package.json | 27 + apps/checklist/vite.config.ts | 37 ++ apps/codex/README.md | 10 + apps/factory/README.md | 19 + apps/hook/README.md | 8 + apps/hook/commands/plannotator-checklist.md | 12 + apps/hook/package.json | 2 +- apps/hook/server/index.ts | 106 ++- apps/opencode-plugin/README.md | 8 + apps/opencode-plugin/checklist.html | 87 +++ .../commands/plannotator-checklist.md | 6 + apps/opencode-plugin/index.ts | 92 +++ apps/opencode-plugin/package.json | 5 +- apps/pi-extension/README.md | 9 + apps/pi-extension/checklist.html | 87 +++ apps/pi-extension/index.ts | 59 ++ apps/pi-extension/package.json | 5 +- apps/pi-extension/server.ts | 301 +++++++++ packages/checklist-editor/App.tsx | 609 ++++++++++++++++++ .../components/ChecklistAnnotationPanel.tsx | 234 +++++++ .../components/ChecklistGroup.tsx | 152 +++++ .../components/ChecklistHeader.tsx | 242 +++++++ .../components/ChecklistItem.tsx | 203 ++++++ .../components/ProgressBar.tsx | 82 +++ .../components/StatusButton.tsx | 145 +++++ .../hooks/useChecklistDraft.ts | 122 ++++ .../hooks/useChecklistProgress.ts | 71 ++ .../hooks/useChecklistState.ts | 197 ++++++ packages/checklist-editor/index.css | 282 ++++++++ packages/checklist-editor/package.json | 16 + .../checklist-editor/utils/exportChecklist.ts | 87 +++ packages/server/checklist.ts | 493 ++++++++++++++ packages/shared/checklist-types.ts | 70 ++ 36 files changed, 4065 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/checklist/SKILL.md create mode 100644 apps/checklist/index.html create mode 100644 apps/checklist/index.tsx create mode 100644 apps/checklist/package.json create mode 100644 apps/checklist/vite.config.ts create mode 100644 apps/factory/README.md create mode 100644 apps/hook/commands/plannotator-checklist.md create mode 100644 apps/opencode-plugin/checklist.html create mode 100644 apps/opencode-plugin/commands/plannotator-checklist.md create mode 100644 apps/pi-extension/checklist.html create mode 100644 packages/checklist-editor/App.tsx create mode 100644 packages/checklist-editor/components/ChecklistAnnotationPanel.tsx create mode 100644 packages/checklist-editor/components/ChecklistGroup.tsx create mode 100644 packages/checklist-editor/components/ChecklistHeader.tsx create mode 100644 packages/checklist-editor/components/ChecklistItem.tsx create mode 100644 packages/checklist-editor/components/ProgressBar.tsx create mode 100644 packages/checklist-editor/components/StatusButton.tsx create mode 100644 packages/checklist-editor/hooks/useChecklistDraft.ts create mode 100644 packages/checklist-editor/hooks/useChecklistProgress.ts create mode 100644 packages/checklist-editor/hooks/useChecklistState.ts create mode 100644 packages/checklist-editor/index.css create mode 100644 packages/checklist-editor/package.json create mode 100644 packages/checklist-editor/utils/exportChecklist.ts create mode 100644 packages/server/checklist.ts create mode 100644 packages/shared/checklist-types.ts diff --git a/.agents/skills/checklist/SKILL.md b/.agents/skills/checklist/SKILL.md new file mode 100644 index 00000000..3457f91c --- /dev/null +++ b/.agents/skills/checklist/SKILL.md @@ -0,0 +1,153 @@ +--- +name: checklist +description: > + Generate a QA checklist for manual developer verification of code changes. + Use when the user wants to verify completed work, review a diff for quality, + create acceptance criteria checks, or run through QA steps before shipping. + Triggers on requests like "create a checklist", "what should I test", + "verify my changes", "QA this", or "pre-flight check". +disable-model-invocation: true +--- + +# QA Checklist + +You are a senior QA engineer. Your job is to analyze the current code changes and produce a **QA checklist** — a structured list of verification tasks the developer needs to manually review before the work is considered done. + +This is not a code review. Code reviews catch style issues and logic bugs in the diff itself. A QA checklist catches the things that only a human can verify by actually running, clicking, testing, and observing the software. You're producing the verification plan that bridges "the code looks right" to "the software actually works." + +## Principles + +**Focus on what humans must verify.** If an automated test already covers something with meaningful assertions, it doesn't need a checklist item. But "tests exist" is not enough — test coverage that only asserts existence or happy-path behavior still leaves gaps that need human eyes. + +**Be specific, not vague.** "Test the login flow" is useless. "Verify that login with an expired JWT returns a 401 with `{error: 'token_expired'}` body, not a 500 with a stack trace" tells the developer exactly what to check, what to expect, and what failure looks like. + +**Every item is a mini test case.** Each checklist entry should have enough context that a developer unfamiliar with the change could pick it up and verify it. The description explains the change and the risk. The steps walk through the exact verification procedure. The expected outcome is clear. + +**Fewer good items beat many shallow ones.** Aim for 5–15 items. If you're producing more than 15, you're generating busywork — prioritize the items where human verification actually matters. If you're producing fewer than 5, look harder at edge cases, integration points, and deployment concerns. + +## Workflow + +### 1. Gather Context + +Start by understanding what changed and why. + +```bash +git diff HEAD +``` + +If that's empty, try the branch diff: + +```bash +git diff main...HEAD +``` + +As you read the diff, build a mental model: + +- **What kind of change is this?** New feature, bug fix, refactor, dependency update, config/infra change. This determines which categories of verification matter most. +- **Which files changed and what do they do?** UI components need visual verification. API routes need functional testing. Database migrations need data integrity checks. Config files need deployment verification. +- **Do tests exist for this code?** Look for test files related to the changed code. Tests that meaningfully cover the changed behavior reduce the need for manual verification — but tests that only cover the happy path or assert existence still leave gaps. + +### 2. Decide What Needs Manual Verification + +Think about each change through the lens of what could go wrong that a human needs to catch. Consider categories like: + +- **Visual** — Does it look right? Layout, responsiveness, dark mode, animations, color contrast. Only relevant when UI files changed. +- **Functional** — Does the feature work end-to-end? Happy path and primary error paths. Always relevant for new features and bug fixes. +- **Edge cases** — Empty input, huge input, special characters, concurrent access, timezone issues. Focus on cases the diff suggests are likely, not every theoretical scenario. +- **Integration** — Does this break callers or consumers? API contract changes, event format changes, shared state mutations. +- **Security** — Auth checks on new endpoints, input sanitization, secrets exposure, CORS changes. +- **Data** — Database migrations, schema changes, backwards compatibility, data format changes. +- **Performance** — Only when the diff touches hot paths, adds queries, or changes data structures. +- **Deployment** — New environment variables, feature flags, migration ordering, new dependencies. +- **Developer experience** — Error messages, documentation, CLI help text, logging. + +These are suggestions, not a fixed list. Use whatever category label best describes the type of verification. If the change involves "api-contract" or "accessibility" or "offline-behavior," use that. + +### 3. Generate the Checklist JSON + +Produce a JSON object with this structure: + +```json +{ + "title": "Short title for the checklist", + "summary": "One paragraph explaining what changed and why manual verification matters.", + "pr": { + "number": 142, + "url": "https://github.com/org/repo/pull/142", + "title": "feat: add OAuth2 support", + "branch": "feat/oauth2", + "provider": "github" + }, + "items": [ + { + "id": "category-N", + "category": "free-form category label", + "check": "Imperative verb phrase — the headline", + "description": "Markdown narrative explaining what changed in the code, what could go wrong, what the expected behavior is, and how the developer knows the test passes.", + "steps": [ + "Step 1: Do this specific thing", + "Step 2: Observe this specific result", + "Step 3: Confirm this specific expectation" + ], + "reason": "Why this needs human eyes — what makes it not fully automatable.", + "files": ["path/to/relevant/file.ts"], + "critical": false + } + ] +} +``` + +**Field guidance:** + +- **`pr`** (optional): Include when the checklist is associated with a pull/merge request. The UI displays a PR badge in the header and enables automation options (post results as a PR comment, auto-approve if all checks pass). Detect the provider from the git remote: + - `github.com` → `"provider": "github"` + - `gitlab.com` or self-hosted GitLab → `"provider": "gitlab"` + - `dev.azure.com` or `visualstudio.com` → `"provider": "azure-devops"` + + To detect if a PR exists for the current branch: + ```bash + # GitHub + gh pr view --json number,url,title,headRefName 2>/dev/null + # GitLab + glab mr view --output json 2>/dev/null + # Azure DevOps + az repos pr list --source-branch "$(git branch --show-current)" --output json 2>/dev/null + ``` + If the command succeeds, populate the `pr` field. If it fails (no PR exists, CLI not installed), omit it entirely. Do not error on missing CLIs — the `pr` field is optional. + +- **`id`**: Prefix with a short category tag and number: `func-1`, `sec-2`, `visual-1`. This makes items easy to reference in feedback. +- **`category`**: Free-form string. Pick the label that best describes the verification type. Common ones: `visual`, `functional`, `edge-case`, `integration`, `security`, `data`, `performance`, `deployment`, `devex`. +- **`check`**: The headline. Always starts with a verb: Verify, Confirm, Check, Test, Ensure, Open, Navigate, Run. This is what appears as the checklist item label. +- **`description`**: The heart of the item. Write this as a markdown narrative that tells the full story: + - What changed in the code (reference specific files/functions) + - What could go wrong as a result + - What the expected behavior should be + - How the developer knows the test passes vs fails +- **`steps`**: Required. Ordered instructions for conducting the verification. Be concrete — "Open browser devtools" not "check the network." Each step should be a single clear action. +- **`reason`**: One sentence explaining why automation can't fully cover this. "CSS grid rendering varies across browsers" is good. "Because it changed" is not. +- **`files`**: File paths from the diff that this item relates to. Helps the developer trace your reasoning. +- **`critical`**: Reserve for items where failure means data loss, security vulnerability, or broken deployment. Typically 0–3 items per checklist. + +### 4. Launch the Checklist UI + +Write your JSON to a temporary file and pass it via `--file`: + +```bash +cat > /tmp/checklist.json << 'CHECKLIST_EOF' + +CHECKLIST_EOF +plannotator checklist --file /tmp/checklist.json +``` + +This avoids shell quoting issues with large or complex JSON. The UI opens for the developer to work through each item — marking them as passed, failed, or skipped with notes and screenshot evidence. Wait for the output — it contains the developer's results. + +### 5. Respond to Results + +When the checklist results come back: + +- **All passed**: The verification is complete. Acknowledge it and move on. +- **Items failed**: Read the developer's notes carefully. Fix the issue if you can. If the current behavior is actually correct, explain why. +- **Items skipped**: Note the reason. If items were skipped as "not applicable," your checklist may have been too broad for this change — take that as feedback. +- **Questions attached**: Answer them directly, with references to the relevant code. + +$ARGUMENTS diff --git a/apps/checklist/index.html b/apps/checklist/index.html new file mode 100644 index 00000000..fb20bc68 --- /dev/null +++ b/apps/checklist/index.html @@ -0,0 +1,18 @@ + + + + + + QA Checklist + + + + + + + + +
+ + + diff --git a/apps/checklist/index.tsx b/apps/checklist/index.tsx new file mode 100644 index 00000000..3074f9e1 --- /dev/null +++ b/apps/checklist/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from '@plannotator/checklist-editor'; +import '@plannotator/checklist-editor/styles'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/apps/checklist/package.json b/apps/checklist/package.json new file mode 100644 index 00000000..111e729c --- /dev/null +++ b/apps/checklist/package.json @@ -0,0 +1,27 @@ +{ + "name": "@plannotator/checklist", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@plannotator/checklist-editor": "workspace:*", + "@plannotator/server": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + "@types/node": "^22.14.0" + } +} diff --git a/apps/checklist/vite.config.ts b/apps/checklist/vite.config.ts new file mode 100644 index 00000000..9382fb6b --- /dev/null +++ b/apps/checklist/vite.config.ts @@ -0,0 +1,37 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { viteSingleFile } from 'vite-plugin-singlefile'; +import tailwindcss from '@tailwindcss/vite'; +import pkg from '../../package.json'; + +export default defineConfig({ + server: { + port: 3002, + host: '0.0.0.0', + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, + plugins: [react(), tailwindcss(), viteSingleFile()], + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), + '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), + '@plannotator/checklist-editor/styles': path.resolve(__dirname, '../../packages/checklist-editor/index.css'), + '@plannotator/checklist-editor': path.resolve(__dirname, '../../packages/checklist-editor/App.tsx'), + } + }, + build: { + target: 'esnext', + assetsInlineLimit: 100000000, + chunkSizeWarningLimit: 100000000, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/apps/codex/README.md b/apps/codex/README.md index e9defa08..0c0944e9 100644 --- a/apps/codex/README.md +++ b/apps/codex/README.md @@ -44,6 +44,16 @@ Run `!plannotator annotate` to annotate any markdown file: | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | | `PLANNOTATOR_BROWSER` | Custom browser to open. macOS: app name or path. Linux/Windows: executable path. | +## Skills + +Skills are installed automatically by the install script above. They are placed in `~/.agents/skills/` and discovered by Codex on startup. + +Alternatively, install skills only via `npx skills add backnotprop/plannotator`. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | + ## Links - [Website](https://plannotator.ai) diff --git a/apps/factory/README.md b/apps/factory/README.md new file mode 100644 index 00000000..5a1b355f --- /dev/null +++ b/apps/factory/README.md @@ -0,0 +1,19 @@ +# Plannotator for Factory + +## Install + +**macOS / Linux / WSL:** + +```bash +curl -fsSL https://plannotator.ai/install.sh | bash +``` + +## Skills + +Skills are installed automatically by the install script above. + +Alternatively, install skills only via `npx skills add backnotprop/plannotator`. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | diff --git a/apps/hook/README.md b/apps/hook/README.md index 96c747a8..1fdef746 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -61,6 +61,14 @@ If you prefer not to use the plugin system, add this to your `~/.claude/settings } ``` +## Skills + +Skills are included with the plugin install. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | + ## How It Works When Claude Code calls `ExitPlanMode`, this hook intercepts and: diff --git a/apps/hook/commands/plannotator-checklist.md b/apps/hook/commands/plannotator-checklist.md new file mode 100644 index 00000000..d46ddd63 --- /dev/null +++ b/apps/hook/commands/plannotator-checklist.md @@ -0,0 +1,12 @@ +--- +description: Open interactive QA checklist verification UI +allowed-tools: Bash(plannotator:*) +--- + +## QA Checklist Results + +!`plannotator checklist '$ARGUMENTS'` + +## Your task + +Address the checklist results above. Items marked FAILED need fixes — read the developer's notes and act on them. Items with questions need answers. Items marked SKIPPED were not verified — acknowledge the reason. diff --git a/apps/hook/package.json b/apps/hook/package.json index ccf9c71d..2a16e17d 100644 --- a/apps/hook/package.json +++ b/apps/hook/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html", + "build": "bun run --cwd ../review build && bun run --cwd ../checklist build && vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html && cp ../checklist/dist/index.html dist/checklist.html", "serve": "bun run server/index.ts" }, "dependencies": { diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 98b87db0..e42ae1b4 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,7 +1,7 @@ /** * Plannotator CLI for Claude Code * - * Supports four modes: + * Supports five modes: * * 1. Plan Review (default, no args): * - Spawned by ExitPlanMode hook @@ -18,7 +18,12 @@ * - Opens any markdown file in the annotation UI * - Outputs structured feedback to stdout * - * 4. Sessions (`plannotator sessions`): + * 4. Checklist (`plannotator checklist ''`): + * - Triggered by /plannotator-checklist skill or slash command + * - Opens QA checklist UI for manual verification + * - Outputs per-item results as markdown to stdout + * + * 5. Sessions (`plannotator sessions`): * - Lists active Plannotator server sessions * - `--open [N]` reopens a session in the browser * - `--clean` removes stale session files @@ -43,6 +48,11 @@ import { startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; +import { + startChecklistServer, + handleChecklistServerReady, + validateChecklist, +} from "@plannotator/server/checklist"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; @@ -60,6 +70,10 @@ const planHtmlContent = planHtml as unknown as string; import reviewHtml from "../dist/review.html" with { type: "text" }; const reviewHtmlContent = reviewHtml as unknown as string; +// @ts-ignore - Bun import attribute for text +import checklistHtml from "../dist/checklist.html" with { type: "text" }; +const checklistHtmlContent = checklistHtml as unknown as string; + // Check for subcommand const args = process.argv.slice(2); @@ -271,6 +285,94 @@ if (args[0] === "sessions") { console.log(result.feedback || "No feedback provided."); process.exit(0); +} else if (args[0] === "checklist") { + // ============================================ + // QA CHECKLIST MODE + // ============================================ + + // JSON can come from CLI argument or --file flag + let jsonInput = args[1]; + + const fileIdx = args.indexOf("--file"); + if (fileIdx !== -1 && args[fileIdx + 1]) { + const filePath = args[fileIdx + 1]; + try { + jsonInput = await Bun.file(filePath).text(); + } catch { + console.error(`Failed to read checklist file: ${filePath}`); + process.exit(1); + } + } + + if (!jsonInput) { + console.error("Usage: plannotator checklist '' or plannotator checklist --file "); + process.exit(1); + } + + // Parse and validate JSON + let checklistData: unknown; + try { + checklistData = JSON.parse(jsonInput); + } catch { + console.error("Invalid JSON. Ensure the checklist is valid JSON."); + process.exit(1); + } + + // Unwrap saved checklist files (which have { checklist, results, ... }) + if (checklistData && typeof checklistData === "object" && "checklist" in checklistData) { + checklistData = (checklistData as Record).checklist; + } + + const errors = validateChecklist(checklistData); + if (errors.length > 0) { + console.error("Checklist validation failed:"); + for (const err of errors) { + console.error(` - ${err}`); + } + console.error("\nSee the checklist skill for the expected JSON schema."); + process.exit(1); + } + + const checklist = checklistData as import("@plannotator/shared/checklist-types").Checklist; + const checklistProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startChecklistServer({ + checklist, + origin: "claude-code", + project: checklistProject, + htmlContent: checklistHtmlContent, + onReady: (url, isRemote, port) => { + handleChecklistServerReady(url, isRemote, port); + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "checklist", + project: checklistProject, + startedAt: new Date().toISOString(), + label: `checklist-${checklistProject}`, + }); + + // Wait for user to complete the checklist + const result = await server.waitForDecision(); + + // Give browser time to receive response and update UI + await Bun.sleep(1500); + + // Cleanup + server.stop(); + + // Output feedback (captured by slash command) + let output = result.feedback || "No checklist results provided."; + if (result.savedTo) { + output += `\n\nChecklist results saved to: ${result.savedTo}\nReopen with: plannotator checklist --file ${result.savedTo}`; + } + console.log(output); + process.exit(0); + } else { // ============================================ // PLAN REVIEW MODE (default) diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index f85e5362..ab480fda 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -43,6 +43,14 @@ Restart OpenCode. The `submit_plan` tool is now available. 3. **Approve** → Agent proceeds with implementation 4. **Request changes** → Annotations sent back as structured feedback +## Skills + +Skills are included with the plugin install. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | + ## Features - **Visual annotations**: Select text, choose an action, see feedback in the sidebar diff --git a/apps/opencode-plugin/checklist.html b/apps/opencode-plugin/checklist.html new file mode 100644 index 00000000..052f6b16 --- /dev/null +++ b/apps/opencode-plugin/checklist.html @@ -0,0 +1,87 @@ + + + + + + QA Checklist + + + + + + + + + + +
+ + diff --git a/apps/opencode-plugin/commands/plannotator-checklist.md b/apps/opencode-plugin/commands/plannotator-checklist.md new file mode 100644 index 00000000..fec9d66b --- /dev/null +++ b/apps/opencode-plugin/commands/plannotator-checklist.md @@ -0,0 +1,6 @@ +--- +description: Open interactive QA checklist verification UI +--- + +The Plannotator Checklist UI has been triggered. Opening the checklist UI... +Acknowledge "Opening checklist UI..." and wait for the user's feedback. diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 949caadf..573075cf 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -26,6 +26,12 @@ import { startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; +import { + startChecklistServer, + handleChecklistServerReady, + validateChecklist, + formatChecklistFeedback, +} from "@plannotator/server/checklist"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; @@ -38,6 +44,10 @@ const htmlContent = indexHtml as unknown as string; import reviewHtml from "./review-editor.html" with { type: "text" }; const reviewHtmlContent = reviewHtml as unknown as string; +// @ts-ignore - Bun import attribute for text +import checklistHtml from "./checklist.html" with { type: "text" }; +const checklistHtmlContent = checklistHtml as unknown as string; + const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours export const PlannotatorPlugin: Plugin = async (ctx) => { @@ -327,6 +337,88 @@ Do NOT proceed with implementation until your plan is approved. } } } + + // Handle /plannotator-checklist command + const isChecklistCommand = commandName === "plannotator-checklist"; + + if (isCommandEvent && isChecklistCommand) { + // @ts-ignore - Event properties contain arguments + const rawArgs = event.properties?.arguments || event.arguments || ""; + + if (!rawArgs) { + ctx.client.app.log({ + level: "error", + message: "Usage: /plannotator-checklist ", + }); + return; + } + + // Parse the JSON argument + let checklistData: unknown; + try { + checklistData = JSON.parse(rawArgs); + } catch { + ctx.client.app.log({ + level: "error", + message: "Invalid JSON argument. Expected a checklist JSON object.", + }); + return; + } + + // Validate the checklist structure + const errors = validateChecklist(checklistData); + if (errors.length > 0) { + ctx.client.app.log({ + level: "error", + message: `Invalid checklist:\n${errors.join("\n")}`, + }); + return; + } + + ctx.client.app.log({ + level: "info", + message: "Opening checklist UI...", + }); + + const server = await startChecklistServer({ + checklist: checklistData as import("@plannotator/shared/checklist-types").Checklist, + origin: "opencode", + htmlContent: checklistHtmlContent, + onReady: handleChecklistServerReady, + }); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + // Send feedback back to the session if provided + if (result.feedback) { + // @ts-ignore - Event properties contain sessionID for command.executed events + const sessionId = event.properties?.sessionID; + + if (sessionId) { + const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; + const targetAgent = result.agentSwitch || 'build'; + + try { + await ctx.client.session.prompt({ + path: { id: sessionId }, + body: { + ...(shouldSwitchAgent && { agent: targetAgent }), + parts: [ + { + type: "text", + text: `${result.feedback}\n\nPlease address the checklist results above.`, + }, + ], + }, + }); + } catch { + // Session may not be available + } + } + } + } }, tool: { diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index f77e7c4a..0550f275 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -27,10 +27,11 @@ "commands", "README.md", "plannotator.html", - "review-editor.html" + "review-editor.html", + "checklist.html" ], "scripts": { - "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", + "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && cp ../hook/dist/checklist.html ./checklist.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", "postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command/ 2>/dev/null || true", "prepublishOnly": "bun run build" }, diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index b0ab7771..c38b7c46 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -84,6 +84,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr | `/plannotator-status` | Show current phase, plan file, and progress | | `/plannotator-review` | Open code review UI for current changes | | `/plannotator-annotate ` | Open markdown file in annotation UI | +| `/plannotator-checklist ` | Open QA checklist verification UI | ## Flags @@ -98,6 +99,14 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr |----------|-------------| | `Ctrl+Alt+P` | Toggle plan mode | +## Skills + +Skills are included with the extension install. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | + ## How it works The extension manages a state machine: **idle** → **planning** → **executing** → **idle**. diff --git a/apps/pi-extension/checklist.html b/apps/pi-extension/checklist.html new file mode 100644 index 00000000..8aeb7857 --- /dev/null +++ b/apps/pi-extension/checklist.html @@ -0,0 +1,87 @@ + + + + + + QA Checklist + + + + + + + + + + +
+ + diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 1c8e724b..5c4d6126 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -31,6 +31,8 @@ import { startPlanReviewServer, startReviewServer, startAnnotateServer, + startChecklistServer, + validateChecklist, getGitContext, runGitDiff, openBrowser, @@ -40,6 +42,7 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); let planHtmlContent = ""; let reviewHtmlContent = ""; +let checklistHtmlContent = ""; try { planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8"); } catch { @@ -50,6 +53,11 @@ try { } catch { // HTML not built yet — review feature will be unavailable } +try { + checklistHtmlContent = readFileSync(resolve(__dirname, "checklist.html"), "utf-8"); +} catch { + // HTML not built yet — checklist feature will be unavailable +} /** Extra tools to ensure are available during planning (on top of whatever is already active). */ const PLANNING_EXTRA_TOOLS = ["grep", "find", "ls", "exit_plan_mode"]; @@ -307,6 +315,57 @@ export default function plannotator(pi: ExtensionAPI): void { }, }); + pi.registerCommand("plannotator-checklist", { + description: "Open interactive QA checklist verification UI", + handler: async (args, ctx) => { + const rawArgs = args?.trim(); + if (!rawArgs) { + ctx.ui.notify("Usage: /plannotator-checklist ", "error"); + return; + } + if (!checklistHtmlContent) { + ctx.ui.notify("Checklist UI not available. Run 'bun run build' in the pi-extension directory.", "error"); + return; + } + + // Parse JSON argument + let checklistData: unknown; + try { + checklistData = JSON.parse(rawArgs); + } catch { + ctx.ui.notify("Invalid JSON argument. Expected a checklist JSON object.", "error"); + return; + } + + // Validate the checklist structure + const errors = validateChecklist(checklistData); + if (errors.length > 0) { + ctx.ui.notify(`Invalid checklist:\n${errors.join("\n")}`, "error"); + return; + } + + ctx.ui.notify("Opening checklist UI...", "info"); + + const server = startChecklistServer({ + checklist: checklistData as { title: string; summary: string; items: unknown[] }, + origin: "pi", + htmlContent: checklistHtmlContent, + }); + + openBrowser(server.url); + + const result = await server.waitForDecision(); + await new Promise((r) => setTimeout(r, 1500)); + server.stop(); + + if (result.feedback) { + pi.sendUserMessage(`${result.feedback}\n\nPlease address the checklist results above.`); + } else { + ctx.ui.notify("Checklist closed (no feedback).", "info"); + } + }, + }); + pi.registerShortcut(Key.ctrlAlt("p"), { description: "Toggle plannotator", handler: async (ctx) => togglePlanMode(ctx), diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index e87a7df4..c208cfa1 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -24,10 +24,11 @@ "utils.ts", "README.md", "plannotator.html", - "review-editor.html" + "review-editor.html", + "checklist.html" ], "scripts": { - "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html", + "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && cp ../hook/dist/checklist.html checklist.html", "prepublishOnly": "cd ../.. && bun run build:pi" }, "peerDependencies": { diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index 75795a2b..7e7ccb2b 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -511,3 +511,304 @@ export function startAnnotateServer(options: { stop: () => server.close(), }; } + +// ── Checklist Validation (Node-compatible, duplicated from packages/server) ── + +/** + * Validate a checklist JSON object. + * Returns an array of error messages (empty = valid). + */ +export function validateChecklist(data: unknown): string[] { + const errors: string[] = []; + + if (!data || typeof data !== "object") { + errors.push("Checklist must be a JSON object."); + return errors; + } + + const obj = data as Record; + + if (typeof obj.title !== "string" || !obj.title.trim()) { + errors.push('Missing or empty "title" (string).'); + } + + if (typeof obj.summary !== "string" || !obj.summary.trim()) { + errors.push('Missing or empty "summary" (string).'); + } + + if (!Array.isArray(obj.items)) { + errors.push('"items" must be an array.'); + return errors; + } + + if (obj.items.length === 0) { + errors.push('"items" array is empty — include at least one checklist item.'); + } + + for (let i = 0; i < obj.items.length; i++) { + const item = obj.items[i] as Record; + const prefix = `items[${i}]`; + + if (typeof item.id !== "string" || !item.id.trim()) { + errors.push(`${prefix}: missing "id" (string, e.g. "func-1").`); + } + + if (typeof item.category !== "string" || !item.category.trim()) { + errors.push(`${prefix}: missing "category" (string, e.g. "functional").`); + } + + if (typeof item.check !== "string" || !item.check.trim()) { + errors.push(`${prefix}: missing "check" (imperative verb phrase).`); + } + + if (typeof item.description !== "string" || !item.description.trim()) { + errors.push(`${prefix}: missing "description" (markdown narrative).`); + } + + if (!Array.isArray(item.steps) || item.steps.length === 0) { + errors.push(`${prefix}: "steps" must be a non-empty array of strings.`); + } + + if (typeof item.reason !== "string" || !item.reason.trim()) { + errors.push(`${prefix}: missing "reason" (why manual verification is needed).`); + } + } + + return errors; +} + +// ── Checklist Feedback Formatting (Node-compatible) ── + +interface ChecklistItemType { + id: string; + category: string; + check: string; + description: string; + steps: string[]; + reason: string; + files?: string[]; + critical?: boolean; +} + +interface ChecklistType { + title: string; + summary: string; + items: ChecklistItemType[]; +} + +interface ChecklistItemResultType { + id: string; + status: "passed" | "failed" | "skipped" | "pending"; + notes?: string; + images?: { path: string; name: string }[]; +} + +function formatChecklistFeedback( + checklist: ChecklistType, + results: ChecklistItemResultType[], + globalNotes?: string, +): string { + const resultMap = new Map(results.map((r) => [r.id, r])); + + let passed = 0; + let failed = 0; + let skipped = 0; + + for (const item of checklist.items) { + const result = resultMap.get(item.id); + if (result?.status === "passed") passed++; + else if (result?.status === "failed") failed++; + else if (result?.status === "skipped") skipped++; + } + + const lines: string[] = []; + + lines.push("# QA Checklist Results"); + lines.push(""); + lines.push("## Summary"); + lines.push(`- **Title**: ${checklist.title}`); + lines.push(`- **Total**: ${checklist.items.length} items`); + lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}`); + lines.push(""); + + const failedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "failed" + ); + if (failedItems.length > 0) { + lines.push("## Failed Items"); + lines.push(""); + for (const item of failedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: FAILED`); + lines.push(`**Category**: ${item.category}`); + if (item.critical) lines.push(`**Critical**: yes`); + if (item.files?.length) lines.push(`**Files**: ${item.files.join(", ")}`); + if (result.notes) lines.push(`**Developer notes**: ${result.notes}`); + if (result.images?.length) { + for (const img of result.images) { + lines.push(`[${img.name}] ${img.path}`); + } + } + lines.push(""); + } + } + + const skippedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "skipped" + ); + if (skippedItems.length > 0) { + lines.push("## Skipped Items"); + lines.push(""); + for (const item of skippedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: SKIPPED`); + if (result.notes) lines.push(`**Reason**: ${result.notes}`); + lines.push(""); + } + } + + const passedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "passed" + ); + if (passedItems.length > 0) { + lines.push("## Passed Items"); + lines.push(""); + for (const item of passedItems) { + const result = resultMap.get(item.id); + const notes = result?.notes ? ` — ${result.notes}` : ""; + lines.push(`- [PASS] ${item.id}: ${item.check}${notes}`); + } + lines.push(""); + } + + if (globalNotes?.trim()) { + lines.push("## Developer Comments"); + lines.push(""); + lines.push(`> ${globalNotes.trim().replace(/\n/g, "\n> ")}`); + lines.push(""); + } + + return lines.join("\n"); +} + +// ── Checklist Storage (Node-compatible, duplicated from packages/server) ── + +/** + * Save a completed checklist (original + results) to disk. + * Returns the path to the saved file. + * + * Structure: ~/.plannotator/checklists/{project}/{slug}.json + */ +function saveChecklistResults( + checklist: ChecklistType, + results: ChecklistItemResultType[], + globalNotes: string | undefined, + project: string, +): string { + const dir = join(os.homedir(), ".plannotator", "checklists", project); + mkdirSync(dir, { recursive: true }); + + const date = new Date().toISOString().split("T")[0]; + const slug = checklist.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + const timestamp = Date.now(); + const filename = `${slug}-${date}-${timestamp}.json`; + const filePath = join(dir, filename); + + writeFileSync(filePath, JSON.stringify({ + checklist, + results, + globalNotes, + submittedAt: new Date().toISOString(), + project, + }, null, 2)); + + return filePath; +} + +// ── Checklist Server ───────────────────────────────────────────────────── + +export interface ChecklistServerResult { + port: number; + url: string; + waitForDecision: () => Promise<{ feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }>; + stop: () => void; +} + +export function startChecklistServer(options: { + checklist: ChecklistType; + htmlContent: string; + origin?: string; + project?: string; +}): ChecklistServerResult { + const project = options.project || detectProjectName(); + + let resolveDecision!: (result: { feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }) => void; + const decisionPromise = new Promise<{ feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }>((r) => { + resolveDecision = r; + }); + + const server = createServer(async (req, res) => { + const url = new URL(req.url!, `http://localhost`); + + if (url.pathname === "/api/checklist" && req.method === "GET") { + json(res, { + checklist: options.checklist, + origin: options.origin ?? "pi", + mode: "checklist", + }); + } else if (url.pathname === "/api/feedback" && req.method === "POST") { + const body = await parseBody(req) as { + results?: ChecklistItemResultType[]; + globalNotes?: string; + agentSwitch?: string; + }; + + const results = body.results || []; + + // Save to disk + let savedTo: string | undefined; + try { + savedTo = saveChecklistResults( + options.checklist, + results, + body.globalNotes, + project, + ); + } catch { + // Non-fatal — feedback still goes to agent + } + + const feedback = formatChecklistFeedback( + options.checklist, + results, + body.globalNotes, + ); + + resolveDecision({ + feedback, + results, + savedTo, + agentSwitch: body.agentSwitch, + }); + + json(res, { ok: true }); + } else { + html(res, options.htmlContent); + } + }); + + const port = listenOnRandomPort(server); + + return { + port, + url: `http://localhost:${port}`, + waitForDecision: () => decisionPromise, + stop: () => server.close(), + }; +} diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx new file mode 100644 index 00000000..88d21d2a --- /dev/null +++ b/packages/checklist-editor/App.tsx @@ -0,0 +1,609 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; +import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; +import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; +import { CommentPopover } from '@plannotator/ui/components/CommentPopover'; +import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; +import { ChecklistHeader } from './components/ChecklistHeader'; +import { ChecklistGroup } from './components/ChecklistGroup'; +import { ChecklistAnnotationPanel } from './components/ChecklistAnnotationPanel'; +import { ProgressBar } from './components/ProgressBar'; +import { useChecklistState } from './hooks/useChecklistState'; +import { useChecklistProgress } from './hooks/useChecklistProgress'; +import { useChecklistDraft } from './hooks/useChecklistDraft'; +import { exportChecklistResults } from './utils/exportChecklist'; +import type { Checklist, ChecklistItem, ChecklistItemStatus } from './hooks/useChecklistState'; +import type { ChecklistPR } from '@plannotator/shared/checklist-types'; +import type { ChecklistAutomations } from './components/ChecklistAnnotationPanel'; +import type { ImageAttachment } from '@plannotator/ui/types'; + +// --------------------------------------------------------------------------- +// Demo Data +// --------------------------------------------------------------------------- + +const DEMO_CHECKLIST: Checklist = { + title: 'Authentication Refactor QA Checklist', + summary: 'Verify the OAuth2 migration from session-based auth. Focus on token refresh, CSRF protection, and backward compatibility with existing sessions.', + pr: { + number: 142, + url: 'https://github.com/acme/webapp/pull/142', + title: 'feat: migrate to OAuth2 token-based auth', + branch: 'feat/oauth2-migration', + provider: 'github' as const, + }, + items: [ + { + id: 'auth-1', + category: 'Security', + check: 'CSRF token validation on all state-changing endpoints', + description: 'The migration replaced session-based CSRF with double-submit cookie pattern. Every POST/PUT/DELETE endpoint must validate the `X-CSRF-Token` header against the `csrf_token` cookie.\n\nCheck that the middleware is applied globally and not just on auth routes.', + steps: [ + 'Open DevTools Network tab', + 'Submit the login form and verify X-CSRF-Token header is present', + 'Try a POST request without the header — should get 403', + 'Verify the csrf_token cookie has SameSite=Strict', + ], + reason: 'CSRF protection is security-critical and automated tests may not catch middleware ordering issues.', + files: ['src/middleware/csrf.ts', 'src/routes/api.ts'], + critical: true, + }, + { + id: 'auth-2', + category: 'Security', + check: 'Token refresh handles race conditions', + description: 'When multiple API calls fire simultaneously and the access token is expired, only one refresh request should be sent. Subsequent calls should queue and use the new token.', + steps: [ + 'Log in and wait for the access token to expire (or manually set it to expired)', + 'Trigger 3+ API calls simultaneously (e.g., navigate to dashboard)', + 'Check Network tab — only one /auth/refresh call should appear', + 'All queued requests should succeed with the refreshed token', + ], + reason: 'Race conditions in token refresh can cause cascading 401s and logout the user.', + files: ['src/lib/api-client.ts', 'src/hooks/useAuth.ts'], + critical: true, + }, + { + id: 'auth-3', + category: 'Functionality', + check: 'Login flow completes successfully', + description: 'Basic end-to-end login with email/password. User should be redirected to their intended destination after authentication.', + steps: [ + 'Navigate to a protected page while logged out', + 'Complete the login form', + 'Verify redirect back to the originally requested page', + 'Check that the user menu shows the correct identity', + ], + reason: 'Core user flow that must work correctly.', + files: ['src/pages/login.tsx', 'src/middleware/auth.ts'], + }, + { + id: 'auth-4', + category: 'Functionality', + check: 'OAuth provider login (Google, GitHub)', + description: 'Social login buttons should initiate the OAuth flow and correctly create or link accounts.', + steps: [ + 'Click "Sign in with Google" and complete the OAuth flow', + 'Verify account is created/linked correctly', + 'Repeat with GitHub provider', + 'Try linking a second provider to an existing account', + ], + reason: 'OAuth flows involve third-party redirects that are difficult to test automatically.', + files: ['src/auth/providers.ts'], + }, + { + id: 'auth-5', + category: 'Backward Compatibility', + check: 'Existing sessions are migrated transparently', + description: 'Users with active session cookies from the old system should be seamlessly migrated to the new token-based system without being logged out.', + steps: [ + 'Log in using the old build to establish a session cookie', + 'Deploy the new build', + 'Refresh the page — user should remain logged in', + 'Verify the old session cookie is replaced with the new token cookies', + ], + reason: 'A forced logout would affect all active users and is unacceptable for a production migration.', + files: ['src/middleware/session-migration.ts'], + critical: true, + }, + { + id: 'auth-6', + category: 'Backward Compatibility', + check: 'API keys continue to work unchanged', + description: 'Service accounts using API key authentication should be unaffected by the OAuth migration.', + steps: [ + 'Make an API call with a valid API key', + 'Verify the response is identical to the old behavior', + 'Check that API key auth bypasses the OAuth middleware', + ], + reason: 'Breaking API key auth would disrupt automated integrations.', + files: ['src/middleware/api-key.ts'], + }, + { + id: 'auth-7', + category: 'UI/UX', + check: 'Error states show helpful messages', + description: 'Invalid credentials, expired links, and rate limiting should show user-friendly error messages, not raw error codes.', + steps: [ + 'Try logging in with wrong credentials — check the error message', + 'Try a password reset with an expired link', + 'Trigger rate limiting (5+ failed attempts) and verify the message', + ], + reason: 'Error copy is hard to verify without visual inspection.', + }, + { + id: 'auth-8', + category: 'UI/UX', + check: 'Loading states during auth operations', + description: 'Buttons should show loading spinners and be disabled during login, registration, and token refresh to prevent double-submission.', + steps: [ + 'Click login and observe button state before response arrives', + 'Check that double-clicking does not submit twice', + 'Verify there is no layout shift when the spinner appears', + ], + reason: 'Loading state polish requires visual verification.', + }, + ], +}; + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +const ChecklistApp: React.FC = () => { + const [checklist, setChecklist] = useState(null); + const [origin, setOrigin] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Fetch checklist data + useEffect(() => { + fetch('/api/checklist') + .then(res => { + if (!res.ok) throw new Error('Not in API mode'); + return res.json(); + }) + .then((data: { checklist: Checklist; origin?: string }) => { + setChecklist(data.checklist); + if (data.origin) setOrigin(data.origin); + }) + .catch(() => { + // Demo mode + setChecklist(DEMO_CHECKLIST); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading || !checklist) { + return ( + +
+
Loading checklist...
+
+
+ ); + } + + return ( + + + + ); +}; + +// --------------------------------------------------------------------------- +// Inner component +// --------------------------------------------------------------------------- + +interface ChecklistAppInnerProps { + checklist: Checklist; + origin: string | null; +} + +// Note popover state +interface NotePopoverState { + anchorEl: HTMLElement; + itemId: string | null; // null = global comment +} + +const ChecklistAppInner: React.FC = ({ checklist, origin }) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false); + const [globalNotes, setGlobalNotes] = useState([]); + const [showPartialConfirm, setShowPartialConfirm] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [notePopover, setNotePopover] = useState(null); + const [automations, setAutomations] = useState({ postToPR: false, approveIfAllPass: false }); + const documentRef = useRef(null); + const globalCommentButtonRef = useRef(null); + + const state = useChecklistState({ items: checklist.items }); + const { counts, categoryProgress, submitState } = useChecklistProgress(checklist.items, state.results); + + const panelResize = useResizablePanel({ + storageKey: 'plannotator-checklist-panel-width', + defaultWidth: 320, + minWidth: 260, + maxWidth: 500, + }); + + // Draft auto-save + const { draftBanner, restoreDraft, dismissDraft } = useChecklistDraft({ + results: state.allResults, + globalNotes, + isApiMode: !!origin, + submitted: !!submitted, + }); + + const handleRestoreDraft = useCallback(() => { + const restored = restoreDraft(); + if (restored) { + state.restoreResults(restored.results); + if (restored.globalNotes) setGlobalNotes(restored.globalNotes); + } + }, [restoreDraft, state.restoreResults]); + + // Toggle item expansion + const handleToggleExpand = useCallback((id: string) => { + setExpandedItems(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + state.selectItem(id); + }, [state.selectItem]); + + // Open note popover for an item + const handleOpenNote = useCallback((anchorEl: HTMLElement, itemId: string) => { + setNotePopover({ anchorEl, itemId }); + }, []); + + // Open global comment popover + const handleOpenGlobalComment = useCallback(() => { + if (globalCommentButtonRef.current) { + setNotePopover({ anchorEl: globalCommentButtonRef.current, itemId: null }); + } + }, []); + + // Handle note popover submit + const handleNoteSubmit = useCallback((text: string, images?: ImageAttachment[]) => { + if (!notePopover) return; + + if (notePopover.itemId === null) { + // Global comment — add as new entry + setGlobalNotes(prev => [...prev, text]); + } else { + // Per-item note — add to array + state.addNote(notePopover.itemId, text); + if (images && images.length > 0) { + state.setImages(notePopover.itemId, images.map(img => ({ path: img.path, name: img.name }))); + } + } + + setNotePopover(null); + }, [notePopover, state.addNote, state.setImages]); + + const handleNoteClose = useCallback(() => { + setNotePopover(null); + }, []); + + // Remove notes + const handleRemoveItemNote = useCallback((id: string, index: number) => { + state.removeNote(id, index); + }, [state.removeNote]); + + const handleRemoveGlobalNote = useCallback((index: number) => { + setGlobalNotes(prev => prev.filter((_, i) => i !== index)); + }, []); + + // Select item from annotation panel (expand + scroll) + const handleSelectFromPanel = useCallback((id: string) => { + setExpandedItems(prev => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + state.selectItem(id); + // Scroll to item + if (documentRef.current) { + const el = documentRef.current.querySelector(`[data-item-id="${id}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [state.selectItem]); + + // Feedback markdown for copy + const feedbackMarkdown = useMemo( + () => exportChecklistResults(checklist.items, state.results, globalNotes.length > 0 ? globalNotes : undefined), + [checklist.items, state.results, globalNotes], + ); + + // Submit + const handleSubmit = useCallback(async () => { + if (submitState === 'all-pending') return; + + if (submitState === 'partial' && !showPartialConfirm) { + setShowPartialConfirm(true); + return; + } + + setShowPartialConfirm(false); + setIsSubmitting(true); + + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: feedbackMarkdown, + results: state.allResults, + globalNotes: globalNotes.length > 0 ? globalNotes : undefined, + automations: checklist.pr ? automations : undefined, + }), + }); + + if (res.ok) { + const hasFailures = state.allResults.some(r => r.status === 'failed'); + setSubmitted(hasFailures ? 'feedback' : 'approved'); + } else { + throw new Error('Failed to submit'); + } + } catch (err) { + console.error('Failed to submit checklist:', err); + setIsSubmitting(false); + } + }, [submitState, showPartialConfirm, feedbackMarkdown, state.allResults, checklist.pr, automations]); + + // Keyboard shortcuts — use refs for values that change frequently to avoid + // tearing down/re-attaching the listener on every state change + const stateRef = useRef(state); + stateRef.current = state; + const submittedRef = useRef(submitted); + submittedRef.current = submitted; + const isSubmittingRef = useRef(isSubmitting); + isSubmittingRef.current = isSubmitting; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + const isInput = tag === 'INPUT' || tag === 'TEXTAREA'; + const s = stateRef.current; + + // Cmd+Enter to submit (works even in inputs) + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + if (submittedRef.current || isSubmittingRef.current) return; + if (!origin) return; + e.preventDefault(); + handleSubmit(); + return; + } + + // Don't intercept other shortcuts in inputs + if (isInput) return; + + switch (e.key) { + case 'j': { + e.preventDefault(); + const nextId = s.selectNext(); + if (nextId) setExpandedItems(new Set([nextId])); + break; + } + case 'k': { + e.preventDefault(); + const prevId = s.selectPrev(); + if (prevId) setExpandedItems(new Set([prevId])); + break; + } + case 'p': + if (s.selectedItemId) { + e.preventDefault(); + const r = s.getResult(s.selectedItemId); + s.setStatus(s.selectedItemId, r.status === 'passed' ? 'pending' : 'passed'); + } + break; + case 'f': + if (s.selectedItemId) { + e.preventDefault(); + const r = s.getResult(s.selectedItemId); + s.setStatus(s.selectedItemId, r.status === 'failed' ? 'pending' : 'failed'); + } + break; + case 's': + if (s.selectedItemId) { + e.preventDefault(); + const r = s.getResult(s.selectedItemId); + s.setStatus(s.selectedItemId, r.status === 'skipped' ? 'pending' : 'skipped'); + } + break; + case 'n': + if (s.selectedItemId) { + e.preventDefault(); + const itemEl = documentRef.current?.querySelector(`[data-item-id="${s.selectedItemId}"]`); + const noteBtn = itemEl?.querySelector('[title="Add note (n)"]') as HTMLElement | null; + if (noteBtn) { + setNotePopover({ anchorEl: noteBtn, itemId: s.selectedItemId }); + } + } + break; + case 'Enter': + if (s.selectedItemId) { + e.preventDefault(); + handleToggleExpand(s.selectedItemId); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [origin, handleSubmit, handleToggleExpand]); + + // Scroll selected item into view + useEffect(() => { + if (!state.selectedItemId || !documentRef.current) return; + const el = documentRef.current.querySelector(`[data-item-id="${state.selectedItemId}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [state.selectedItemId]); + + const agentLabel = origin === 'claude-code' ? 'Claude Code' : origin === 'pi' ? 'Pi' : 'OpenCode'; + + return ( +
+ setIsPanelOpen(p => !p)} + /> + + {/* Draft restore banner */} + + + {/* Main content */} +
+ {/* Checklist document */} +
+
+ {/* Title */} +

{checklist.title}

+ + {/* Summary */} + {checklist.summary && ( +

{checklist.summary}

+ )} + + {/* Progress bar */} + + + {/* Checklist items in glassomorphic surface */} +
+ {/* Global comment button — inside the surface, right-aligned */} +
+ +
+ + {state.categories.map(category => { + const items = state.groupedItems.get(category); + const progress = categoryProgress.get(category); + if (!items || !progress) return null; + return ( + + ); + })} +
+
+
+ + {/* Resize handle + Annotation panel */} + {isPanelOpen && ( + <> + + + + )} +
+ + {/* Comment popover */} + {notePopover && ( + i.id === notePopover.itemId)?.check || '' + : '' + } + isGlobal={notePopover.itemId === null} + initialText="" + onSubmit={handleNoteSubmit} + onClose={handleNoteClose} + /> + )} + + {/* Partial submit confirmation */} + setShowPartialConfirm(false)} + onConfirm={handleSubmit} + title="Partial Results" + message={<>{counts.pending} item{counts.pending !== 1 ? 's' : ''} haven't been reviewed yet. Submit anyway?} + confirmText="Submit Partial Results" + cancelText="Continue Reviewing" + variant="warning" + showCancel + /> + + {/* Completion overlay */} + {origin && ( + + )} + + {/* Demo mode toast */} + {!origin && ( +
+ Demo mode — j/k navigate, p/f/s set status, Enter expand +
+ )} +
+ ); +}; + +export default ChecklistApp; diff --git a/packages/checklist-editor/components/ChecklistAnnotationPanel.tsx b/packages/checklist-editor/components/ChecklistAnnotationPanel.tsx new file mode 100644 index 00000000..accc1157 --- /dev/null +++ b/packages/checklist-editor/components/ChecklistAnnotationPanel.tsx @@ -0,0 +1,234 @@ +import React, { useState, useRef } from 'react'; +import type { ChecklistItem, ChecklistItemResult } from '../hooks/useChecklistState'; +import type { ChecklistPR } from '@plannotator/shared/checklist-types'; +import { StatusIcon } from './StatusButton'; +import { PRIcon } from './ChecklistHeader'; + +export interface ChecklistAutomations { + postToPR: boolean; + approveIfAllPass: boolean; +} + +interface ChecklistAnnotationPanelProps { + items: ChecklistItem[]; + getResult: (id: string) => ChecklistItemResult; + globalNotes: string[]; + pr?: ChecklistPR; + automations: ChecklistAutomations; + onAutomationsChange: (automations: ChecklistAutomations) => void; + onSelectItem: (id: string) => void; + onRemoveItemNote: (id: string, index: number) => void; + onRemoveGlobalNote: (index: number) => void; + width: number; + feedbackMarkdown: string; +} + +// Provider labels +const PROVIDER_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + 'azure-devops': 'Azure DevOps', +}; + +export const ChecklistAnnotationPanel: React.FC = ({ + items, + getResult, + globalNotes, + pr, + automations, + onAutomationsChange, + onSelectItem, + onRemoveItemNote, + onRemoveGlobalNote, + width, + feedbackMarkdown, +}) => { + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef>(); + + const itemsWithNotes = items.filter(item => { + const result = getResult(item.id); + return (result.notes && result.notes.length > 0) || (result.images && result.images.length > 0); + }); + + // Count all individual notes + const itemNoteCount = itemsWithNotes.reduce((sum, item) => { + const result = getResult(item.id); + return sum + (result.notes?.length || 0); + }, 0); + const noteCount = itemNoteCount + globalNotes.length; + + const handleCopy = async () => { + if (!feedbackMarkdown) return; + try { + await navigator.clipboard.writeText(feedbackMarkdown); + setCopied(true); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + + return ( + + ); +}; diff --git a/packages/checklist-editor/components/ChecklistGroup.tsx b/packages/checklist-editor/components/ChecklistGroup.tsx new file mode 100644 index 00000000..821ab194 --- /dev/null +++ b/packages/checklist-editor/components/ChecklistGroup.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect, useRef } from 'react'; +import type { ChecklistItem as ChecklistItemType, ChecklistItemResult } from '../hooks/useChecklistState'; +import type { CategoryProgress } from '../hooks/useChecklistProgress'; +import { ChecklistItem } from './ChecklistItem'; +import { StatusIcon } from './StatusButton'; + +const MAX_COMPACT_VISIBLE = 3; + +interface ChecklistGroupProps { + category: string; + items: ChecklistItemType[]; + progress: CategoryProgress; + expandedItems: Set; + selectedItemId: string | null; + onToggleExpand: (id: string) => void; + onOpenNote: (anchorEl: HTMLElement, itemId: string) => void; + getResult: (id: string) => ChecklistItemResult; + onSetStatus: (id: string, status: ChecklistItemResult['status']) => void; +} + +export const ChecklistGroup: React.FC = ({ + category, + items, + progress, + expandedItems, + selectedItemId, + onToggleExpand, + onOpenNote, + getResult, + onSetStatus, +}) => { + const isComplete = progress.reviewed === progress.total && progress.reviewed > 0; + const [manuallyExpanded, setManuallyExpanded] = useState(false); + const wasCompleteRef = useRef(false); + + // Auto-collapse when category becomes complete + useEffect(() => { + if (isComplete && !wasCompleteRef.current) { + setManuallyExpanded(false); + } + wasCompleteRef.current = isComplete; + }, [isComplete]); + + const isCollapsed = isComplete && !manuallyExpanded; + const overflow = items.length - MAX_COMPACT_VISIBLE; + + return ( +
+ {/* Category heading */} +
setManuallyExpanded(prev => !prev) : undefined} + > + {/* Collapse chevron — only when complete */} + {isComplete && ( + + + + )} + + + {category} + + + {/* Micro-progress */} + + {progress.reviewed > 0 && ( + <> + + {progress.reviewed} + + /{progress.total} + + )} + {progress.reviewed === 0 && ( + {progress.total} + )} + + + {/* Completion check */} + {isComplete && progress.failed === 0 && ( + + + + )} + + {/* Failure indicator */} + {progress.failed > 0 && !isComplete && ( + + )} +
+ + {/* Full items — visible when not collapsed */} +
+
+
+ {items.map(item => ( + onToggleExpand(item.id)} + onOpenNote={(anchorEl) => onOpenNote(anchorEl, item.id)} + onSetStatus={status => onSetStatus(item.id, status)} + /> + ))} +
+
+
+ + {/* Compact summary — visible when collapsed */} +
+
+
+
+ {items.slice(0, MAX_COMPACT_VISIBLE).map((item, i) => { + const result = getResult(item.id); + return ( +
+ + {item.check} +
+ ); + })} + + {/* Overflow fade + count */} + {overflow > 0 && ( +
+ +{overflow} more +
+ )} +
+
+
+
+
+ ); +}; diff --git a/packages/checklist-editor/components/ChecklistHeader.tsx b/packages/checklist-editor/components/ChecklistHeader.tsx new file mode 100644 index 00000000..32759594 --- /dev/null +++ b/packages/checklist-editor/components/ChecklistHeader.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; +import { Settings } from '@plannotator/ui/components/Settings'; +import type { StatusCounts, SubmitState } from '../hooks/useChecklistProgress'; +import type { ChecklistPR } from '@plannotator/shared/checklist-types'; + +declare const __APP_VERSION__: string; + +// --------------------------------------------------------------------------- +// Provider icons (compact, monochrome-friendly) +// --------------------------------------------------------------------------- + +// Icons sourced from repo root SVGs (GitHub.svg, GitLab.svg, Azure Devops.svg) +const GitHubIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const GitLabIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + + + +); + +const AzureDevOpsIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + +); + +const ProviderIcon: React.FC<{ provider: ChecklistPR['provider']; className?: string }> = ({ provider, className }) => { + switch (provider) { + case 'github': return ; + case 'gitlab': return ; + case 'azure-devops': return ; + } +}; + +// PR icon (from PR.svg in repo root) +export const PRIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +// --------------------------------------------------------------------------- +// Submit button +// --------------------------------------------------------------------------- + +interface SubmitButtonProps { + submitState: SubmitState; + isSubmitting: boolean; + onClick: () => void; + counts: StatusCounts; +} + +const SubmitButton: React.FC = ({ + submitState, + isSubmitting, + onClick, + counts, +}) => { + if (isSubmitting) { + return ( + + ); + } + + switch (submitState) { + case 'all-pending': + return ( + + ); + case 'partial': + return ( + + ); + case 'all-reviewed-with-failures': + return ( + + ); + case 'all-passed': + return ( + + ); + } +}; + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +interface ChecklistHeaderProps { + title: string; + origin: string | null; + pr?: ChecklistPR; + counts: StatusCounts; + submitState: SubmitState; + isSubmitting: boolean; + onSubmit: () => void; + isPanelOpen: boolean; + onTogglePanel: () => void; +} + +export const ChecklistHeader: React.FC = ({ + title, + origin, + pr, + counts, + submitState, + isSubmitting, + onSubmit, + isPanelOpen, + onTogglePanel, +}) => ( +
+ {/* Left side */} +
+ + Plannotator + + + v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} + + + QA Checklist + + {origin && ( + + )} + + {/* PR link replaces title; fallback to title text */} + | + {pr ? ( + + + + #{pr.number} + {pr.branch && ( + <> + · + {pr.branch} + + )} + + ) : ( + + {title} + + )} +
+ + {/* Right side */} +
+ {origin && ( + + )} +
+ + {}} + onIdentityChange={() => {}} + origin={origin} + mode="review" + /> + {/* Panel toggle */} + +
+
+); diff --git a/packages/checklist-editor/components/ChecklistItem.tsx b/packages/checklist-editor/components/ChecklistItem.tsx new file mode 100644 index 00000000..833044aa --- /dev/null +++ b/packages/checklist-editor/components/ChecklistItem.tsx @@ -0,0 +1,203 @@ +import React, { useRef, useCallback } from 'react'; +import type { ChecklistItem as ChecklistItemType, ChecklistItemResult } from '../hooks/useChecklistState'; +import { StatusIcon, QuickActions } from './StatusButton'; + +interface ChecklistItemProps { + item: ChecklistItemType; + result: ChecklistItemResult; + isExpanded: boolean; + isSelected: boolean; + onToggleExpand: () => void; + onOpenNote: (anchorEl: HTMLElement) => void; + onSetStatus: (status: ChecklistItemResult['status']) => void; +} + +export const ChecklistItem: React.FC = ({ + item, + result, + isExpanded, + isSelected, + onToggleExpand, + onOpenNote, + onSetStatus, +}) => { + const noteButtonRef = useRef(null); + + const handleNoteClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (noteButtonRef.current) { + onOpenNote(noteButtonRef.current); + } + }, [onOpenNote]); + + const sectionHeading = "text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1.5"; + + return ( +
+ {/* Top row */} +
+ {/* Status icon */} +
+ +
+ + {/* Content */} +
+ {/* Check headline */} +
+ {item.check} +
+ + {/* Badges row */} +
+ + {item.category} + + {item.critical && ( + Critical + )} +
+
+ + {/* Quick actions + Note button */} +
e.stopPropagation()}> + + +
+
+ + {/* Description preview (collapsed only) */} + {!isExpanded && item.description && ( +
+
+ {item.description} +
+
+ )} + + {/* Expandable body */} +
+
+
+ {/* Description */} + {item.description && ( +
+

Description

+
+ {renderSimpleMarkdown(item.description)} +
+
+ )} + + {/* Verification steps */} + {item.steps.length > 0 && ( +
+

Verification Steps

+
    + {item.steps.map((step, i) => ( +
  1. {step}
  2. + ))} +
+
+ )} + + {/* File references */} + {item.files && item.files.length > 0 && ( +
+

Files

+
+ {item.files.map((file, i) => ( +
+ {file} +
+ ))} +
+
+ )} + + {/* Reason */} + {item.reason && ( +
+

Why Manual Verification

+

{item.reason}

+
+ )} +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Simple markdown renderer for description text +// --------------------------------------------------------------------------- + +function renderSimpleMarkdown(text: string): React.ReactNode { + const lines = text.split('\n'); + const elements: React.ReactNode[] = []; + let inCode = false; + let codeBlock: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('```')) { + if (inCode) { + elements.push(
{codeBlock.join('\n')}
); + codeBlock = []; + inCode = false; + } else { + inCode = true; + } + continue; + } + + if (inCode) { + codeBlock.push(line); + continue; + } + + if (line.trim() === '') { + elements.push(
); + continue; + } + + // Inline code + const rendered = line.replace(/`([^`]+)`/g, '$1'); + // Bold + const withBold = rendered.replace(/\*\*([^*]+)\*\*/g, '$1'); + + elements.push( +

, + ); + } + + if (inCode && codeBlock.length > 0) { + elements.push(

{codeBlock.join('\n')}
); + } + + return <>{elements}; +} diff --git a/packages/checklist-editor/components/ProgressBar.tsx b/packages/checklist-editor/components/ProgressBar.tsx new file mode 100644 index 00000000..f889f663 --- /dev/null +++ b/packages/checklist-editor/components/ProgressBar.tsx @@ -0,0 +1,82 @@ +import React, { useState, useEffect } from 'react'; +import type { StatusCounts } from '../hooks/useChecklistProgress'; + +interface ProgressBarProps { + counts: StatusCounts; + stopped?: boolean; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + + if (hours > 0) { + const hh = String(hours).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } + return `${mm}:${ss}`; +} + +export const ProgressBar: React.FC = ({ counts, stopped }) => { + const { passed, failed, skipped, pending, total } = counts; + const [elapsed, setElapsed] = useState(0); + const [startTime] = useState(() => Date.now()); + + useEffect(() => { + if (stopped) return; + const interval = setInterval(() => { + setElapsed(Date.now() - startTime); + }, 1000); + return () => clearInterval(interval); + }, [startTime, stopped]); + + if (total === 0) return null; + + const reviewed = passed + failed + skipped; + const pctValue = Math.round((reviewed / total) * 100); + const pct = (n: number) => `${(n / total) * 100}%`; + + return ( +
+
+ + {formatElapsed(elapsed)} + + + {pctValue}% — {reviewed}/{total} reviewed + +
+
+ {passed > 0 && ( +
+ )} + {failed > 0 && ( +
+ )} + {skipped > 0 && ( +
+ )} + {pending > 0 && ( +
+ )} +
+
+ ); +}; diff --git a/packages/checklist-editor/components/StatusButton.tsx b/packages/checklist-editor/components/StatusButton.tsx new file mode 100644 index 00000000..0084f2c2 --- /dev/null +++ b/packages/checklist-editor/components/StatusButton.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import type { ChecklistItemStatus } from '../hooks/useChecklistState'; + +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- + +const PassIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +const FailIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +const SkipIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +// --------------------------------------------------------------------------- +// Status Icon (for display) +// --------------------------------------------------------------------------- + +export const StatusIcon: React.FC<{ status: ChecklistItemStatus; className?: string }> = ({ + status, + className = 'w-4 h-4', +}) => { + switch (status) { + case 'passed': + return ( +
+ +
+ ); + case 'failed': + return ( +
+ +
+ ); + case 'skipped': + return ( +
+ +
+ ); + default: + return ( +
+
+
+ ); + } +}; + +// --------------------------------------------------------------------------- +// Action Buttons +// --------------------------------------------------------------------------- + +interface StatusButtonProps { + status: ChecklistItemStatus; + currentStatus: ChecklistItemStatus; + onClick: () => void; + size?: 'sm' | 'md'; +} + +const CONFIG: Record< + 'passed' | 'failed' | 'skipped', + { label: string; shortcut: string; activeClass: string; hoverClass: string; Icon: React.FC<{ className?: string }> } +> = { + passed: { + label: 'Pass', + shortcut: 'P', + activeClass: 'bg-success text-success-foreground', + hoverClass: 'hover:bg-success/15 hover:text-success', + Icon: PassIcon, + }, + failed: { + label: 'Fail', + shortcut: 'F', + activeClass: 'bg-destructive text-destructive-foreground', + hoverClass: 'hover:bg-destructive/15 hover:text-destructive', + Icon: FailIcon, + }, + skipped: { + label: 'Skip', + shortcut: 'S', + activeClass: 'bg-warning text-warning-foreground', + hoverClass: 'hover:bg-warning/15 hover:text-warning', + Icon: SkipIcon, + }, +}; + +export const StatusButton: React.FC = ({ + status, + currentStatus, + onClick, + size = 'md', +}) => { + if (status === 'pending') return null; + const cfg = CONFIG[status]; + const isActive = currentStatus === status; + const sizeClass = size === 'sm' ? 'px-1.5 py-0.5 text-[10px] gap-0.5' : 'px-2.5 py-1.5 text-xs gap-1.5'; + + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// Quick Action Buttons (compact row for hover) +// --------------------------------------------------------------------------- + +interface QuickActionsProps { + currentStatus: ChecklistItemStatus; + onSetStatus: (status: ChecklistItemStatus) => void; +} + +export const QuickActions: React.FC = ({ currentStatus, onSetStatus }) => ( +
e.stopPropagation()}> + {(['passed', 'failed', 'skipped'] as const).map(s => ( + onSetStatus(currentStatus === s ? 'pending' : s)} + /> + ))} +
+); diff --git a/packages/checklist-editor/hooks/useChecklistDraft.ts b/packages/checklist-editor/hooks/useChecklistDraft.ts new file mode 100644 index 00000000..a3ad9190 --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistDraft.ts @@ -0,0 +1,122 @@ +/** + * Auto-save checklist results to the server as a draft. + * + * Follows the pattern from useCodeAnnotationDraft — debounced POST to /api/draft, + * load on mount, restore/dismiss via banner. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { formatTimeAgo } from '@plannotator/ui/utils/timeFormat'; +import type { ChecklistItemResult } from './useChecklistState'; + +const DEBOUNCE_MS = 500; + +interface DraftData { + checklistResults: ChecklistItemResult[]; + globalNotes?: string[] | string; + ts: number; +} + +interface UseChecklistDraftOptions { + results: ChecklistItemResult[]; + globalNotes: string[]; + isApiMode: boolean; + submitted: boolean; +} + +interface UseChecklistDraftResult { + draftBanner: { count: number; timeAgo: string } | null; + restoreDraft: () => { results: ChecklistItemResult[]; globalNotes: string[] } | null; + dismissDraft: () => void; +} + +export function useChecklistDraft({ + results, + globalNotes, + isApiMode, + submitted, +}: UseChecklistDraftOptions): UseChecklistDraftResult { + const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null); + const draftDataRef = useRef(null); + const timerRef = useRef | null>(null); + const hasMountedRef = useRef(false); + + // Load draft on mount + useEffect(() => { + if (!isApiMode) return; + + fetch('/api/draft') + .then(res => { + if (!res.ok) return null; + return res.json(); + }) + .then((data: DraftData | null) => { + if ( + data?.checklistResults && + Array.isArray(data.checklistResults) && + data.checklistResults.length > 0 + ) { + const reviewed = data.checklistResults.filter(r => r.status !== 'pending'); + if (reviewed.length > 0) { + draftDataRef.current = data; + setDraftBanner({ + count: reviewed.length, + timeAgo: formatTimeAgo(data.ts || 0), + }); + } + } + hasMountedRef.current = true; + }) + .catch(() => { + hasMountedRef.current = true; + }); + }, [isApiMode]); + + // Debounced auto-save on result changes + useEffect(() => { + if (!isApiMode || submitted) return; + if (!hasMountedRef.current) return; + + const reviewed = results.filter(r => r.status !== 'pending'); + if (reviewed.length === 0) return; + + if (timerRef.current) clearTimeout(timerRef.current); + + timerRef.current = setTimeout(() => { + const payload: DraftData = { + checklistResults: results, + globalNotes: globalNotes.length > 0 ? globalNotes : undefined, + ts: Date.now(), + }; + + fetch('/api/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => {}); + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [results, globalNotes, isApiMode, submitted]); + + const restoreDraft = useCallback(() => { + const data = draftDataRef.current; + setDraftBanner(null); + draftDataRef.current = null; + if (!data) return null; + return { + results: data.checklistResults, + globalNotes: Array.isArray(data.globalNotes) ? data.globalNotes : data.globalNotes ? [data.globalNotes] : [], + }; + }, []); + + const dismissDraft = useCallback(() => { + setDraftBanner(null); + draftDataRef.current = null; + fetch('/api/draft', { method: 'DELETE' }).catch(() => {}); + }, []); + + return { draftBanner, restoreDraft, dismissDraft }; +} diff --git a/packages/checklist-editor/hooks/useChecklistProgress.ts b/packages/checklist-editor/hooks/useChecklistProgress.ts new file mode 100644 index 00000000..56b01c82 --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistProgress.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import type { ChecklistItem, ChecklistItemResult, ChecklistItemStatus } from './useChecklistState'; + +export interface StatusCounts { + passed: number; + failed: number; + skipped: number; + pending: number; + total: number; +} + +export interface CategoryProgress { + category: string; + reviewed: number; + total: number; + passed: number; + failed: number; + skipped: number; +} + +export type SubmitState = + | 'all-pending' + | 'partial' + | 'all-reviewed-with-failures' + | 'all-passed'; + +export function useChecklistProgress( + items: ChecklistItem[], + results: Map, +) { + const counts = useMemo((): StatusCounts => { + const c: StatusCounts = { passed: 0, failed: 0, skipped: 0, pending: 0, total: items.length }; + for (const item of items) { + const r = results.get(item.id); + const status: ChecklistItemStatus = r?.status ?? 'pending'; + c[status]++; + } + return c; + }, [items, results]); + + const categoryProgress = useMemo((): Map => { + const map = new Map(); + for (const item of items) { + let cp = map.get(item.category); + if (!cp) { + cp = { category: item.category, reviewed: 0, total: 0, passed: 0, failed: 0, skipped: 0 }; + map.set(item.category, cp); + } + cp.total++; + const r = results.get(item.id); + const status: ChecklistItemStatus = r?.status ?? 'pending'; + if (status !== 'pending') { + cp.reviewed++; + if (status === 'passed') cp.passed++; + if (status === 'failed') cp.failed++; + if (status === 'skipped') cp.skipped++; + } + } + return map; + }, [items, results]); + + const submitState = useMemo((): SubmitState => { + const reviewed = counts.passed + counts.failed + counts.skipped; + if (reviewed === 0) return 'all-pending'; + if (reviewed < items.length) return 'partial'; + if (counts.failed > 0) return 'all-reviewed-with-failures'; + return 'all-passed'; + }, [items.length, counts]); + + return { counts, categoryProgress, submitState }; +} diff --git a/packages/checklist-editor/hooks/useChecklistState.ts b/packages/checklist-editor/hooks/useChecklistState.ts new file mode 100644 index 00000000..3a7237aa --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistState.ts @@ -0,0 +1,197 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { + Checklist, + ChecklistItem, + ChecklistItemStatus, + ChecklistItemResult, +} from '@plannotator/shared/checklist-types'; + +export type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult }; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +interface UseChecklistStateOptions { + items: ChecklistItem[]; +} + +export function useChecklistState({ items }: UseChecklistStateOptions) { + const [results, setResults] = useState>(() => { + const map = new Map(); + for (const item of items) { + map.set(item.id, { id: item.id, status: 'pending' }); + } + return map; + }); + + const [selectedItemId, setSelectedItemId] = useState( + items.length > 0 ? items[0].id : null, + ); + + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + // Categories in order of first appearance + const categories = useMemo(() => { + const seen = new Set(); + const cats: string[] = []; + for (const item of items) { + if (!seen.has(item.category)) { + seen.add(item.category); + cats.push(item.category); + } + } + return cats; + }, [items]); + + // Grouped items + const groupedItems = useMemo(() => { + const map = new Map(); + for (const cat of categories) { + map.set(cat, []); + } + for (const item of items) { + map.get(item.category)!.push(item); + } + return map; + }, [items, categories]); + + // Flat ordered list for keyboard navigation + const flatItemIds = useMemo(() => { + const ids: string[] = []; + for (const cat of categories) { + if (collapsedGroups.has(cat)) continue; + const group = groupedItems.get(cat); + if (group) { + for (const item of group) ids.push(item.id); + } + } + return ids; + }, [categories, groupedItems, collapsedGroups]); + + // --- Actions --- + + const setStatus = useCallback((id: string, status: ChecklistItemStatus) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id); + next.set(id, { ...existing, id, status }); + return next; + }); + }, []); + + const addNote = useCallback((id: string, note: string) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id) || { id, status: 'pending' as const }; + const notes = [...(existing.notes || []), note]; + next.set(id, { ...existing, notes }); + return next; + }); + }, []); + + const removeNote = useCallback((id: string, index: number) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id); + if (!existing?.notes) return prev; + const notes = existing.notes.filter((_, i) => i !== index); + next.set(id, { ...existing, notes: notes.length > 0 ? notes : undefined }); + return next; + }); + }, []); + + const setImages = useCallback((id: string, images: { path: string; name: string }[]) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id) || { id, status: 'pending' as const }; + next.set(id, { ...existing, images: images.length > 0 ? images : undefined }); + return next; + }); + }, []); + + const selectItem = useCallback((id: string | null) => { + setSelectedItemId(id); + }, []); + + const selectNext = useCallback((): string | null => { + if (!selectedItemId) { + if (flatItemIds.length > 0) { + setSelectedItemId(flatItemIds[0]); + return flatItemIds[0]; + } + return null; + } + const idx = flatItemIds.indexOf(selectedItemId); + if (idx < flatItemIds.length - 1) { + setSelectedItemId(flatItemIds[idx + 1]); + return flatItemIds[idx + 1]; + } + return selectedItemId; + }, [selectedItemId, flatItemIds]); + + const selectPrev = useCallback((): string | null => { + if (!selectedItemId) { + if (flatItemIds.length > 0) { + const last = flatItemIds[flatItemIds.length - 1]; + setSelectedItemId(last); + return last; + } + return null; + } + const idx = flatItemIds.indexOf(selectedItemId); + if (idx > 0) { + setSelectedItemId(flatItemIds[idx - 1]); + return flatItemIds[idx - 1]; + } + return selectedItemId; + }, [selectedItemId, flatItemIds]); + + const toggleGroup = useCallback((category: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }, []); + + const getResult = useCallback((id: string): ChecklistItemResult => { + return results.get(id) || { id, status: 'pending' }; + }, [results]); + + const allResults = useMemo(() => Array.from(results.values()), [results]); + + const restoreResults = useCallback((restored: ChecklistItemResult[]) => { + setResults(prev => { + const next = new Map(prev); + for (const r of restored) { + next.set(r.id, r); + } + return next; + }); + }, []); + + return { + results, + allResults, + selectedItemId, + collapsedGroups, + categories, + groupedItems, + flatItemIds, + setStatus, + addNote, + removeNote, + setImages, + selectItem, + selectNext, + selectPrev, + toggleGroup, + getResult, + restoreResults, + }; +} diff --git a/packages/checklist-editor/index.css b/packages/checklist-editor/index.css new file mode 100644 index 00000000..a5c3e0f4 --- /dev/null +++ b/packages/checklist-editor/index.css @@ -0,0 +1,282 @@ +@import "tailwindcss"; + +/* Tell Tailwind where to scan for classes */ +@source "../ui/components/**/*.tsx"; +@source "../ui/hooks/**/*.ts"; +@source "./*.tsx"; +@source "./components/**/*.tsx"; + +@import "../ui/styles/theme.css"; + +/* ===================================================== + QA Checklist Specific Styles + ===================================================== */ + +/* Background grid — matches plan review document area */ +.bg-grid { + background-image: + linear-gradient(to right, oklch(0.32 0.02 260 / 0.5) 1px, transparent 1px), + linear-gradient(to bottom, oklch(0.32 0.02 260 / 0.5) 1px, transparent 1px); + background-size: 24px 24px; +} + +.light .bg-grid { + background-image: + linear-gradient(to right, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px), + linear-gradient(to bottom, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px); +} + +/* Checklist item — status expressed via background tint, not left border */ +.checklist-item { + transition: background-color 150ms, box-shadow 150ms, border-color 150ms; +} + +.checklist-item.passed { + background: oklch(from var(--success) l c h / 0.06); + border-color: oklch(from var(--success) l c h / 0.2); +} + +.checklist-item.failed { + background: oklch(from var(--destructive) l c h / 0.06); + border-color: oklch(from var(--destructive) l c h / 0.2); +} + +.checklist-item.skipped { + background: oklch(from var(--warning) l c h / 0.06); + border-color: oklch(from var(--warning) l c h / 0.2); +} + +.checklist-item.selected { + box-shadow: + 0 0 0 1px oklch(from var(--primary) l c h / 0.3), + 0 2px 8px -2px rgba(0, 0, 0, 0.12); +} + +/* Critical badge */ +.critical-badge { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--destructive-foreground); + background: var(--destructive); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} + +/* Progress bar segments */ +.progress-segment { + transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Category group header */ +.category-header { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); +} + +/* Quick action buttons — always visible in card layout */ +.quick-actions { + opacity: 1; +} + +/* Description preview with gradient mask fade */ +.checklist-item-preview { + max-height: 2.5em; + overflow: hidden; + mask-image: linear-gradient(to bottom, black 50%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); +} + +/* Expandable body — CSS grid trick for smooth expand */ +.checklist-item-body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 200ms ease; +} + +.checklist-item-body.expanded { + grid-template-rows: 1fr; +} + +.checklist-item-body > div { + min-height: 0; + overflow: hidden; +} + +/* Category group collapse — same CSS grid trick, slightly slower */ +.checklist-group-body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.checklist-group-body.expanded { + grid-template-rows: 1fr; +} + +.checklist-group-body > div { + min-height: 0; + overflow: hidden; +} + +/* Category group compact summary — inverse of group-body */ +.checklist-group-compact { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.checklist-group-compact.expanded { + grid-template-rows: 1fr; +} + +.checklist-group-compact > div { + min-height: 0; + overflow: hidden; +} + +.compact-summary-inner { + padding: 0.25rem 0.25rem 0.5rem; +} + +.compact-summary-row { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + line-height: 1.4; + color: var(--muted-foreground); + padding: 0.125rem 0; + opacity: 0; + animation: compact-fade-in 200ms ease forwards; +} + +.compact-summary-row span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes compact-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 0.7; + transform: translateY(0); + } +} + +.compact-summary-overflow { + display: flex; + align-items: center; + padding-top: 0.125rem; + font-size: 0.6875rem; + font-weight: 500; + color: var(--muted-foreground); + opacity: 0.4; + mask-image: linear-gradient(to bottom, black 30%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%); +} + +/* Document centering — matches plan review centered column */ +.checklist-document { + max-width: 52rem; + margin: 0 auto; + padding: 2rem 1.5rem; +} + + +/* Markdown rendered in description panel */ +.checklist-description { + font-size: 0.875rem; + line-height: 1.6; + color: var(--card-foreground); +} + +.checklist-description code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--muted); + padding: 0.1em 0.35em; + border-radius: 0.25rem; +} + +.checklist-description pre { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.5; + background: oklch(0.13 0.015 260); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + margin: 0.375rem 0; + overflow-x: auto; +} + +.light .checklist-description pre { + background: oklch(0.96 0.005 260); +} + +/* Verification steps list */ +.verification-steps { + counter-reset: step; + list-style: none; + padding: 0; +} + +.verification-steps li { + counter-increment: step; + position: relative; + padding-left: 2rem; + margin-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.5; +} + +.verification-steps li::before { + content: counter(step); + position: absolute; + left: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--primary); + background: oklch(from var(--primary) l c h / 0.1); + border-radius: 50%; +} + + +/* Toast animations */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in-from-bottom-2 { + from { transform: translateY(0.5rem) translateX(-50%); } + to { transform: translateY(0) translateX(-50%); } +} + +.animate-in { + animation-duration: 200ms; + animation-timing-function: ease-out; + animation-fill-mode: both; +} + +.fade-in { + animation-name: fade-in; +} + +.slide-in-from-bottom-2 { + animation-name: slide-in-from-bottom-2; +} diff --git a/packages/checklist-editor/package.json b/packages/checklist-editor/package.json new file mode 100644 index 00000000..45d4552d --- /dev/null +++ b/packages/checklist-editor/package.json @@ -0,0 +1,16 @@ +{ + "name": "@plannotator/checklist-editor", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./App.tsx", + "./styles": "./index.css" + }, + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + } +} diff --git a/packages/checklist-editor/utils/exportChecklist.ts b/packages/checklist-editor/utils/exportChecklist.ts new file mode 100644 index 00000000..4e4c9ebd --- /dev/null +++ b/packages/checklist-editor/utils/exportChecklist.ts @@ -0,0 +1,87 @@ +import type { ChecklistItem, ChecklistItemResult, ChecklistItemStatus } from '../hooks/useChecklistState'; + +const STATUS_ICONS: Record = { + passed: '[PASS]', + failed: '[FAIL]', + skipped: '[SKIP]', + pending: '[----]', +}; + +/** + * Format checklist results as markdown feedback suitable for sending + * to the agent or copying to clipboard. + */ +export function exportChecklistResults( + items: ChecklistItem[], + results: Map, + globalNotes?: string[] | string, +): string { + const lines: string[] = []; + + lines.push('# QA Checklist Results\n'); + + // Summary counts + const counts = { passed: 0, failed: 0, skipped: 0, pending: 0 }; + for (const item of items) { + const r = results.get(item.id); + counts[r?.status ?? 'pending']++; + } + + const total = items.length; + const reviewed = counts.passed + counts.failed + counts.skipped; + lines.push(`**${reviewed}/${total}** items reviewed | ${counts.passed} passed | ${counts.failed} failed | ${counts.skipped} skipped\n`); + + // Group by category + const categories: string[] = []; + const grouped = new Map(); + for (const item of items) { + if (!grouped.has(item.category)) { + categories.push(item.category); + grouped.set(item.category, []); + } + grouped.get(item.category)!.push(item); + } + + for (const category of categories) { + const catItems = grouped.get(category)!; + lines.push(`## ${category}\n`); + + for (const item of catItems) { + const r = results.get(item.id); + const status = r?.status ?? 'pending'; + const icon = STATUS_ICONS[status]; + const critical = item.critical ? ' **[CRITICAL]**' : ''; + + lines.push(`### ${icon} ${item.check}${critical}\n`); + + if (status === 'failed') { + lines.push(`> **Status:** FAILED\n`); + } + + if (r?.notes && r.notes.length > 0) { + for (const note of r.notes) { + lines.push(`**Notes:** ${note}\n`); + } + } + + if (r?.images && r.images.length > 0) { + lines.push('**Evidence:**'); + for (const img of r.images) { + lines.push(`- [${img.name}] ${img.path}`); + } + lines.push(''); + } + } + } + + const notes = Array.isArray(globalNotes) ? globalNotes : globalNotes ? [globalNotes] : []; + if (notes.length > 0) { + lines.push('---\n'); + lines.push(`## Overall Notes\n`); + for (const note of notes) { + lines.push(`- ${note}\n`); + } + } + + return lines.join('\n'); +} diff --git a/packages/server/checklist.ts b/packages/server/checklist.ts new file mode 100644 index 00000000..603b5e95 --- /dev/null +++ b/packages/server/checklist.ts @@ -0,0 +1,493 @@ +/** + * Checklist Server + * + * Serves a QA checklist for interactive developer verification. + * The agent produces structured JSON, this server serves it to + * the checklist UI and collects per-item pass/fail/skip results. + * + * Follows the same patterns as annotate.ts (simplest server). + * + * Environment variables: + * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode + * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) + */ + +import { homedir } from "os"; +import { join } from "path"; +import { mkdirSync, writeFileSync } from "fs"; +import { startServer } from "./serve"; +import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers"; +import { contentHash, deleteDraft } from "./draft"; +import type { Checklist, ChecklistItem, ChecklistPR, ChecklistSubmission, ChecklistItemResult } from "@plannotator/shared/checklist-types"; + +// Re-export utilities +export { isRemoteSession, getServerPort } from "./remote"; +export { openBrowser } from "./browser"; +export { handleServerReady as handleChecklistServerReady } from "./shared-handlers"; + +// Re-export types for consumers +export type { Checklist, ChecklistItem, ChecklistPR, ChecklistSubmission, ChecklistItemResult }; + +// --- Types --- + +export interface ChecklistServerOptions { + /** Validated checklist JSON from the agent */ + checklist: Checklist; + /** HTML content to serve for the UI */ + htmlContent: string; + /** Origin identifier for UI customization */ + origin?: "opencode" | "claude-code" | "pi"; + /** Project name for storage scoping */ + project?: string; + /** Called when server starts with the URL, remote status, and port */ + onReady?: (url: string, isRemote: boolean, port: number) => void; +} + +export interface ChecklistServerResult { + /** The port the server is running on */ + port: number; + /** The full URL to access the server */ + url: string; + /** Whether running in remote mode */ + isRemote: boolean; + /** Wait for user checklist submission */ + waitForDecision: () => Promise; + /** Stop the server */ + stop: () => void; +} + +export interface ChecklistDecision { + /** Formatted markdown feedback for the agent */ + feedback: string; + /** Per-item results */ + results: ChecklistItemResult[]; + /** Path where checklist + results were saved */ + savedTo?: string; + /** Optional agent switch target */ + agentSwitch?: string; +} + +// --- Validation --- + +/** + * Validate a checklist JSON object. + * Returns an array of error messages (empty = valid). + */ +export function validateChecklist(data: unknown): string[] { + const errors: string[] = []; + + if (!data || typeof data !== "object") { + errors.push("Checklist must be a JSON object."); + return errors; + } + + const obj = data as Record; + + if (typeof obj.title !== "string" || !obj.title.trim()) { + errors.push('Missing or empty "title" (string).'); + } + + if (typeof obj.summary !== "string" || !obj.summary.trim()) { + errors.push('Missing or empty "summary" (string).'); + } + + // Validate optional PR field + if (obj.pr !== undefined) { + if (typeof obj.pr !== "object" || obj.pr === null) { + errors.push('"pr" must be an object if provided.'); + } else { + const pr = obj.pr as Record; + if (typeof pr.number !== "number") { + errors.push('pr.number must be a number.'); + } + if (typeof pr.url !== "string" || !pr.url) { + errors.push('pr.url must be a non-empty string.'); + } + const validProviders = ["github", "gitlab", "azure-devops"]; + if (!validProviders.includes(pr.provider as string)) { + errors.push(`pr.provider must be one of: ${validProviders.join(", ")}.`); + } + } + } + + if (!Array.isArray(obj.items)) { + errors.push('"items" must be an array.'); + return errors; + } + + if (obj.items.length === 0) { + errors.push('"items" array is empty — include at least one checklist item.'); + } + + for (let i = 0; i < obj.items.length; i++) { + const item = obj.items[i] as Record; + const prefix = `items[${i}]`; + + if (typeof item.id !== "string" || !item.id.trim()) { + errors.push(`${prefix}: missing "id" (string, e.g. "func-1").`); + } + + if (typeof item.category !== "string" || !item.category.trim()) { + errors.push(`${prefix}: missing "category" (string, e.g. "functional").`); + } + + if (typeof item.check !== "string" || !item.check.trim()) { + errors.push(`${prefix}: missing "check" (imperative verb phrase).`); + } + + if (typeof item.description !== "string" || !item.description.trim()) { + errors.push(`${prefix}: missing "description" (markdown narrative).`); + } + + if (!Array.isArray(item.steps) || item.steps.length === 0) { + errors.push(`${prefix}: "steps" must be a non-empty array of strings.`); + } + + if (typeof item.reason !== "string" || !item.reason.trim()) { + errors.push(`${prefix}: missing "reason" (why manual verification is needed).`); + } + } + + return errors; +} + +// --- Feedback Formatting --- + +/** + * Format checklist results as markdown for the agent. + */ +export function formatChecklistFeedback( + checklist: Checklist, + results: ChecklistItemResult[], + globalNotes?: string[] | string, + automations?: { postToPR?: boolean; approveIfAllPass?: boolean }, +): string { + const resultMap = new Map(results.map((r) => [r.id, r])); + + let passed = 0; + let failed = 0; + let skipped = 0; + + for (const item of checklist.items) { + const result = resultMap.get(item.id); + if (result?.status === "passed") passed++; + else if (result?.status === "failed") failed++; + else if (result?.status === "skipped") skipped++; + } + + const lines: string[] = []; + + lines.push("# QA Checklist Results"); + lines.push(""); + lines.push("## Summary"); + lines.push(`- **Title**: ${checklist.title}`); + lines.push(`- **Total**: ${checklist.items.length} items`); + lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}`); + lines.push(""); + + // Failed items — full detail + const failedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "failed" + ); + if (failedItems.length > 0) { + lines.push("## Failed Items"); + lines.push(""); + for (const item of failedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: FAILED`); + lines.push(`**Category**: ${item.category}`); + if (item.critical) lines.push(`**Critical**: yes`); + if (item.files?.length) lines.push(`**Files**: ${item.files.join(", ")}`); + const itemNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of itemNotes) { + lines.push(`**Developer notes**: ${note}`); + } + if (result.images?.length) { + for (const img of result.images) { + lines.push(`[${img.name}] ${img.path}`); + } + } + lines.push(""); + } + } + + // Skipped items + const skippedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "skipped" + ); + if (skippedItems.length > 0) { + lines.push("## Skipped Items"); + lines.push(""); + for (const item of skippedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: SKIPPED`); + const skipNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of skipNotes) { + lines.push(`**Reason**: ${note}`); + } + lines.push(""); + } + } + + // Passed items — compact + const passedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "passed" + ); + if (passedItems.length > 0) { + lines.push("## Passed Items"); + lines.push(""); + for (const item of passedItems) { + const result = resultMap.get(item.id); + const passNotes = Array.isArray(result?.notes) ? result.notes : result?.notes ? [result.notes] : []; + const notes = passNotes.length > 0 ? ` — ${passNotes.join('; ')}` : ""; + lines.push(`- [PASS] ${item.id}: ${item.check}${notes}`); + } + lines.push(""); + } + + // Global notes + const notes = Array.isArray(globalNotes) ? globalNotes : globalNotes ? [globalNotes] : []; + if (notes.length > 0) { + lines.push("## Developer Comments"); + lines.push(""); + for (const note of notes) { + lines.push(`> ${note.trim().replace(/\n/g, "\n> ")}`); + lines.push(""); + } + } + + // Automation instructions + if (automations && checklist.pr) { + const pr = checklist.pr; + const hasAutomations = automations.postToPR || automations.approveIfAllPass; + + if (hasAutomations) { + lines.push("## Automations"); + lines.push(""); + + if (automations.postToPR) { + if (pr.provider === "github") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`gh\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr comment ${pr.number} --body ''`); + lines.push("```"); + lines.push("If `gh` is not available, inform the developer to install the GitHub CLI (`brew install gh` or https://cli.github.com)."); + } else if (pr.provider === "gitlab") { + lines.push("**Post results to MR**: The developer requested that you post these checklist results as a comment on the merge request."); + lines.push(`Use the \`glab\` CLI to post a note to MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr note ${pr.number} --message ''`); + lines.push("```"); + lines.push("If `glab` is not available, inform the developer to install the GitLab CLI (`brew install glab` or https://gitlab.com/gitlab-org/cli)."); + } else if (pr.provider === "azure-devops") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`az\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr update --id ${pr.number} --description ''`); + lines.push("```"); + lines.push("If `az` is not available, inform the developer to install Azure CLI (`brew install azure-cli` or https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)."); + } + lines.push(""); + } + + if (automations.approveIfAllPass && failed === 0 && skipped === 0) { + if (pr.provider === "github") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr review ${pr.number} --approve --body 'QA checklist passed (${passed}/${passed} items)'`); + lines.push("```"); + } else if (pr.provider === "gitlab") { + lines.push("**Approve MR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`glab\` CLI to approve MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr approve ${pr.number}`); + lines.push("```"); + } else if (pr.provider === "azure-devops") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`az\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr set-vote --id ${pr.number} --vote approve`); + lines.push("```"); + } + lines.push(""); + } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0)) { + lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); + lines.push(""); + } + } + } + + return lines.join("\n"); +} + +// --- Storage --- + +/** + * Save a completed checklist (original + results) to disk. + * Returns the path to the saved file. + * + * Structure: ~/.plannotator/checklists/{project}/{slug}.json + * The saved file contains the full checklist JSON plus results, + * so it can be reopened via `plannotator checklist --file `. + */ +function saveChecklistResults( + checklist: Checklist, + results: ChecklistItemResult[], + globalNotes: string[] | string | undefined, + project: string, +): string { + const dir = join(homedir(), ".plannotator", "checklists", project); + mkdirSync(dir, { recursive: true }); + + const date = new Date().toISOString().split("T")[0]; + const slug = checklist.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + const timestamp = Date.now(); + const filename = `${slug}-${date}-${timestamp}.json`; + const filePath = join(dir, filename); + + writeFileSync(filePath, JSON.stringify({ + checklist, + results, + globalNotes, + submittedAt: new Date().toISOString(), + project, + }, null, 2)); + + return filePath; +} + +// --- Server Implementation --- + +/** + * Start the Checklist server + * + * Handles: + * - Remote detection and port configuration + * - API routes (/api/checklist, /api/feedback) + * - Port conflict retries + */ +export async function startChecklistServer( + options: ChecklistServerOptions +): Promise { + const { + checklist, + htmlContent, + origin, + project = "_unknown", + onReady, + } = options; + + const draftKey = contentHash(JSON.stringify(checklist)); + + // Decision promise + let resolveDecision: (result: ChecklistDecision) => void; + const decisionPromise = new Promise((resolve) => { + resolveDecision = resolve; + }); + + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); + + // API: Get checklist data + if (url.pathname === "/api/checklist" && req.method === "GET") { + return Response.json({ + checklist, + origin, + mode: "checklist", + }); + } + + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + return handleImage(req); + } + + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req); + } + + // API: Checklist draft persistence + if (url.pathname === "/api/draft") { + if (req.method === "POST") return handleDraftSave(req, draftKey); + if (req.method === "DELETE") return handleDraftDelete(draftKey); + return handleDraftLoad(draftKey); + } + + // API: Submit checklist results + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as ChecklistSubmission & { + agentSwitch?: string; + }; + + deleteDraft(draftKey); + + const results = body.results || []; + + // Save to disk + let savedTo: string | undefined; + try { + savedTo = saveChecklistResults( + checklist, + results, + body.globalNotes, + project, + ); + } catch { + // Non-fatal — feedback still goes to agent + } + + const feedback = formatChecklistFeedback( + checklist, + results, + body.globalNotes, + body.automations, + ); + + resolveDecision({ + feedback, + results, + savedTo, + agentSwitch: body.agentSwitch, + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to process checklist submission"; + return Response.json({ error: message }, { status: 500 }); + } + } + + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + + // Notify caller that server is ready + if (onReady) { + onReady(serverUrl, isRemote, port); + } + + return { + port, + url: serverUrl, + isRemote, + waitForDecision: () => decisionPromise, + stop: () => server.stop(), + }; +} diff --git a/packages/shared/checklist-types.ts b/packages/shared/checklist-types.ts new file mode 100644 index 00000000..45fe4a75 --- /dev/null +++ b/packages/shared/checklist-types.ts @@ -0,0 +1,70 @@ +// --- Agent-Produced Checklist --- + +export interface ChecklistItem { + /** Category-prefixed ID, e.g. "func-1", "sec-2" */ + id: string; + /** Free-form category label, e.g. "visual", "security", "api-contract" */ + category: string; + /** Imperative verb phrase: "Verify that..." */ + check: string; + /** Markdown narrative: what changed, what could go wrong, expected behavior */ + description: string; + /** Ordered instructions for conducting the verification */ + steps: string[]; + /** Why manual verification is needed (not automatable) */ + reason: string; + /** Related file paths from the diff */ + files?: string[]; + /** True if failure means data loss, security breach, or broken deploy */ + critical?: boolean; +} + +/** Pull/merge request reference for linking checklist to a PR */ +export interface ChecklistPR { + /** PR/MR number */ + number: number; + /** Full URL to the PR/MR */ + url: string; + /** PR/MR title */ + title?: string; + /** Source branch name */ + branch?: string; + /** Git hosting provider */ + provider: "github" | "gitlab" | "azure-devops"; +} + +export interface Checklist { + /** Short title for the checklist */ + title: string; + /** One paragraph: what changed and why manual verification matters */ + summary: string; + /** The verification items */ + items: ChecklistItem[]; + /** Optional associated pull/merge request */ + pr?: ChecklistPR; +} + +// --- Developer Response --- + +export type ChecklistItemStatus = "passed" | "failed" | "skipped" | "pending"; + +export interface ChecklistItemResult { + /** Matches the original item ID */ + id: string; + status: ChecklistItemStatus; + /** Developer notes (failure details, skip reason, questions) */ + notes?: string[]; + /** Screenshot evidence */ + images?: { path: string; name: string }[]; +} + +export interface ChecklistSubmission { + results: ChecklistItemResult[]; + /** Overall notes from the developer */ + globalNotes?: string[]; + /** Automation flags selected by the developer */ + automations?: { + postToPR?: boolean; + approveIfAllPass?: boolean; + }; +} From 587ac3101ebcd1d2cac276ff7ea650bf0f47e834 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 10 Mar 2026 23:07:26 -0700 Subject: [PATCH 03/15] fix: add missing tailwindcss dependency to checklist-editor The @tailwindcss/vite plugin resolves tailwindcss from the CSS file's directory, not the app's. CI failed because tailwindcss wasn't listed in checklist-editor's package.json (only hoisted locally). Co-Authored-By: Claude Opus 4.6 --- bun.lock | 1 + packages/checklist-editor/package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 51a44249..d3253b81 100644 --- a/bun.lock +++ b/bun.lock @@ -158,6 +158,7 @@ "@plannotator/ui": "workspace:*", "react": "^19.2.3", "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", }, }, "packages/editor": { diff --git a/packages/checklist-editor/package.json b/packages/checklist-editor/package.json index 45d4552d..99436842 100644 --- a/packages/checklist-editor/package.json +++ b/packages/checklist-editor/package.json @@ -11,6 +11,7 @@ "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18" } } From c519e4f90ad07265c3f159337c1dcbd2c7a8209d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 10 Mar 2026 23:08:37 -0700 Subject: [PATCH 04/15] fix: gitignore compiled checklist HTML in opencode and pi plugins These are build artifacts generated by copying from apps/checklist/dist, same pattern as plannotator.html and review-editor.html which were already gitignored. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 +- apps/opencode-plugin/checklist.html | 87 ----------------------------- apps/pi-extension/checklist.html | 87 ----------------------------- 3 files changed, 4 insertions(+), 176 deletions(-) delete mode 100644 apps/opencode-plugin/checklist.html delete mode 100644 apps/pi-extension/checklist.html diff --git a/.gitignore b/.gitignore index 6bac1df9..2075e369 100644 --- a/.gitignore +++ b/.gitignore @@ -19,13 +19,15 @@ dist-ssr # VS Code extension package *.vsix -# OpenCode plugin build artifacts (generated from hook/review dist) +# OpenCode plugin build artifacts (generated from hook/review/checklist dist) apps/opencode-plugin/plannotator.html apps/opencode-plugin/review-editor.html +apps/opencode-plugin/checklist.html -# Pi extension build artifacts (generated from hook/review dist) +# Pi extension build artifacts (generated from hook/review/checklist dist) apps/pi-extension/plannotator.html apps/pi-extension/review-editor.html +apps/pi-extension/checklist.html # Editor directories and files .vscode/* diff --git a/apps/opencode-plugin/checklist.html b/apps/opencode-plugin/checklist.html deleted file mode 100644 index 052f6b16..00000000 --- a/apps/opencode-plugin/checklist.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - QA Checklist - - - - - - - - - - -
- - diff --git a/apps/pi-extension/checklist.html b/apps/pi-extension/checklist.html deleted file mode 100644 index 8aeb7857..00000000 --- a/apps/pi-extension/checklist.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - QA Checklist - - - - - - - - - - -
- - From 8c260735a775abedd0d90d4ea94d52236c44bd45 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 10 Mar 2026 23:16:09 -0700 Subject: [PATCH 05/15] docs: lead READMEs with capabilities table Move the capabilities/features summary to the top of the Codex and Claude Code plugin READMEs so all features are visible immediately. Co-Authored-By: Claude Opus 4.6 --- apps/codex/README.md | 32 ++++++++++++++++---------------- apps/hook/README.md | 21 ++++++++++----------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/apps/codex/README.md b/apps/codex/README.md index 0c0944e9..7d155da1 100644 --- a/apps/codex/README.md +++ b/apps/codex/README.md @@ -1,6 +1,14 @@ # Plannotator for Codex -Code review and markdown annotation are supported today. Plan mode is not yet supported — it requires hooks to intercept the agent's plan submission, which Codex does not currently expose. +## Capabilities + +| Feature | How to use | +|---------|------------| +| **Code Review** | `!plannotator review` — Visual diff annotation UI | +| **Markdown Annotation** | `!plannotator annotate path/to/file.md` — Annotate any markdown file | +| **QA Checklist** | Skill: `checklist` — Generate and verify QA checklists interactively | + +Plan mode is not yet supported — it requires hooks to intercept the agent's plan submission, which Codex does not currently expose. ## Install @@ -16,26 +24,28 @@ curl -fsSL https://plannotator.ai/install.sh | bash irm https://plannotator.ai/install.ps1 | iex ``` +This installs the `plannotator` CLI and places skills in `~/.agents/skills/` where Codex discovers them on startup. To install skills only: `npx skills add backnotprop/plannotator`. + ## Usage ### Code Review -Run `!plannotator review` to open the code review UI for your current changes: - ``` !plannotator review ``` -This captures your git diff, opens a browser with the review UI, and waits for your feedback. When you submit annotations, the feedback is printed to stdout. +Captures your git diff, opens a browser with the review UI, and waits for your feedback. Annotations are sent back to the agent as structured feedback. ### Annotate Markdown -Run `!plannotator annotate` to annotate any markdown file: - ``` !plannotator annotate path/to/file.md ``` +### QA Checklist + +The `checklist` skill is invoked by the agent when you ask it to verify changes, create acceptance criteria, or run QA checks. It generates a structured checklist and opens an interactive UI for pass/fail/skip verification. + ## Environment Variables | Variable | Description | @@ -44,16 +54,6 @@ Run `!plannotator annotate` to annotate any markdown file: | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | | `PLANNOTATOR_BROWSER` | Custom browser to open. macOS: app name or path. Linux/Windows: executable path. | -## Skills - -Skills are installed automatically by the install script above. They are placed in `~/.agents/skills/` and discovered by Codex on startup. - -Alternatively, install skills only via `npx skills add backnotprop/plannotator`. - -| Skill | Description | -|-------|-------------| -| `checklist` | Generate a QA checklist for manual verification of code changes | - ## Links - [Website](https://plannotator.ai) diff --git a/apps/hook/README.md b/apps/hook/README.md index 1fdef746..1f6e95b3 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -1,6 +1,13 @@ # Plannotator Claude Code Plugin -This directory contains the Claude Code plugin configuration for Plannotator. +## Capabilities + +| Feature | How to use | +|---------|------------| +| **Plan Review** | Automatic — intercepts `ExitPlanMode` via hooks | +| **Code Review** | `/plannotator-review` — Visual diff annotation UI | +| **Markdown Annotation** | `/plannotator-annotate path/to/file.md` — Annotate any markdown file | +| **QA Checklist** | `/plannotator-checklist` or skill: `checklist` — Generate and verify QA checklists interactively | ## Prerequisites @@ -23,7 +30,7 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d --- -[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) +[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) --- @@ -36,7 +43,7 @@ In Claude Code: /plugin install plannotator@plannotator ``` -**Important:** Restart Claude Code after installing the plugin for the hooks to take effect. +**Important:** Restart Claude Code after installing the plugin for the hooks to take effect. Skills are included with the plugin install. ## Manual Installation (Hooks) @@ -61,14 +68,6 @@ If you prefer not to use the plugin system, add this to your `~/.claude/settings } ``` -## Skills - -Skills are included with the plugin install. - -| Skill | Description | -|-------|-------------| -| `checklist` | Generate a QA checklist for manual verification of code changes | - ## How It Works When Claude Code calls `ExitPlanMode`, this hook intercepts and: From 69c3dfb815fc4816a14671a5cf6c29c8a6ff1412 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 11 Mar 2026 12:44:08 -0700 Subject: [PATCH 06/15] fix: address checklist code review issues - Prevent auto-approval on incomplete checklists (pending items now counted) - Fix XSS in description rendering by replacing dangerouslySetInnerHTML with React elements - Preserve saved results when reopening checklists via --file flag - Fix Pi extension notes type mismatch (string vs string[]) and add automations support - Add project scoping to OpenCode checklist server - Add remote/SSH share link for checklist sessions - Update CLAUDE.md with checklist server API, project structure, and build commands Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 34 +++++-- apps/hook/server/index.ts | 15 ++- apps/opencode-plugin/index.ts | 3 + apps/pi-extension/server.ts | 91 ++++++++++++++++--- packages/checklist-editor/App.tsx | 25 ++++- .../components/ChecklistItem.tsx | 45 +++++++-- packages/server/checklist.ts | 16 +++- 7 files changed, 196 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa9bc2f8..4f642711 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,8 @@ plannotator/ │ │ ├── draft.ts # Annotation draft persistence (~/.plannotator/drafts/) │ │ ├── integrations.ts # Obsidian, Bear integrations │ │ ├── ide.ts # VS Code diff integration (openEditorDiff) +│ │ ├── checklist.ts # startChecklistServer(), formatChecklistFeedback() +│ │ ├── serve.ts # Shared Bun server startup (startServer) │ │ ├── editor-annotations.ts # VS Code editor annotation endpoints │ │ └── project.ts # Project name detection for tags │ ├── ui/ # Shared React components @@ -53,13 +55,19 @@ plannotator/ │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts │ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts │ │ └── types.ts -│ ├── shared/ # Cross-package types (EditorAnnotation) +│ ├── shared/ # Cross-package types (EditorAnnotation, checklist-types) │ ├── editor/ # Plan review App.tsx -│ └── review-editor/ # Code review UI -│ ├── App.tsx # Main review app -│ ├── components/ # DiffViewer, FileTree, ReviewPanel -│ ├── demoData.ts # Demo diff for standalone mode -│ └── index.css # Review-specific styles +│ ├── review-editor/ # Code review UI +│ │ ├── App.tsx # Main review app +│ │ ├── components/ # DiffViewer, FileTree, ReviewPanel +│ │ ├── demoData.ts # Demo diff for standalone mode +│ │ └── index.css # Review-specific styles +│ └── checklist-editor/ # QA checklist UI +│ ├── App.tsx # Main checklist app +│ ├── components/ # ChecklistItem, ChecklistGroup, ChecklistHeader, etc. +│ ├── hooks/ # useChecklistState, useChecklistProgress, useChecklistDraft +│ └── index.css # Checklist-specific styles +├── .agents/skills/checklist/ # QA checklist skill (SKILL.md) ├── .claude-plugin/marketplace.json # For marketplace install └── legacy/ # Old pre-monorepo code (reference only) ``` @@ -195,6 +203,16 @@ Send Annotations → feedback sent to agent session | `/api/upload` | POST | Upload image, returns `{ path, originalName }` | | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | +### Checklist Server (`packages/server/checklist.ts`) + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/api/checklist` | GET | Returns `{ checklist, origin, mode, initialResults?, initialGlobalNotes? }` | +| `/api/feedback` | POST | Submit results (body: results, globalNotes, automations, agentSwitch) | +| `/api/image` | GET | Serve image by path query param | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | +| `/api/draft` | GET/POST/DELETE | Auto-save checklist drafts to survive server crashes | + All servers use random ports locally or fixed port (`19432`) in remote mode. ### Paste Service (`apps/paste-service/`) @@ -358,6 +376,7 @@ bun install # Run any app bun run dev:hook # Hook server (plan review) bun run dev:review # Review editor (code review) +bun run dev:checklist # Checklist editor (QA checklist) bun run dev:portal # Portal editor bun run dev:marketing # Marketing site bun run dev:vscode # VS Code extension (watch mode) @@ -368,7 +387,8 @@ bun run dev:vscode # VS Code extension (watch mode) ```bash bun run build:hook # Single-file HTML for hook server bun run build:review # Code review editor -bun run build:opencode # OpenCode plugin (copies HTML from hook + review) +bun run build:checklist # QA checklist editor +bun run build:opencode # OpenCode plugin (copies HTML from hook + review + checklist) bun run build:portal # Static build for share.plannotator.ai bun run build:marketing # Static build for plannotator.ai bun run build:vscode # VS Code extension bundle diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index e42ae1b4..584e5566 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -319,8 +319,13 @@ if (args[0] === "sessions") { } // Unwrap saved checklist files (which have { checklist, results, ... }) + let initialResults: import("@plannotator/shared/checklist-types").ChecklistItemResult[] | undefined; + let initialGlobalNotes: string[] | undefined; if (checklistData && typeof checklistData === "object" && "checklist" in checklistData) { - checklistData = (checklistData as Record).checklist; + const saved = checklistData as Record; + initialResults = saved.results as typeof initialResults; + initialGlobalNotes = saved.globalNotes as typeof initialGlobalNotes; + checklistData = saved.checklist; } const errors = validateChecklist(checklistData); @@ -341,8 +346,14 @@ if (args[0] === "sessions") { origin: "claude-code", project: checklistProject, htmlContent: checklistHtmlContent, - onReady: (url, isRemote, port) => { + initialResults, + initialGlobalNotes, + onReady: async (url, isRemote, port) => { handleChecklistServerReady(url, isRemote, port); + + if (isRemote && sharingEnabled) { + await writeRemoteShareLink(JSON.stringify(checklist), shareBaseUrl, "review the checklist", "checklist only").catch(() => {}); + } }, }); diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 573075cf..575918f8 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -35,6 +35,7 @@ import { import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; +import { detectProjectName } from "@plannotator/server/project"; // @ts-ignore - Bun import attribute for text import indexHtml from "./plannotator.html" with { type: "text" }; @@ -380,9 +381,11 @@ Do NOT proceed with implementation until your plan is approved. message: "Opening checklist UI...", }); + const checklistProject = (await detectProjectName()) ?? "_unknown"; const server = await startChecklistServer({ checklist: checklistData as import("@plannotator/shared/checklist-types").Checklist, origin: "opencode", + project: checklistProject, htmlContent: checklistHtmlContent, onReady: handleChecklistServerReady, }); diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index 7e7ccb2b..5e24c141 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -594,31 +594,35 @@ interface ChecklistType { title: string; summary: string; items: ChecklistItemType[]; + pr?: { number: number; url: string; provider: string; title?: string; branch?: string }; } interface ChecklistItemResultType { id: string; status: "passed" | "failed" | "skipped" | "pending"; - notes?: string; + notes?: string[] | string; images?: { path: string; name: string }[]; } function formatChecklistFeedback( checklist: ChecklistType, results: ChecklistItemResultType[], - globalNotes?: string, + globalNotes?: string[] | string, + automations?: { postToPR?: boolean; approveIfAllPass?: boolean }, ): string { const resultMap = new Map(results.map((r) => [r.id, r])); let passed = 0; let failed = 0; let skipped = 0; + let pending = 0; for (const item of checklist.items) { const result = resultMap.get(item.id); if (result?.status === "passed") passed++; else if (result?.status === "failed") failed++; else if (result?.status === "skipped") skipped++; + else pending++; } const lines: string[] = []; @@ -628,7 +632,7 @@ function formatChecklistFeedback( lines.push("## Summary"); lines.push(`- **Title**: ${checklist.title}`); lines.push(`- **Total**: ${checklist.items.length} items`); - lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}`); + lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}${pending > 0 ? ` | **Pending**: ${pending}` : ""}`); lines.push(""); const failedItems = checklist.items.filter( @@ -644,7 +648,10 @@ function formatChecklistFeedback( lines.push(`**Category**: ${item.category}`); if (item.critical) lines.push(`**Critical**: yes`); if (item.files?.length) lines.push(`**Files**: ${item.files.join(", ")}`); - if (result.notes) lines.push(`**Developer notes**: ${result.notes}`); + const itemNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of itemNotes) { + lines.push(`**Developer notes**: ${note}`); + } if (result.images?.length) { for (const img of result.images) { lines.push(`[${img.name}] ${img.path}`); @@ -664,7 +671,10 @@ function formatChecklistFeedback( const result = resultMap.get(item.id)!; lines.push(`### ${item.id}: ${item.check}`); lines.push(`**Status**: SKIPPED`); - if (result.notes) lines.push(`**Reason**: ${result.notes}`); + const skipNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of skipNotes) { + lines.push(`**Reason**: ${note}`); + } lines.push(""); } } @@ -677,17 +687,74 @@ function formatChecklistFeedback( lines.push(""); for (const item of passedItems) { const result = resultMap.get(item.id); - const notes = result?.notes ? ` — ${result.notes}` : ""; - lines.push(`- [PASS] ${item.id}: ${item.check}${notes}`); + const passNotes = result ? (Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []) : []; + const notesSuffix = passNotes.length > 0 ? ` — ${passNotes.join("; ")}` : ""; + lines.push(`- [PASS] ${item.id}: ${item.check}${notesSuffix}`); } lines.push(""); } - if (globalNotes?.trim()) { + const notes = Array.isArray(globalNotes) ? globalNotes : globalNotes ? [globalNotes] : []; + if (notes.length > 0) { lines.push("## Developer Comments"); lines.push(""); - lines.push(`> ${globalNotes.trim().replace(/\n/g, "\n> ")}`); - lines.push(""); + for (const note of notes) { + lines.push(`> ${note.trim().replace(/\n/g, "\n> ")}`); + lines.push(""); + } + } + + // Automations (PR integration) + if (automations && checklist.pr) { + const pr = checklist.pr; + + if (automations.postToPR) { + lines.push("## Post Results to PR"); + lines.push(""); + if (pr.provider === "github") { + lines.push(`Post a summary comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr comment ${pr.number} --body 'QA Checklist: ${passed} passed, ${failed} failed, ${skipped} skipped out of ${checklist.items.length} items'`); + lines.push("```"); + } else if (pr.provider === "gitlab") { + lines.push(`Post a summary note to MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr note ${pr.number} --message 'QA Checklist: ${passed} passed, ${failed} failed, ${skipped} skipped out of ${checklist.items.length} items'`); + lines.push("```"); + } else if (pr.provider === "azure-devops") { + lines.push(`Post a summary comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr update --id ${pr.number} --description 'QA Checklist: ${passed} passed, ${failed} failed, ${skipped} skipped out of ${checklist.items.length} items'`); + lines.push("```"); + } + lines.push(""); + } + + if (automations.approveIfAllPass && failed === 0 && skipped === 0 && pending === 0) { + if (pr.provider === "github") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr review ${pr.number} --approve --body 'QA checklist passed (${passed}/${passed} items)'`); + lines.push("```"); + } else if (pr.provider === "gitlab") { + lines.push("**Approve MR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`glab\` CLI to approve MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr approve ${pr.number}`); + lines.push("```"); + } else if (pr.provider === "azure-devops") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`az\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr set-vote --id ${pr.number} --vote approve`); + lines.push("```"); + } + lines.push(""); + } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0 || pending > 0)) { + lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); + lines.push(""); + } } return lines.join("\n"); @@ -765,7 +832,8 @@ export function startChecklistServer(options: { } else if (url.pathname === "/api/feedback" && req.method === "POST") { const body = await parseBody(req) as { results?: ChecklistItemResultType[]; - globalNotes?: string; + globalNotes?: string[] | string; + automations?: { postToPR?: boolean; approveIfAllPass?: boolean }; agentSwitch?: string; }; @@ -788,6 +856,7 @@ export function startChecklistServer(options: { options.checklist, results, body.globalNotes, + body.automations, ); resolveDecision({ diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx index 88d21d2a..969a0ff9 100644 --- a/packages/checklist-editor/App.tsx +++ b/packages/checklist-editor/App.tsx @@ -13,7 +13,7 @@ import { useChecklistState } from './hooks/useChecklistState'; import { useChecklistProgress } from './hooks/useChecklistProgress'; import { useChecklistDraft } from './hooks/useChecklistDraft'; import { exportChecklistResults } from './utils/exportChecklist'; -import type { Checklist, ChecklistItem, ChecklistItemStatus } from './hooks/useChecklistState'; +import type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from './hooks/useChecklistState'; import type { ChecklistPR } from '@plannotator/shared/checklist-types'; import type { ChecklistAutomations } from './components/ChecklistAnnotationPanel'; import type { ImageAttachment } from '@plannotator/ui/types'; @@ -153,6 +153,8 @@ const DEMO_CHECKLIST: Checklist = { const ChecklistApp: React.FC = () => { const [checklist, setChecklist] = useState(null); const [origin, setOrigin] = useState(null); + const [initialResults, setInitialResults] = useState(); + const [initialGlobalNotes, setInitialGlobalNotes] = useState(); const [isLoading, setIsLoading] = useState(true); // Fetch checklist data @@ -162,9 +164,11 @@ const ChecklistApp: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { checklist: Checklist; origin?: string }) => { + .then((data: { checklist: Checklist; origin?: string; initialResults?: ChecklistItemResult[]; initialGlobalNotes?: string[] }) => { setChecklist(data.checklist); if (data.origin) setOrigin(data.origin); + if (data.initialResults) setInitialResults(data.initialResults); + if (data.initialGlobalNotes) setInitialGlobalNotes(data.initialGlobalNotes); }) .catch(() => { // Demo mode @@ -185,7 +189,7 @@ const ChecklistApp: React.FC = () => { return ( - + ); }; @@ -197,6 +201,8 @@ const ChecklistApp: React.FC = () => { interface ChecklistAppInnerProps { checklist: Checklist; origin: string | null; + initialResults?: ChecklistItemResult[]; + initialGlobalNotes?: string[]; } // Note popover state @@ -205,7 +211,7 @@ interface NotePopoverState { itemId: string | null; // null = global comment } -const ChecklistAppInner: React.FC = ({ checklist, origin }) => { +const ChecklistAppInner: React.FC = ({ checklist, origin, initialResults, initialGlobalNotes }) => { const [isSubmitting, setIsSubmitting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false); const [globalNotes, setGlobalNotes] = useState([]); @@ -243,6 +249,17 @@ const ChecklistAppInner: React.FC = ({ checklist, origin } }, [restoreDraft, state.restoreResults]); + // Restore initial results from saved checklist file (--file flag) + useEffect(() => { + if (initialResults?.length) { + state.restoreResults(initialResults); + } + if (initialGlobalNotes?.length) { + setGlobalNotes(initialGlobalNotes); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // One-time on mount + // Toggle item expansion const handleToggleExpand = useCallback((id: string) => { setExpandedItems(prev => { diff --git a/packages/checklist-editor/components/ChecklistItem.tsx b/packages/checklist-editor/components/ChecklistItem.tsx index 833044aa..eded45e7 100644 --- a/packages/checklist-editor/components/ChecklistItem.tsx +++ b/packages/checklist-editor/components/ChecklistItem.tsx @@ -155,6 +155,44 @@ export const ChecklistItem: React.FC = ({ // Simple markdown renderer for description text // --------------------------------------------------------------------------- +function renderInlineMarkdown(text: string): React.ReactNode { + const parts: React.ReactNode[] = []; + let remaining = text; + let key = 0; + + while (remaining.length > 0) { + // Bold: **text** + let match = remaining.match(/^\*\*(.+?)\*\*/); + if (match) { + parts.push({renderInlineMarkdown(match[1])}); + remaining = remaining.slice(match[0].length); + continue; + } + + // Inline code: `code` + match = remaining.match(/^`([^`]+)`/); + if (match) { + parts.push({match[1]}); + remaining = remaining.slice(match[0].length); + continue; + } + + // Plain text — consume up to next special character + match = remaining.match(/^[^*`]+/); + if (match) { + parts.push({match[0]}); + remaining = remaining.slice(match[0].length); + continue; + } + + // Single * or ` that didn't match a pattern — consume one char + parts.push({remaining[0]}); + remaining = remaining.slice(1); + } + + return <>{parts}; +} + function renderSimpleMarkdown(text: string): React.ReactNode { const lines = text.split('\n'); const elements: React.ReactNode[] = []; @@ -185,13 +223,8 @@ function renderSimpleMarkdown(text: string): React.ReactNode { continue; } - // Inline code - const rendered = line.replace(/`([^`]+)`/g, '$1'); - // Bold - const withBold = rendered.replace(/\*\*([^*]+)\*\*/g, '$1'); - elements.push( -

, +

{renderInlineMarkdown(line)}

, ); } diff --git a/packages/server/checklist.ts b/packages/server/checklist.ts index 603b5e95..66c05eb6 100644 --- a/packages/server/checklist.ts +++ b/packages/server/checklist.ts @@ -39,6 +39,10 @@ export interface ChecklistServerOptions { origin?: "opencode" | "claude-code" | "pi"; /** Project name for storage scoping */ project?: string; + /** Pre-existing results to restore (from saved checklist files) */ + initialResults?: ChecklistItemResult[]; + /** Pre-existing global notes to restore (from saved checklist files) */ + initialGlobalNotes?: string[]; /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; } @@ -167,12 +171,14 @@ export function formatChecklistFeedback( let passed = 0; let failed = 0; let skipped = 0; + let pending = 0; for (const item of checklist.items) { const result = resultMap.get(item.id); if (result?.status === "passed") passed++; else if (result?.status === "failed") failed++; else if (result?.status === "skipped") skipped++; + else pending++; } const lines: string[] = []; @@ -182,7 +188,7 @@ export function formatChecklistFeedback( lines.push("## Summary"); lines.push(`- **Title**: ${checklist.title}`); lines.push(`- **Total**: ${checklist.items.length} items`); - lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}`); + lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}${pending > 0 ? ` | **Pending**: ${pending}` : ""}`); lines.push(""); // Failed items — full detail @@ -293,7 +299,7 @@ export function formatChecklistFeedback( lines.push(""); } - if (automations.approveIfAllPass && failed === 0 && skipped === 0) { + if (automations.approveIfAllPass && failed === 0 && skipped === 0 && pending === 0) { if (pr.provider === "github") { lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); @@ -314,7 +320,7 @@ export function formatChecklistFeedback( lines.push("```"); } lines.push(""); - } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0)) { + } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0 || pending > 0)) { lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); lines.push(""); } @@ -382,6 +388,8 @@ export async function startChecklistServer( htmlContent, origin, project = "_unknown", + initialResults, + initialGlobalNotes, onReady, } = options; @@ -403,6 +411,8 @@ export async function startChecklistServer( checklist, origin, mode: "checklist", + ...(initialResults && { initialResults }), + ...(initialGlobalNotes && { initialGlobalNotes }), }); } From 57587cb09ce2f29717d85e4ff8d3cf3cb1ade8f3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 11 Mar 2026 17:00:43 -0700 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20Pi=20extension=20parity=20?= =?UTF-8?q?=E2=80=94=20add=20image/upload/draft=20routes=20and=20align=20c?= =?UTF-8?q?hecklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add image validation, serving, upload, and draft persistence helpers (Node-compatible duplicates of packages/server/image.ts and draft.ts) - Add /api/image, /api/upload, /api/draft routes to all four Pi servers (plan, review, annotate, checklist) — previously silently failed - Fix 6 checklist divergences from canonical: - Add PR field validation to validateChecklist - Align formatChecklistFeedback automation output with canonical - Fix saveChecklistResults globalNotes type (string → string[] | string) - Add initialResults/initialGlobalNotes support to startChecklistServer - Add onReady callback to startChecklistServer - Add draft cleanup on checklist submission - Remove unused formatChecklistFeedback import from OpenCode plugin Co-Authored-By: Claude Opus 4.6 --- apps/opencode-plugin/index.ts | 1 - apps/pi-extension/server.ts | 328 +++++++++++++++++++++++++++++----- 2 files changed, 282 insertions(+), 47 deletions(-) diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 575918f8..8cd59f26 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -30,7 +30,6 @@ import { startChecklistServer, handleChecklistServerReady, validateChecklist, - formatChecklistFeedback, } from "@plannotator/server/checklist"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index 5e24c141..a3db6249 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -9,8 +9,9 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import { execSync } from "node:child_process"; import os from "node:os"; -import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { join, basename } from "node:path"; +import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs"; +import { join, basename, resolve, extname } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; // ── Helpers ────────────────────────────────────────────────────────────── @@ -44,6 +45,171 @@ function listenOnRandomPort(server: Server): number { return addr.port; } +// ── Image Validation (duplicated from packages/server/image.ts) ───────── + +const ALLOWED_IMAGE_EXTENSIONS = new Set([ + "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "tiff", "tif", "avif", +]); +const UPLOAD_DIR = "/tmp/plannotator"; + +function getExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf("."); + if (lastDot === -1) return ""; + return filePath.slice(lastDot + 1).toLowerCase(); +} + +function validateImagePath(rawPath: string): { valid: boolean; resolved: string; error?: string } { + const resolved = resolve(rawPath); + if (!ALLOWED_IMAGE_EXTENSIONS.has(getExtension(resolved))) { + return { valid: false, resolved, error: "Path does not point to a supported image file" }; + } + return { valid: true, resolved }; +} + +function validateUploadExtension(fileName: string): { valid: boolean; ext: string; error?: string } { + const ext = getExtension(fileName) || "png"; + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return { valid: false, ext, error: `File extension ".${ext}" is not a supported image type` }; + } + return { valid: true, ext }; +} + +const MIME_MAP: Record = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", + webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", + tiff: "image/tiff", tif: "image/tiff", avif: "image/avif", +}; + +function handlePiImage(req: IncomingMessage, res: import("node:http").ServerResponse): void { + const url = new URL(req.url!, "http://localhost"); + const imagePath = url.searchParams.get("path"); + if (!imagePath) { json(res, { error: "Missing path parameter" }, 400); return; } + const validation = validateImagePath(imagePath); + if (!validation.valid) { json(res, { error: validation.error }, 403); return; } + try { + if (!existsSync(validation.resolved)) { json(res, { error: "File not found" }, 404); return; } + const data = readFileSync(validation.resolved); + const mime = MIME_MAP[getExtension(validation.resolved)] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": mime, "Content-Length": data.length }); + res.end(data); + } catch { + json(res, { error: "Failed to read file" }, 500); + } +} + +// ── Image Upload (Node multipart parser) ──────────────────────────────── + +function parseMultipartFile(req: IncomingMessage): Promise<{ filename: string; data: Buffer } | null> { + return new Promise((resolve) => { + const contentType = req.headers["content-type"] || ""; + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (!boundaryMatch) { resolve(null); return; } + const boundary = boundaryMatch[1]; + + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)); + req.on("end", () => { + try { + const body = Buffer.concat(chunks); + const boundaryBuf = Buffer.from(`--${boundary}`); + // Find the file part + const bodyStr = body.toString("latin1"); + const parts = bodyStr.split(`--${boundary}`); + for (const part of parts) { + if (!part.includes('name="file"')) continue; + const filenameMatch = part.match(/filename="([^"]+)"/); + if (!filenameMatch) continue; + // Find blank line separating headers from content + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd === -1) continue; + // Extract binary data (use Buffer offset, not string) + const partStart = bodyStr.indexOf(part); + const dataStart = partStart + headerEnd + 4; + // Find the closing boundary + let dataEnd = bodyStr.indexOf(`\r\n--${boundary}`, dataStart); + if (dataEnd === -1) dataEnd = body.length; + resolve({ filename: filenameMatch[1], data: body.subarray(dataStart, dataEnd) }); + return; + } + resolve(null); + } catch { + resolve(null); + } + }); + }); +} + +async function handlePiUpload(req: IncomingMessage, res: import("node:http").ServerResponse): Promise { + try { + const file = await parseMultipartFile(req); + if (!file) { json(res, { error: "No file provided" }, 400); return; } + const extResult = validateUploadExtension(file.filename); + if (!extResult.valid) { json(res, { error: extResult.error }, 400); return; } + mkdirSync(UPLOAD_DIR, { recursive: true }); + const tempPath = `${UPLOAD_DIR}/${randomUUID()}.${extResult.ext}`; + writeFileSync(tempPath, file.data); + json(res, { path: tempPath, originalName: file.filename }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + json(res, { error: message }, 500); + } +} + +// ── Draft Persistence (duplicated from packages/server/draft.ts) ──────── + +function getDraftDir(): string { + const dir = join(os.homedir(), ".plannotator", "drafts"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function contentHash(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +function saveDraft(key: string, data: object): void { + writeFileSync(join(getDraftDir(), `${key}.json`), JSON.stringify(data), "utf-8"); +} + +function loadDraft(key: string): object | null { + const filePath = join(getDraftDir(), `${key}.json`); + try { + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, "utf-8")); + } catch { + return null; + } +} + +function deleteDraft(key: string): void { + const filePath = join(getDraftDir(), `${key}.json`); + try { + if (existsSync(filePath)) unlinkSync(filePath); + } catch { + // Ignore delete failures + } +} + +async function handlePiDraft(req: IncomingMessage, res: import("node:http").ServerResponse, draftKey: string): Promise { + if (req.method === "POST") { + try { + const body = await parseBody(req); + saveDraft(draftKey, body); + json(res, { ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save draft"; + json(res, { error: message }, 500); + } + } else if (req.method === "DELETE") { + deleteDraft(draftKey); + json(res, { ok: true }); + } else { + const draft = loadDraft(draftKey); + if (!draft) { json(res, { found: false }, 404); return; } + json(res, draft); + } +} + /** * Open URL in system browser (Node-compatible, no Bun $ dependency). * Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts. @@ -277,6 +443,8 @@ export function startPlanReviewServer(options: { project, }; + const draftKey = contentHash(options.plan); + let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void; const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => { resolveDecision = r; @@ -316,6 +484,12 @@ export function startPlanReviewServer(options: { const body = await parseBody(req); resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -414,6 +588,7 @@ export function startReviewServer(options: { let currentPatch = options.rawPatch; let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; + const draftKey = contentHash(currentPatch || ""); let resolveDecision!: (result: { feedback: string }) => void; const decisionPromise = new Promise<{ feedback: string }>((r) => { @@ -448,6 +623,12 @@ export function startReviewServer(options: { const body = await parseBody(req); resolveDecision({ feedback: (body.feedback as string) || "" }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -478,6 +659,8 @@ export function startAnnotateServer(options: { htmlContent: string; origin?: string; }): AnnotateServerResult { + const draftKey = contentHash(options.markdown); + let resolveDecision!: (result: { feedback: string }) => void; const decisionPromise = new Promise<{ feedback: string }>((r) => { resolveDecision = r; @@ -497,6 +680,12 @@ export function startAnnotateServer(options: { const body = await parseBody(req); resolveDecision({ feedback: (body.feedback as string) || "" }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -536,6 +725,25 @@ export function validateChecklist(data: unknown): string[] { errors.push('Missing or empty "summary" (string).'); } + // Validate optional PR field + if (obj.pr !== undefined) { + if (typeof obj.pr !== "object" || obj.pr === null) { + errors.push('"pr" must be an object if provided.'); + } else { + const pr = obj.pr as Record; + if (typeof pr.number !== "number") { + errors.push('pr.number must be a number.'); + } + if (typeof pr.url !== "string" || !pr.url) { + errors.push('pr.url must be a non-empty string.'); + } + const validProviders = ["github", "gitlab", "azure-devops"]; + if (!validProviders.includes(pr.provider as string)) { + errors.push(`pr.provider must be one of: ${validProviders.join(", ")}.`); + } + } + } + if (!Array.isArray(obj.items)) { errors.push('"items" must be an array.'); return errors; @@ -704,56 +912,66 @@ function formatChecklistFeedback( } } - // Automations (PR integration) + // Automation instructions (matches packages/server/checklist.ts) if (automations && checklist.pr) { const pr = checklist.pr; + const hasAutomations = automations.postToPR || automations.approveIfAllPass; - if (automations.postToPR) { - lines.push("## Post Results to PR"); + if (hasAutomations) { + lines.push("## Automations"); lines.push(""); - if (pr.provider === "github") { - lines.push(`Post a summary comment to PR #${pr.number}:`); - lines.push("```bash"); - lines.push(`gh pr comment ${pr.number} --body 'QA Checklist: ${passed} passed, ${failed} failed, ${skipped} skipped out of ${checklist.items.length} items'`); - lines.push("```"); - } else if (pr.provider === "gitlab") { - lines.push(`Post a summary note to MR !${pr.number}:`); - lines.push("```bash"); - lines.push(`glab mr note ${pr.number} --message 'QA Checklist: ${passed} passed, ${failed} failed, ${skipped} skipped out of ${checklist.items.length} items'`); - lines.push("```"); - } else if (pr.provider === "azure-devops") { - lines.push(`Post a summary comment to PR #${pr.number}:`); - lines.push("```bash"); - lines.push(`az repos pr update --id ${pr.number} --description 'QA Checklist: ${passed} passed, ${failed} failed, ${skipped} skipped out of ${checklist.items.length} items'`); - lines.push("```"); + + if (automations.postToPR) { + if (pr.provider === "github") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`gh\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr comment ${pr.number} --body ''`); + lines.push("```"); + lines.push("If `gh` is not available, inform the developer to install the GitHub CLI (`brew install gh` or https://cli.github.com)."); + } else if (pr.provider === "gitlab") { + lines.push("**Post results to MR**: The developer requested that you post these checklist results as a comment on the merge request."); + lines.push(`Use the \`glab\` CLI to post a note to MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr note ${pr.number} --message ''`); + lines.push("```"); + lines.push("If `glab` is not available, inform the developer to install the GitLab CLI (`brew install glab` or https://gitlab.com/gitlab-org/cli)."); + } else if (pr.provider === "azure-devops") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`az\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr update --id ${pr.number} --description ''`); + lines.push("```"); + lines.push("If `az` is not available, inform the developer to install Azure CLI (`brew install azure-cli` or https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)."); + } + lines.push(""); } - lines.push(""); - } - if (automations.approveIfAllPass && failed === 0 && skipped === 0 && pending === 0) { - if (pr.provider === "github") { - lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); - lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); - lines.push("```bash"); - lines.push(`gh pr review ${pr.number} --approve --body 'QA checklist passed (${passed}/${passed} items)'`); - lines.push("```"); - } else if (pr.provider === "gitlab") { - lines.push("**Approve MR**: All checklist items passed. The developer requested auto-approval."); - lines.push(`Use the \`glab\` CLI to approve MR !${pr.number}:`); - lines.push("```bash"); - lines.push(`glab mr approve ${pr.number}`); - lines.push("```"); - } else if (pr.provider === "azure-devops") { - lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); - lines.push(`Use the \`az\` CLI to approve PR #${pr.number}:`); - lines.push("```bash"); - lines.push(`az repos pr set-vote --id ${pr.number} --vote approve`); - lines.push("```"); + if (automations.approveIfAllPass && failed === 0 && skipped === 0 && pending === 0) { + if (pr.provider === "github") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr review ${pr.number} --approve --body 'QA checklist passed (${passed}/${passed} items)'`); + lines.push("```"); + } else if (pr.provider === "gitlab") { + lines.push("**Approve MR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`glab\` CLI to approve MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr approve ${pr.number}`); + lines.push("```"); + } else if (pr.provider === "azure-devops") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`az\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr set-vote --id ${pr.number} --vote approve`); + lines.push("```"); + } + lines.push(""); + } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0 || pending > 0)) { + lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); + lines.push(""); } - lines.push(""); - } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0 || pending > 0)) { - lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); - lines.push(""); } } @@ -771,7 +989,7 @@ function formatChecklistFeedback( function saveChecklistResults( checklist: ChecklistType, results: ChecklistItemResultType[], - globalNotes: string | undefined, + globalNotes: string[] | string | undefined, project: string, ): string { const dir = join(os.homedir(), ".plannotator", "checklists", project); @@ -812,8 +1030,12 @@ export function startChecklistServer(options: { htmlContent: string; origin?: string; project?: string; + initialResults?: ChecklistItemResultType[]; + initialGlobalNotes?: string[]; + onReady?: (url: string, port: number) => void; }): ChecklistServerResult { const project = options.project || detectProjectName(); + const draftKey = contentHash(JSON.stringify(options.checklist)); let resolveDecision!: (result: { feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }) => void; const decisionPromise = new Promise<{ feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }>((r) => { @@ -828,6 +1050,8 @@ export function startChecklistServer(options: { checklist: options.checklist, origin: options.origin ?? "pi", mode: "checklist", + ...(options.initialResults && { initialResults: options.initialResults }), + ...(options.initialGlobalNotes && { initialGlobalNotes: options.initialGlobalNotes }), }); } else if (url.pathname === "/api/feedback" && req.method === "POST") { const body = await parseBody(req) as { @@ -837,6 +1061,8 @@ export function startChecklistServer(options: { agentSwitch?: string; }; + deleteDraft(draftKey); + const results = body.results || []; // Save to disk @@ -867,6 +1093,12 @@ export function startChecklistServer(options: { }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -874,6 +1106,10 @@ export function startChecklistServer(options: { const port = listenOnRandomPort(server); + if (options.onReady) { + options.onReady(`http://localhost:${port}`, port); + } + return { port, url: `http://localhost:${port}`, From 73ff8fa886d89cb13fdd1187c9b95bbc51fdfeee Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 12 Mar 2026 17:37:33 -0700 Subject: [PATCH 08/15] feat: add QA surface area coverage toggle with diagnostic waffle cells Adds a coverage visualization toggle to the checklist UI. When the agent provides fileDiffs and diffMap data, users can switch between the standard checklist view and a diagnostic coverage map showing a file tree with colored waffle cells (red=failed, yellow=skipped, green=passed, gray=pending). - Extract ToolstripButton from AnnotationToolstrip for reuse - Add fileDiffs/diffMap to checklist data model and validation - Coverage view renders inside document boundary with glassmorphic styling - Stacked/side-by-side layout toggle within coverage view - Compact icon-only status buttons in side-by-side mode - Keyboard shortcut (v) to toggle between views - Update checklist skill with fileDiffs/diffMap guidance Co-Authored-By: Claude Opus 4.6 --- .agents/skills/checklist/SKILL.md | 14 +- apps/pi-extension/server.ts | 26 +++ packages/checklist-editor/App.tsx | 133 +++++++++---- .../components/CoverageFileTree.tsx | 179 ++++++++++++++++++ .../components/CoverageTaskList.tsx | 92 +++++++++ .../components/CoverageView.tsx | 121 ++++++++++++ .../components/ProgressBar.tsx | 5 +- .../components/StatusButton.tsx | 13 +- .../components/ViewModeToggle.tsx | 48 +++++ .../components/WaffleCells.tsx | 82 ++++++++ .../hooks/useChecklistCoverage.ts | 179 ++++++++++++++++++ packages/server/checklist.ts | 26 +++ packages/shared/checklist-types.ts | 9 + .../ui/components/AnnotationToolstrip.tsx | 115 +---------- packages/ui/components/ToolstripButton.tsx | 116 ++++++++++++ 15 files changed, 996 insertions(+), 162 deletions(-) create mode 100644 packages/checklist-editor/components/CoverageFileTree.tsx create mode 100644 packages/checklist-editor/components/CoverageTaskList.tsx create mode 100644 packages/checklist-editor/components/CoverageView.tsx create mode 100644 packages/checklist-editor/components/ViewModeToggle.tsx create mode 100644 packages/checklist-editor/components/WaffleCells.tsx create mode 100644 packages/checklist-editor/hooks/useChecklistCoverage.ts create mode 100644 packages/ui/components/ToolstripButton.tsx diff --git a/.agents/skills/checklist/SKILL.md b/.agents/skills/checklist/SKILL.md index 3457f91c..5fd904cc 100644 --- a/.agents/skills/checklist/SKILL.md +++ b/.agents/skills/checklist/SKILL.md @@ -47,6 +47,8 @@ As you read the diff, build a mental model: - **Which files changed and what do they do?** UI components need visual verification. API routes need functional testing. Database migrations need data integrity checks. Config files need deployment verification. - **Do tests exist for this code?** Look for test files related to the changed code. Tests that meaningfully cover the changed behavior reduce the need for manual verification — but tests that only cover the happy path or assert existence still leave gaps. +As you read the diff, count the number of diff hunks (`@@` markers) per file. You'll use these counts in step 3 to populate `fileDiffs` and `diffMap`. + ### 2. Decide What Needs Manual Verification Think about each change through the lens of what could go wrong that a human needs to catch. Consider categories like: @@ -78,6 +80,11 @@ Produce a JSON object with this structure: "branch": "feat/oauth2", "provider": "github" }, + "fileDiffs": { + "src/middleware/auth.ts": 5, + "src/pages/login.tsx": 3, + "src/lib/api-client.ts": 4 + }, "items": [ { "id": "category-N", @@ -90,7 +97,8 @@ Produce a JSON object with this structure: "Step 3: Confirm this specific expectation" ], "reason": "Why this needs human eyes — what makes it not fully automatable.", - "files": ["path/to/relevant/file.ts"], + "files": ["src/middleware/auth.ts", "src/pages/login.tsx"], + "diffMap": { "src/middleware/auth.ts": 3, "src/pages/login.tsx": 2 }, "critical": false } ] @@ -125,7 +133,9 @@ Produce a JSON object with this structure: - How the developer knows the test passes vs fails - **`steps`**: Required. Ordered instructions for conducting the verification. Be concrete — "Open browser devtools" not "check the network." Each step should be a single clear action. - **`reason`**: One sentence explaining why automation can't fully cover this. "CSS grid rendering varies across browsers" is good. "Because it changed" is not. -- **`files`**: File paths from the diff that this item relates to. Helps the developer trace your reasoning. +- **`files`**: File paths from the diff that this item relates to. Helps the developer trace your reasoning. Optional when `diffMap` is provided (derivable from its keys). +- **`diffMap`**: Object mapping file paths to the number of diff hunks in that file that this check exercises. Paths must be keys in `fileDiffs`. Multiple items can cover the same hunks — that's expected (many-to-many). Example: `{ "src/middleware/auth.ts": 3, "src/pages/login.tsx": 2 }`. +- **`fileDiffs`** (on the top-level checklist, not per-item): Object mapping each changed file's relative path to its total number of diff hunks. Count `@@` markers per file in the `git diff` output. This enables the coverage visualization toggle in the checklist UI. Example: `{ "src/middleware/auth.ts": 5, "src/pages/login.tsx": 3 }`. - **`critical`**: Reserve for items where failure means data loss, security vulnerability, or broken deployment. Typically 0–3 items per checklist. ### 4. Launch the Checklist UI diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index a3db6249..fe0a5e04 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -744,6 +744,19 @@ export function validateChecklist(data: unknown): string[] { } } + // Validate optional fileDiffs field + if (obj.fileDiffs !== undefined) { + if (typeof obj.fileDiffs !== "object" || obj.fileDiffs === null || Array.isArray(obj.fileDiffs)) { + errors.push('"fileDiffs" must be an object mapping file paths to hunk counts.'); + } else { + for (const [key, val] of Object.entries(obj.fileDiffs as Record)) { + if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { + errors.push(`fileDiffs["${key}"] must be a positive integer.`); + } + } + } + } + if (!Array.isArray(obj.items)) { errors.push('"items" must be an array.'); return errors; @@ -780,6 +793,19 @@ export function validateChecklist(data: unknown): string[] { if (typeof item.reason !== "string" || !item.reason.trim()) { errors.push(`${prefix}: missing "reason" (why manual verification is needed).`); } + + // Validate optional diffMap + if (item.diffMap !== undefined) { + if (typeof item.diffMap !== "object" || item.diffMap === null || Array.isArray(item.diffMap)) { + errors.push(`${prefix}: "diffMap" must be an object mapping file paths to hunk counts.`); + } else { + for (const [key, val] of Object.entries(item.diffMap as Record)) { + if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { + errors.push(`${prefix}: diffMap["${key}"] must be a positive integer.`); + } + } + } + } } return errors; diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx index 969a0ff9..30eefcb9 100644 --- a/packages/checklist-editor/App.tsx +++ b/packages/checklist-editor/App.tsx @@ -9,12 +9,15 @@ import { ChecklistHeader } from './components/ChecklistHeader'; import { ChecklistGroup } from './components/ChecklistGroup'; import { ChecklistAnnotationPanel } from './components/ChecklistAnnotationPanel'; import { ProgressBar } from './components/ProgressBar'; +import { CoverageView } from './components/CoverageView'; +import { ViewModeToggle } from './components/ViewModeToggle'; import { useChecklistState } from './hooks/useChecklistState'; import { useChecklistProgress } from './hooks/useChecklistProgress'; import { useChecklistDraft } from './hooks/useChecklistDraft'; +import { useChecklistCoverage } from './hooks/useChecklistCoverage'; import { exportChecklistResults } from './utils/exportChecklist'; import type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from './hooks/useChecklistState'; -import type { ChecklistPR } from '@plannotator/shared/checklist-types'; +import type { ChecklistPR, ChecklistViewMode } from '@plannotator/shared/checklist-types'; import type { ChecklistAutomations } from './components/ChecklistAnnotationPanel'; import type { ImageAttachment } from '@plannotator/ui/types'; @@ -32,6 +35,19 @@ const DEMO_CHECKLIST: Checklist = { branch: 'feat/oauth2-migration', provider: 'github' as const, }, + fileDiffs: { + 'src/middleware/csrf.ts': 3, + 'src/middleware/auth.ts': 5, + 'src/middleware/session-migration.ts': 4, + 'src/middleware/api-key.ts': 2, + 'src/routes/api.ts': 2, + 'src/lib/api-client.ts': 6, + 'src/hooks/useAuth.ts': 4, + 'src/pages/login.tsx': 8, + 'src/auth/providers.ts': 5, + 'src/components/AuthButton.tsx': 3, + 'src/components/ErrorMessage.tsx': 2, + }, items: [ { id: 'auth-1', @@ -46,6 +62,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'CSRF protection is security-critical and automated tests may not catch middleware ordering issues.', files: ['src/middleware/csrf.ts', 'src/routes/api.ts'], + diffMap: { 'src/middleware/csrf.ts': 3, 'src/routes/api.ts': 2 }, critical: true, }, { @@ -61,6 +78,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'Race conditions in token refresh can cause cascading 401s and logout the user.', files: ['src/lib/api-client.ts', 'src/hooks/useAuth.ts'], + diffMap: { 'src/lib/api-client.ts': 4, 'src/hooks/useAuth.ts': 3 }, critical: true, }, { @@ -76,6 +94,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'Core user flow that must work correctly.', files: ['src/pages/login.tsx', 'src/middleware/auth.ts'], + diffMap: { 'src/pages/login.tsx': 5, 'src/middleware/auth.ts': 3 }, }, { id: 'auth-4', @@ -90,6 +109,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'OAuth flows involve third-party redirects that are difficult to test automatically.', files: ['src/auth/providers.ts'], + diffMap: { 'src/auth/providers.ts': 5, 'src/pages/login.tsx': 2 }, }, { id: 'auth-5', @@ -104,6 +124,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'A forced logout would affect all active users and is unacceptable for a production migration.', files: ['src/middleware/session-migration.ts'], + diffMap: { 'src/middleware/session-migration.ts': 4, 'src/middleware/auth.ts': 2 }, critical: true, }, { @@ -118,6 +139,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'Breaking API key auth would disrupt automated integrations.', files: ['src/middleware/api-key.ts'], + diffMap: { 'src/middleware/api-key.ts': 2 }, }, { id: 'auth-7', @@ -130,6 +152,7 @@ const DEMO_CHECKLIST: Checklist = { 'Trigger rate limiting (5+ failed attempts) and verify the message', ], reason: 'Error copy is hard to verify without visual inspection.', + diffMap: { 'src/components/ErrorMessage.tsx': 2, 'src/pages/login.tsx': 1 }, }, { id: 'auth-8', @@ -142,6 +165,7 @@ const DEMO_CHECKLIST: Checklist = { 'Verify there is no layout shift when the spinner appears', ], reason: 'Loading state polish requires visual verification.', + diffMap: { 'src/components/AuthButton.tsx': 3, 'src/lib/api-client.ts': 2, 'src/hooks/useAuth.ts': 1 }, }, ], }; @@ -220,11 +244,14 @@ const ChecklistAppInner: React.FC = ({ checklist, origin const [isPanelOpen, setIsPanelOpen] = useState(true); const [notePopover, setNotePopover] = useState(null); const [automations, setAutomations] = useState({ postToPR: false, approveIfAllPass: false }); + const [viewMode, setViewMode] = useState('checklist'); const documentRef = useRef(null); const globalCommentButtonRef = useRef(null); const state = useChecklistState({ items: checklist.items }); const { counts, categoryProgress, submitState } = useChecklistProgress(checklist.items, state.results); + const coverageData = useChecklistCoverage(checklist.fileDiffs, checklist.items, state.results); + const hasCoverage = coverageData !== null; const panelResize = useResizablePanel({ storageKey: 'plannotator-checklist-panel-width', @@ -383,6 +410,8 @@ const ChecklistAppInner: React.FC = ({ checklist, origin submittedRef.current = submitted; const isSubmittingRef = useRef(isSubmitting); isSubmittingRef.current = isSubmitting; + const hasCoverageRef = useRef(hasCoverage); + hasCoverageRef.current = hasCoverage; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -446,6 +475,12 @@ const ChecklistAppInner: React.FC = ({ checklist, origin } } break; + case 'v': + if (hasCoverageRef.current) { + e.preventDefault(); + setViewMode(prev => prev === 'checklist' ? 'coverage' : 'checklist'); + } + break; case 'Enter': if (s.selectedItemId) { e.preventDefault(); @@ -507,46 +542,62 @@ const ChecklistAppInner: React.FC = ({ checklist, origin

{checklist.summary}

)} - {/* Progress bar */} - - - {/* Checklist items in glassomorphic surface */} -
- {/* Global comment button — inside the surface, right-aligned */} -
- -
- - {state.categories.map(category => { - const items = state.groupedItems.get(category); - const progress = categoryProgress.get(category); - if (!items || !progress) return null; - return ( - - ); - })} + {/* Toggle + Progress bar */} +
+ {hasCoverage && } +
+ + {/* View content — swaps between checklist items and coverage */} + {viewMode === 'coverage' && coverageData ? ( + + ) : ( +
+ {/* Global comment button — inside the surface, right-aligned */} +
+ +
+ + {state.categories.map(category => { + const items = state.groupedItems.get(category); + const progress = categoryProgress.get(category); + if (!items || !progress) return null; + return ( + + ); + })} +
+ )}
@@ -616,7 +667,7 @@ const ChecklistAppInner: React.FC = ({ checklist, origin {/* Demo mode toast */} {!origin && (
- Demo mode — j/k navigate, p/f/s set status, Enter expand + Demo mode — j/k navigate, p/f/s set status, Enter expand{hasCoverage && <>, v coverage}
)}
diff --git a/packages/checklist-editor/components/CoverageFileTree.tsx b/packages/checklist-editor/components/CoverageFileTree.tsx new file mode 100644 index 00000000..b0d9fc2b --- /dev/null +++ b/packages/checklist-editor/components/CoverageFileTree.tsx @@ -0,0 +1,179 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { WaffleCells } from './WaffleCells'; +import type { FileTreeNode } from '../hooks/useChecklistCoverage'; + +interface CoverageFileTreeProps { + tree: FileTreeNode[]; +} + +// Collect all directory paths for initial expanded state +function collectDirPaths(nodes: FileTreeNode[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.type === 'dir') { + paths.push(node.path); + if (node.children) { + paths.push(...collectDirPaths(node.children)); + } + } + } + return paths; +} + +const FolderIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const FileIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const ChevronIcon: React.FC<{ expanded: boolean; className?: string }> = ({ expanded, className }) => ( + + + +); + +const TreeRow: React.FC<{ + node: FileTreeNode; + depth: number; + expandedDirs: Set; + onToggle: (path: string) => void; +}> = ({ node, depth, expandedDirs, onToggle }) => { + const isDir = node.type === 'dir'; + const isExpanded = isDir && expandedDirs.has(node.path); + const coveredDiffs = node.passedDiffs + node.failedDiffs + node.skippedDiffs; + const percent = node.totalDiffs > 0 + ? Math.round((coveredDiffs / node.totalDiffs) * 100) + : 0; + const isFull = percent === 100 && node.totalDiffs > 0; + + return ( + <> +
isDir && onToggle(node.path)} + > + {/* Chevron for directories */} + {isDir ? ( + + ) : ( + + )} + + {/* Icon */} + {isDir ? ( + + ) : ( + + )} + + {/* Name */} + + {node.name}{isDir ? '/' : ''} + + + {/* Spacer */} + + + {/* Waffle cells */} + + + {/* Percentage */} + 0 + ? 'text-muted-foreground/70' + : 'text-muted-foreground/30' + }`}> + {percent}% + +
+ + {/* Children */} + {isDir && isExpanded && node.children?.map(child => ( + + ))} + + ); +}; + +export const CoverageFileTree: React.FC = ({ tree }) => { + const allDirPaths = useMemo(() => new Set(collectDirPaths(tree)), [tree]); + const [expandedDirs, setExpandedDirs] = useState>(allDirPaths); + + // Sync expanded dirs when tree changes (new dirs should start expanded) + useMemo(() => { + const newDirs = collectDirPaths(tree); + setExpandedDirs(prev => { + const next = new Set(prev); + for (const d of newDirs) { + if (!prev.has(d) && !allDirPaths.has(d)) { + // genuinely new directory + } + next.add(d); + } + return next; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tree]); + + const handleToggle = useCallback((path: string) => { + setExpandedDirs(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + return ( +
+ {tree.map(node => ( + + ))} +
+ ); +}; diff --git a/packages/checklist-editor/components/CoverageTaskList.tsx b/packages/checklist-editor/components/CoverageTaskList.tsx new file mode 100644 index 00000000..efa2965d --- /dev/null +++ b/packages/checklist-editor/components/CoverageTaskList.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { StatusIcon, StatusButton } from './StatusButton'; +import type { ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from '@plannotator/shared/checklist-types'; + +interface CoverageTaskListProps { + items: ChecklistItem[]; + categories: string[]; + groupedItems: Map; + selectedItemId: string | null; + getResult: (id: string) => ChecklistItemResult; + onSetStatus: (id: string, status: ChecklistItemStatus) => void; + onSelectItem: (id: string) => void; + compact?: boolean; +} + +export const CoverageTaskList: React.FC = ({ + categories, + groupedItems, + selectedItemId, + getResult, + onSetStatus, + onSelectItem, + compact, +}) => { + return ( +
+ {categories.map(category => { + const items = groupedItems.get(category); + if (!items) return null; + + return ( +
+ {/* Category header */} +
+ + {category} + +
+ + {/* Items */} + {items.map(item => { + const result = getResult(item.id); + const isSelected = selectedItemId === item.id; + const diffCount = item.diffMap + ? Object.values(item.diffMap).reduce((a, b) => a + b, 0) + : 0; + + return ( +
onSelectItem(item.id)} + className={`flex items-center gap-2 px-3 py-1.5 cursor-default transition-colors duration-100 ${ + isSelected ? 'bg-muted/50' : 'hover:bg-muted/20' + } ${item.critical ? 'border-l-2 border-destructive/40' : ''}`} + > + {/* Status dot */} + + + {/* Check text */} + + {item.check} + + + {/* Diff count badge */} + {diffCount > 0 && ( + + {diffCount} + + )} + + {/* Compact P/F/S actions */} +
e.stopPropagation()}> + {(['passed', 'failed', 'skipped'] as const).map(s => ( + onSetStatus(item.id, result.status === s ? 'pending' : s)} + size={compact ? 'xs' : 'sm'} + /> + ))} +
+
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/packages/checklist-editor/components/CoverageView.tsx b/packages/checklist-editor/components/CoverageView.tsx new file mode 100644 index 00000000..18103ac1 --- /dev/null +++ b/packages/checklist-editor/components/CoverageView.tsx @@ -0,0 +1,121 @@ +import React, { useState } from 'react'; +import { CoverageFileTree } from './CoverageFileTree'; +import { CoverageTaskList } from './CoverageTaskList'; +import type { CoverageData } from '../hooks/useChecklistCoverage'; +import type { ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from '@plannotator/shared/checklist-types'; + +type CoverageLayout = 'stacked' | 'side-by-side'; + +interface CoverageViewProps { + coverageData: CoverageData; + items: ChecklistItem[]; + categories: string[]; + groupedItems: Map; + selectedItemId: string | null; + getResult: (id: string) => ChecklistItemResult; + onSetStatus: (id: string, status: ChecklistItemStatus) => void; + onSelectItem: (id: string) => void; +} + +const StackedIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + +); + +const SideBySideIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + +); + +export const CoverageView: React.FC = ({ + coverageData, + items, + categories, + groupedItems, + selectedItemId, + getResult, + onSetStatus, + onSelectItem, +}) => { + const [layout, setLayout] = useState('stacked'); + const isSideBySide = layout === 'side-by-side'; + + return ( +
+ {/* Header: global progress + layout toggle */} +
+ + {coverageData.globalCovered} / {coverageData.globalTotal} diffs covered + + 0 + ? 'text-foreground/70' + : 'text-muted-foreground/40' + }`}> + ({coverageData.globalPercent}%) + +
+
+
+ + {/* Layout toggle */} +
+ + +
+
+ + {/* Content: stacked or side-by-side */} +
+ {/* File tree */} +
+ +
+ + {/* Task list */} +
+
+ + Tasks + +
+ +
+
+
+ ); +}; diff --git a/packages/checklist-editor/components/ProgressBar.tsx b/packages/checklist-editor/components/ProgressBar.tsx index f889f663..32379c2d 100644 --- a/packages/checklist-editor/components/ProgressBar.tsx +++ b/packages/checklist-editor/components/ProgressBar.tsx @@ -4,6 +4,7 @@ import type { StatusCounts } from '../hooks/useChecklistProgress'; interface ProgressBarProps { counts: StatusCounts; stopped?: boolean; + className?: string; } function formatElapsed(ms: number): string { @@ -22,7 +23,7 @@ function formatElapsed(ms: number): string { return `${mm}:${ss}`; } -export const ProgressBar: React.FC = ({ counts, stopped }) => { +export const ProgressBar: React.FC = ({ counts, stopped, className }) => { const { passed, failed, skipped, pending, total } = counts; const [elapsed, setElapsed] = useState(0); const [startTime] = useState(() => Date.now()); @@ -42,7 +43,7 @@ export const ProgressBar: React.FC = ({ counts, stopped }) => const pct = (n: number) => `${(n / total) * 100}%`; return ( -
+
{formatElapsed(elapsed)} diff --git a/packages/checklist-editor/components/StatusButton.tsx b/packages/checklist-editor/components/StatusButton.tsx index 0084f2c2..72e77b7b 100644 --- a/packages/checklist-editor/components/StatusButton.tsx +++ b/packages/checklist-editor/components/StatusButton.tsx @@ -67,7 +67,7 @@ interface StatusButtonProps { status: ChecklistItemStatus; currentStatus: ChecklistItemStatus; onClick: () => void; - size?: 'sm' | 'md'; + size?: 'xs' | 'sm' | 'md'; } const CONFIG: Record< @@ -106,7 +106,12 @@ export const StatusButton: React.FC = ({ if (status === 'pending') return null; const cfg = CONFIG[status]; const isActive = currentStatus === status; - const sizeClass = size === 'sm' ? 'px-1.5 py-0.5 text-[10px] gap-0.5' : 'px-2.5 py-1.5 text-xs gap-1.5'; + const sizeClass = size === 'xs' + ? 'p-1' + : size === 'sm' + ? 'px-1.5 py-0.5 text-[10px] gap-0.5' + : 'px-2.5 py-1.5 text-xs gap-1.5'; + const iconClass = size === 'xs' ? 'w-2.5 h-2.5' : size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'; return ( ); }; diff --git a/packages/checklist-editor/components/ViewModeToggle.tsx b/packages/checklist-editor/components/ViewModeToggle.tsx new file mode 100644 index 00000000..6e14b3b5 --- /dev/null +++ b/packages/checklist-editor/components/ViewModeToggle.tsx @@ -0,0 +1,48 @@ +import React, { useState, useEffect } from 'react'; +import { ToolstripButton } from '@plannotator/ui/components/ToolstripButton'; +import type { ChecklistViewMode } from '@plannotator/shared/checklist-types'; + +interface ViewModeToggleProps { + mode: ChecklistViewMode; + onModeChange: (mode: ChecklistViewMode) => void; +} + +export const ViewModeToggle: React.FC = ({ mode, onModeChange }) => { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + requestAnimationFrame(() => setMounted(true)); + }, []); + + return ( +
+ onModeChange('checklist')} + label="Checklist" + color="primary" + mounted={mounted} + icon={ + + + + } + /> + onModeChange('coverage')} + label="Coverage" + color="secondary" + mounted={mounted} + icon={ + + + + + + + } + /> +
+ ); +}; diff --git a/packages/checklist-editor/components/WaffleCells.tsx b/packages/checklist-editor/components/WaffleCells.tsx new file mode 100644 index 00000000..6aac8ce4 --- /dev/null +++ b/packages/checklist-editor/components/WaffleCells.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +// Cells are allocated in severity order: red (failed) first, then yellow +// (skipped), then green (passed), then gray (pending). When items overlap +// on the same file, the worst outcome takes visual priority — a passing +// check never hides a failure. + +interface WaffleCellsProps { + total: number; + passed: number; + failed: number; + skipped: number; + maxCells?: number; + cellSize?: number; +} + +const CELL_CLASSES = { + failed: 'bg-destructive rounded-[1px] transition-colors duration-200', + skipped: 'bg-warning rounded-[1px] transition-colors duration-200', + passed: 'bg-success rounded-[1px] transition-colors duration-200', + pending: 'bg-muted-foreground/15 rounded-[1px] transition-colors duration-200', +} as const; + +export const WaffleCells: React.FC = ({ + total, + passed, + failed, + skipped, + maxCells = 20, + cellSize = 7, +}) => { + if (total === 0) return null; + + const covered = passed + failed + skipped; + + // When total exceeds maxCells, compress proportionally + let cellCount: number; + let redCount: number; + let yellowCount: number; + let greenCount: number; + + if (total <= maxCells) { + cellCount = total; + redCount = failed; + yellowCount = skipped; + greenCount = passed; + } else { + cellCount = maxCells; + redCount = Math.round((failed / total) * maxCells); + yellowCount = Math.round((skipped / total) * maxCells); + greenCount = Math.round((passed / total) * maxCells); + // Clamp so we don't exceed cellCount due to rounding + const colorTotal = redCount + yellowCount + greenCount; + if (colorTotal > cellCount) { + greenCount = Math.max(0, cellCount - redCount - yellowCount); + } + } + + return ( +
+ {Array.from({ length: cellCount }, (_, i) => { + let cls: string; + if (i < redCount) cls = CELL_CLASSES.failed; + else if (i < redCount + yellowCount) cls = CELL_CLASSES.skipped; + else if (i < redCount + yellowCount + greenCount) cls = CELL_CLASSES.passed; + else cls = CELL_CLASSES.pending; + + return ( +
+ ); + })} +
+ ); +}; diff --git a/packages/checklist-editor/hooks/useChecklistCoverage.ts b/packages/checklist-editor/hooks/useChecklistCoverage.ts new file mode 100644 index 00000000..eef90c2c --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistCoverage.ts @@ -0,0 +1,179 @@ +import { useMemo } from 'react'; +import type { ChecklistItem, ChecklistItemResult } from '@plannotator/shared/checklist-types'; + +export interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'dir'; + totalDiffs: number; + passedDiffs: number; + failedDiffs: number; + skippedDiffs: number; + children?: FileTreeNode[]; +} + +export interface CoverageData { + tree: FileTreeNode[]; + globalCovered: number; + globalTotal: number; + globalPercent: number; +} + +interface FileCoverageEntry { + total: number; + passed: number; + failed: number; + skipped: number; +} + +// Waffle cells are a diagnostic map, not a progress bar. +// +// Each cell represents a diff hunk. Color shows the verification outcome: +// red = failed (problem found) +// yellow = skipped (acknowledged but unverified) +// green = passed (verified and cleared) +// gray = pending (not yet examined) +// +// When multiple items cover the same file, cells are allocated in severity +// order: red first, then yellow, then green. Each is clamped so the sum +// never exceeds the file's total hunks. This means failed items always +// dominate overlapping coverage — a passing check doesn't hide a failure. + +function computeFileCoverage( + fileDiffs: Record, + items: ChecklistItem[], + results: Map, +): Map { + const coverage = new Map(); + + for (const [file, total] of Object.entries(fileDiffs)) { + coverage.set(file, { total, passed: 0, failed: 0, skipped: 0 }); + } + + for (const item of items) { + if (!item.diffMap) continue; + const result = results.get(item.id); + const status = result?.status ?? 'pending'; + if (status === 'pending') continue; + + for (const [file, hunks] of Object.entries(item.diffMap)) { + const entry = coverage.get(file); + if (!entry) continue; + + if (status === 'passed') entry.passed += hunks; + else if (status === 'failed') entry.failed += hunks; + else if (status === 'skipped') entry.skipped += hunks; + } + } + + // Clamp each bucket so the sum doesn't exceed total. + // Severity order: failed eats into capacity first, then skipped, then passed. + for (const entry of coverage.values()) { + entry.failed = Math.min(entry.failed, entry.total); + entry.skipped = Math.min(entry.skipped, entry.total - entry.failed); + entry.passed = Math.min(entry.passed, entry.total - entry.failed - entry.skipped); + } + + return coverage; +} + +function buildFileTree( + fileCoverage: Map, +): FileTreeNode[] { + const root: FileTreeNode[] = []; + + for (const [filePath, entry] of fileCoverage) { + const segments = filePath.split('/'); + let current = root; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isFile = i === segments.length - 1; + const fullPath = segments.slice(0, i + 1).join('/'); + + let node = current.find(n => n.name === segment); + if (!node) { + node = { + name: segment, + path: fullPath, + type: isFile ? 'file' : 'dir', + totalDiffs: isFile ? entry.total : 0, + passedDiffs: isFile ? entry.passed : 0, + failedDiffs: isFile ? entry.failed : 0, + skippedDiffs: isFile ? entry.skipped : 0, + ...(isFile ? {} : { children: [] }), + }; + current.push(node); + } + + if (isFile) { + node.totalDiffs = entry.total; + node.passedDiffs = entry.passed; + node.failedDiffs = entry.failed; + node.skippedDiffs = entry.skipped; + } else { + current = node.children!; + } + } + } + + function aggregate(nodes: FileTreeNode[]): { total: number; passed: number; failed: number; skipped: number } { + let total = 0, passed = 0, failed = 0, skipped = 0; + for (const node of nodes) { + if (node.type === 'dir' && node.children) { + const child = aggregate(node.children); + node.totalDiffs = child.total; + node.passedDiffs = child.passed; + node.failedDiffs = child.failed; + node.skippedDiffs = child.skipped; + } + total += node.totalDiffs; + passed += node.passedDiffs; + failed += node.failedDiffs; + skipped += node.skippedDiffs; + } + return { total, passed, failed, skipped }; + } + + aggregate(root); + + function sortTree(nodes: FileTreeNode[]) { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.children) sortTree(node.children); + } + } + sortTree(root); + + return root; +} + +export function useChecklistCoverage( + fileDiffs: Record | undefined, + items: ChecklistItem[], + results: Map, +): CoverageData | null { + return useMemo(() => { + if (!fileDiffs || Object.keys(fileDiffs).length === 0) return null; + + const fileCoverage = computeFileCoverage(fileDiffs, items, results); + const tree = buildFileTree(fileCoverage); + + let globalCovered = 0; + let globalTotal = 0; + for (const entry of fileCoverage.values()) { + globalCovered += entry.passed + entry.failed + entry.skipped; + globalTotal += entry.total; + } + + return { + tree, + globalCovered, + globalTotal, + globalPercent: globalTotal > 0 ? Math.round((globalCovered / globalTotal) * 100) : 0, + }; + }, [fileDiffs, items, results]); +} diff --git a/packages/server/checklist.ts b/packages/server/checklist.ts index 66c05eb6..61697910 100644 --- a/packages/server/checklist.ts +++ b/packages/server/checklist.ts @@ -114,6 +114,19 @@ export function validateChecklist(data: unknown): string[] { } } + // Validate optional fileDiffs field + if (obj.fileDiffs !== undefined) { + if (typeof obj.fileDiffs !== "object" || obj.fileDiffs === null || Array.isArray(obj.fileDiffs)) { + errors.push('"fileDiffs" must be an object mapping file paths to hunk counts.'); + } else { + for (const [key, val] of Object.entries(obj.fileDiffs as Record)) { + if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { + errors.push(`fileDiffs["${key}"] must be a positive integer.`); + } + } + } + } + if (!Array.isArray(obj.items)) { errors.push('"items" must be an array.'); return errors; @@ -150,6 +163,19 @@ export function validateChecklist(data: unknown): string[] { if (typeof item.reason !== "string" || !item.reason.trim()) { errors.push(`${prefix}: missing "reason" (why manual verification is needed).`); } + + // Validate optional diffMap + if (item.diffMap !== undefined) { + if (typeof item.diffMap !== "object" || item.diffMap === null || Array.isArray(item.diffMap)) { + errors.push(`${prefix}: "diffMap" must be an object mapping file paths to hunk counts.`); + } else { + for (const [key, val] of Object.entries(item.diffMap as Record)) { + if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { + errors.push(`${prefix}: diffMap["${key}"] must be a positive integer.`); + } + } + } + } } return errors; diff --git a/packages/shared/checklist-types.ts b/packages/shared/checklist-types.ts index 45fe4a75..52eccf12 100644 --- a/packages/shared/checklist-types.ts +++ b/packages/shared/checklist-types.ts @@ -15,6 +15,10 @@ export interface ChecklistItem { reason: string; /** Related file paths from the diff */ files?: string[]; + /** Maps file path → number of diff hunks this item covers. + * Paths must be keys in Checklist.fileDiffs. + * Multiple items can cover the same hunks (many-to-many). */ + diffMap?: Record; /** True if failure means data loss, security breach, or broken deploy */ critical?: boolean; } @@ -42,6 +46,9 @@ export interface Checklist { items: ChecklistItem[]; /** Optional associated pull/merge request */ pr?: ChecklistPR; + /** Total diff hunks per file path (relative to repo root). + * Presence of this field enables the coverage toggle view. */ + fileDiffs?: Record; } // --- Developer Response --- @@ -58,6 +65,8 @@ export interface ChecklistItemResult { images?: { path: string; name: string }[]; } +export type ChecklistViewMode = 'checklist' | 'coverage'; + export interface ChecklistSubmission { results: ChecklistItemResult[]; /** Overall notes from the developer */ diff --git a/packages/ui/components/AnnotationToolstrip.tsx b/packages/ui/components/AnnotationToolstrip.tsx index 78490fcb..2ca5eec8 100644 --- a/packages/ui/components/AnnotationToolstrip.tsx +++ b/packages/ui/components/AnnotationToolstrip.tsx @@ -1,6 +1,7 @@ -import React, { useState, useRef, useLayoutEffect, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import type { EditorMode, InputMethod } from '../types'; import { TaterSpritePullup } from './TaterSpritePullup'; +import { ToolstripButton } from './ToolstripButton'; interface AnnotationToolstripProps { inputMethod: InputMethod; @@ -180,115 +181,3 @@ export const AnnotationToolstrip: React.FC = ({ ); }; -/* ─── Color system ─── */ - -const colorStyles = { - primary: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-primary/80 bg-primary/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - secondary: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-secondary/80 bg-secondary/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - accent: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-accent/80 bg-accent/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - destructive: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-destructive/80 bg-destructive/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, -} as const; - -type ButtonColor = keyof typeof colorStyles; - -/* ─── Constants ─── */ - -const ICON_SIZE = 28; // collapsed button width (px) -const H_PAD = 10; // horizontal padding when expanded (px) — matches px-2.5 -const GAP = 6; // gap between icon and label (px) — matches gap-1.5 -const ICON_INNER = 14; // icon element width (px) -const DURATION = 180; // transition ms - -/* ─── Button ─── */ - -const ToolstripButton: React.FC<{ - active: boolean; - onClick: () => void; - icon: React.ReactNode; - label: string; - color: ButtonColor; - mounted: boolean; -}> = ({ active, onClick, icon, label, color, mounted }) => { - const [hovered, setHovered] = useState(false); - const [labelWidth, setLabelWidth] = useState(0); - const measureRef = useRef(null); - const styles = colorStyles[color]; - - // Measure label text width synchronously before first paint - useLayoutEffect(() => { - if (measureRef.current) { - setLabelWidth(measureRef.current.offsetWidth); - } - }, [label]); - - const expanded = active || hovered; - const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD; - const currentWidth = expanded ? expandedWidth : ICON_SIZE; - - const colorClass = active - ? styles.active - : hovered - ? styles.hover - : styles.inactive; - - const transition = mounted - ? `width ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease` - : 'none'; - - const innerTransition = mounted - ? `padding-left ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` - : 'none'; - - return ( - - ); -}; diff --git a/packages/ui/components/ToolstripButton.tsx b/packages/ui/components/ToolstripButton.tsx new file mode 100644 index 00000000..0db48ba6 --- /dev/null +++ b/packages/ui/components/ToolstripButton.tsx @@ -0,0 +1,116 @@ +import React, { useState, useRef, useLayoutEffect } from 'react'; + +/* ─── Color system ─── */ + +export const toolstripColorStyles = { + primary: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-primary/80 bg-primary/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, + secondary: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-secondary/80 bg-secondary/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, + accent: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-accent/80 bg-accent/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, + destructive: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-destructive/80 bg-destructive/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, +} as const; + +export type ToolstripButtonColor = keyof typeof toolstripColorStyles; + +/* ─── Constants ─── */ + +export const ICON_SIZE = 28; // collapsed button width (px) +const H_PAD = 10; // horizontal padding when expanded (px) — matches px-2.5 +const GAP = 6; // gap between icon and label (px) — matches gap-1.5 +const ICON_INNER = 14; // icon element width (px) +export const DURATION = 180; // transition ms + +/* ─── Button ─── */ + +export interface ToolstripButtonProps { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; + color: ToolstripButtonColor; + mounted: boolean; +} + +export const ToolstripButton: React.FC = ({ active, onClick, icon, label, color, mounted }) => { + const [hovered, setHovered] = useState(false); + const [labelWidth, setLabelWidth] = useState(0); + const measureRef = useRef(null); + const styles = toolstripColorStyles[color]; + + // Measure label text width synchronously before first paint + useLayoutEffect(() => { + if (measureRef.current) { + setLabelWidth(measureRef.current.offsetWidth); + } + }, [label]); + + const expanded = active || hovered; + const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD; + const currentWidth = expanded ? expandedWidth : ICON_SIZE; + + const colorClass = active + ? styles.active + : hovered + ? styles.hover + : styles.inactive; + + const transition = mounted + ? `width ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease` + : 'none'; + + const innerTransition = mounted + ? `padding-left ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` + : 'none'; + + return ( + + ); +}; From c39670a6b7a67cce8576076b0683c77bc7143aa5 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 12 Mar 2026 18:05:32 -0700 Subject: [PATCH 09/15] fix: remove critical border from coverage task list for cleaner layout Co-Authored-By: Claude Opus 4.6 --- packages/checklist-editor/components/CoverageTaskList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/checklist-editor/components/CoverageTaskList.tsx b/packages/checklist-editor/components/CoverageTaskList.tsx index efa2965d..eb65c594 100644 --- a/packages/checklist-editor/components/CoverageTaskList.tsx +++ b/packages/checklist-editor/components/CoverageTaskList.tsx @@ -52,7 +52,7 @@ export const CoverageTaskList: React.FC = ({ onClick={() => onSelectItem(item.id)} className={`flex items-center gap-2 px-3 py-1.5 cursor-default transition-colors duration-100 ${ isSelected ? 'bg-muted/50' : 'hover:bg-muted/20' - } ${item.critical ? 'border-l-2 border-destructive/40' : ''}`} + }`} > {/* Status dot */} From 39c31d7e44270bfb5728683da0d80a86c5e8303d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 13 Mar 2026 00:49:34 -0700 Subject: [PATCH 10/15] feat: add PR Balance visualization with enriched fileDiffs data model Enrich fileDiffs from Record to support FileDiffInfo objects ({hunks, lines, status}) alongside the legacy number format. Coverage uses hunks; PR Balance uses lines + status to render a U-shaped bar chart (modified descending left, new ascending right) with center-of-mass indicator and collapsible squarified treemap bins. Co-Authored-By: Claude Opus 4.6 --- .agents/skills/checklist/SKILL.md | 16 +- apps/pi-extension/server.ts | 23 +- packages/checklist-editor/App.tsx | 52 ++- .../checklist-editor/components/PRBalance.tsx | 349 ++++++++++++++++++ .../hooks/useChecklistCoverage.ts | 15 +- packages/server/checklist.ts | 23 +- packages/shared/checklist-types.ts | 17 +- 7 files changed, 464 insertions(+), 31 deletions(-) create mode 100644 packages/checklist-editor/components/PRBalance.tsx diff --git a/.agents/skills/checklist/SKILL.md b/.agents/skills/checklist/SKILL.md index 5fd904cc..4ebd5c8c 100644 --- a/.agents/skills/checklist/SKILL.md +++ b/.agents/skills/checklist/SKILL.md @@ -49,6 +49,14 @@ As you read the diff, build a mental model: As you read the diff, count the number of diff hunks (`@@` markers) per file. You'll use these counts in step 3 to populate `fileDiffs` and `diffMap`. +Also collect line counts and new/modified status for the PR Balance visualization: +```bash +# Line counts per file (added + removed) +git diff --stat HEAD | head -n -1 +# New files (status "new"), everything else is "modified" +git diff --diff-filter=A --name-only HEAD +``` + ### 2. Decide What Needs Manual Verification Think about each change through the lens of what could go wrong that a human needs to catch. Consider categories like: @@ -81,9 +89,9 @@ Produce a JSON object with this structure: "provider": "github" }, "fileDiffs": { - "src/middleware/auth.ts": 5, - "src/pages/login.tsx": 3, - "src/lib/api-client.ts": 4 + "src/middleware/auth.ts": { "hunks": 5, "lines": 320, "status": "modified" }, + "src/pages/login.tsx": { "hunks": 3, "lines": 180, "status": "modified" }, + "src/lib/api-client.ts": { "hunks": 4, "lines": 250, "status": "new" } }, "items": [ { @@ -135,7 +143,7 @@ Produce a JSON object with this structure: - **`reason`**: One sentence explaining why automation can't fully cover this. "CSS grid rendering varies across browsers" is good. "Because it changed" is not. - **`files`**: File paths from the diff that this item relates to. Helps the developer trace your reasoning. Optional when `diffMap` is provided (derivable from its keys). - **`diffMap`**: Object mapping file paths to the number of diff hunks in that file that this check exercises. Paths must be keys in `fileDiffs`. Multiple items can cover the same hunks — that's expected (many-to-many). Example: `{ "src/middleware/auth.ts": 3, "src/pages/login.tsx": 2 }`. -- **`fileDiffs`** (on the top-level checklist, not per-item): Object mapping each changed file's relative path to its total number of diff hunks. Count `@@` markers per file in the `git diff` output. This enables the coverage visualization toggle in the checklist UI. Example: `{ "src/middleware/auth.ts": 5, "src/pages/login.tsx": 3 }`. +- **`fileDiffs`** (on the top-level checklist, not per-item): Object mapping each changed file's relative path to its diff metadata. Each value is `{ "hunks": N, "lines": N, "status": "new" | "modified" }`. `hunks` = count of `@@` markers in the diff. `lines` = total lines changed (from `git diff --stat`). `status` = `"new"` for added files, `"modified"` for everything else. This enables coverage visualization (hunks) and PR Balance (lines + status). Legacy format (plain number = hunk count) is still accepted but won't enable PR Balance. - **`critical`**: Reserve for items where failure means data loss, security vulnerability, or broken deployment. Typically 0–3 items per checklist. ### 4. Launch the Checklist UI diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index fe0a5e04..092a6078 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -744,14 +744,29 @@ export function validateChecklist(data: unknown): string[] { } } - // Validate optional fileDiffs field + // Validate optional fileDiffs field (accepts number or {hunks, lines, status}) if (obj.fileDiffs !== undefined) { if (typeof obj.fileDiffs !== "object" || obj.fileDiffs === null || Array.isArray(obj.fileDiffs)) { - errors.push('"fileDiffs" must be an object mapping file paths to hunk counts.'); + errors.push('"fileDiffs" must be an object mapping file paths to diff info.'); } else { for (const [key, val] of Object.entries(obj.fileDiffs as Record)) { - if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { - errors.push(`fileDiffs["${key}"] must be a positive integer.`); + if (typeof val === "number") { + if (val < 1 || !Number.isInteger(val)) { + errors.push(`fileDiffs["${key}"] must be a positive integer.`); + } + } else if (typeof val === "object" && val !== null) { + const info = val as Record; + if (typeof info.hunks !== "number" || info.hunks < 1 || !Number.isInteger(info.hunks)) { + errors.push(`fileDiffs["${key}"].hunks must be a positive integer.`); + } + if (typeof info.lines !== "number" || info.lines < 1 || !Number.isInteger(info.lines)) { + errors.push(`fileDiffs["${key}"].lines must be a positive integer.`); + } + if (info.status !== "new" && info.status !== "modified") { + errors.push(`fileDiffs["${key}"].status must be "new" or "modified".`); + } + } else { + errors.push(`fileDiffs["${key}"] must be a positive integer or {hunks, lines, status} object.`); } } } diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx index 30eefcb9..75db3393 100644 --- a/packages/checklist-editor/App.tsx +++ b/packages/checklist-editor/App.tsx @@ -10,6 +10,7 @@ import { ChecklistGroup } from './components/ChecklistGroup'; import { ChecklistAnnotationPanel } from './components/ChecklistAnnotationPanel'; import { ProgressBar } from './components/ProgressBar'; import { CoverageView } from './components/CoverageView'; +import { PRBalance } from './components/PRBalance'; import { ViewModeToggle } from './components/ViewModeToggle'; import { useChecklistState } from './hooks/useChecklistState'; import { useChecklistProgress } from './hooks/useChecklistProgress'; @@ -36,17 +37,17 @@ const DEMO_CHECKLIST: Checklist = { provider: 'github' as const, }, fileDiffs: { - 'src/middleware/csrf.ts': 3, - 'src/middleware/auth.ts': 5, - 'src/middleware/session-migration.ts': 4, - 'src/middleware/api-key.ts': 2, - 'src/routes/api.ts': 2, - 'src/lib/api-client.ts': 6, - 'src/hooks/useAuth.ts': 4, - 'src/pages/login.tsx': 8, - 'src/auth/providers.ts': 5, - 'src/components/AuthButton.tsx': 3, - 'src/components/ErrorMessage.tsx': 2, + 'src/middleware/csrf.ts': { hunks: 3, lines: 145, status: 'new' }, + 'src/middleware/auth.ts': { hunks: 5, lines: 320, status: 'modified' }, + 'src/middleware/session-migration.ts': { hunks: 4, lines: 210, status: 'new' }, + 'src/middleware/api-key.ts': { hunks: 2, lines: 85, status: 'modified' }, + 'src/routes/api.ts': { hunks: 2, lines: 60, status: 'modified' }, + 'src/lib/api-client.ts': { hunks: 6, lines: 480, status: 'modified' }, + 'src/hooks/useAuth.ts': { hunks: 4, lines: 190, status: 'new' }, + 'src/pages/login.tsx': { hunks: 8, lines: 650, status: 'modified' }, + 'src/auth/providers.ts': { hunks: 5, lines: 380, status: 'new' }, + 'src/components/AuthButton.tsx': { hunks: 3, lines: 120, status: 'modified' }, + 'src/components/ErrorMessage.tsx': { hunks: 2, lines: 75, status: 'new' }, }, items: [ { @@ -245,6 +246,7 @@ const ChecklistAppInner: React.FC = ({ checklist, origin const [notePopover, setNotePopover] = useState(null); const [automations, setAutomations] = useState({ postToPR: false, approveIfAllPass: false }); const [viewMode, setViewMode] = useState('checklist'); + const [balanceOpen, setBalanceOpen] = useState(false); const documentRef = useRef(null); const globalCommentButtonRef = useRef(null); @@ -252,6 +254,10 @@ const ChecklistAppInner: React.FC = ({ checklist, origin const { counts, categoryProgress, submitState } = useChecklistProgress(checklist.items, state.results); const coverageData = useChecklistCoverage(checklist.fileDiffs, checklist.items, state.results); const hasCoverage = coverageData !== null; + const hasBalance = useMemo( + () => checklist.fileDiffs && Object.values(checklist.fileDiffs).some(v => typeof v === 'object'), + [checklist.fileDiffs], + ); const panelResize = useResizablePanel({ storageKey: 'plannotator-checklist-panel-width', @@ -548,6 +554,30 @@ const ChecklistAppInner: React.FC = ({ checklist, origin
+ {/* PR Balance — collapsible orientation card */} + {hasBalance && checklist.fileDiffs && ( +
+ + {balanceOpen && ( +
+ +
+ )} +
+ )} + {/* View content — swaps between checklist items and coverage */} {viewMode === 'coverage' && coverageData ? ( ; +} + +// --- Helpers --- + +interface FileEntry { + file: string; + lines: number; + status: 'new' | 'modified'; +} + +function toEntries(fileDiffs: Record): FileEntry[] { + return Object.entries(fileDiffs).map(([file, val]) => { + if (typeof val === 'number') { + return { file, lines: val, status: 'modified' as const }; + } + return { file, lines: val.lines, status: val.status }; + }); +} + +interface TreemapRect extends FileEntry { + tx: number; ty: number; tw: number; th: number; +} + +function squarify(items: FileEntry[], x: number, y: number, w: number, h: number): TreemapRect[] { + const rects: TreemapRect[] = []; + const total = items.reduce((s, i) => s + i.lines, 0); + if (!total || !items.length) return rects; + + let remaining = [...items].sort((a, b) => b.lines - a.lines); + let cx = x, cy = y, cw = w, ch = h; + + while (remaining.length) { + const isWide = cw >= ch; + const side = isWide ? ch : cw; + const areaLeft = remaining.reduce((s, i) => s + i.lines, 0); + const scale = (cw * ch) / areaLeft; + let row = [remaining[0]]; + let rowArea = remaining[0].lines * scale; + + for (let i = 1; i < remaining.length; i++) { + const newRow = [...row, remaining[i]]; + const newRowArea = rowArea + remaining[i].lines * scale; + const worstNew = newRow.reduce((worst, item) => { + const a = item.lines * scale; + const rl = newRowArea / side; + const is2 = a / rl; + return Math.max(worst, Math.max(rl / is2, is2 / rl)); + }, 0); + const worstOld = row.reduce((worst, item) => { + const a = item.lines * scale; + const rl = rowArea / side; + const is2 = a / rl; + return Math.max(worst, Math.max(rl / is2, is2 / rl)); + }, 0); + if (worstNew <= worstOld) { row = newRow; rowArea = newRowArea; } + else break; + } + + const rowLen = rowArea / side; + let offset = 0; + for (const item of row) { + const itemArea = item.lines * scale; + const itemLen = itemArea / rowLen; + if (isWide) rects.push({ ...item, tx: cx, ty: cy + offset, tw: rowLen, th: itemLen }); + else rects.push({ ...item, tx: cx + offset, ty: cy, tw: itemLen, th: rowLen }); + offset += itemLen; + } + if (isWide) { cx += rowLen; cw -= rowLen; } + else { cy += rowLen; ch -= rowLen; } + remaining = remaining.slice(row.length); + } + + return rects; +} + +// --- Constants --- +const SVG_W = 680; +const MARGIN_L = 30; +const MARGIN_R = 30; +const BEAM_Y = 420; +const BAR_FLOOR = BEAM_Y - 6; +const MAX_BAR_H = 350; +const BIN_TOP = 50; +const BIN_BOT = BEAM_Y - 16; +const BIN_H = BIN_BOT - BIN_TOP; +const BIN_GAP = 24; + +// --- Component --- + +export const PRBalance: React.FC = ({ fileDiffs }) => { + const [collapsed, setCollapsed] = useState(false); + const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null); + const svgRef = useRef(null); + + const entries = useMemo(() => toEntries(fileDiffs), [fileDiffs]); + + // Check if we have enriched data (at least one FileDiffInfo object) + const hasEnrichedData = useMemo( + () => Object.values(fileDiffs).some(v => typeof v === 'object'), + [fileDiffs], + ); + + // If no enriched data, don't render — PR Balance needs lines + status + if (!hasEnrichedData || entries.length === 0) return null; + + // All layout geometry derived from entries in one pass + const layout = useMemo(() => { + // U-shape: modified descending (tallest at left edge), new ascending (tallest at right edge) + const mod = entries.filter(e => e.status === 'modified').sort((a, b) => b.lines - a.lines); + const nw = entries.filter(e => e.status === 'new').sort((a, b) => a.lines - b.lines); + const all = [...mod, ...nw]; + const N = all.length; + const split = mod.length; + const totalMod = mod.reduce((s, f) => s + f.lines, 0); + const totalNew = nw.reduce((s, f) => s + f.lines, 0); + const totalAll = totalMod + totalNew; + const maxLines = Math.max(...all.map(f => f.lines)); + + const chartW = SVG_W - MARGIN_L - MARGIN_R; + const GAP = 2; + const BAR_W = Math.max(2, Math.floor((chartW - GAP * (N - 1)) / N)); + const totalBarsW = BAR_W * N + GAP * (N - 1); + const offsetX = MARGIN_L + (chartW - totalBarsW) / 2; + const divXPos = offsetX + split * (BAR_W + GAP) - GAP / 2; + const modMidX = split > 0 ? offsetX + (split * (BAR_W + GAP) - GAP) / 2 : 0; + const newMidX = nw.length > 0 + ? offsetX + split * (BAR_W + GAP) + ((N - split) * (BAR_W + GAP) - GAP) / 2 + : 0; + + // Center of mass + const masses = all.map((f, i) => ({ x: offsetX + i * (BAR_W + GAP) + BAR_W / 2, m: f.lines })); + const totalM = masses.reduce((s, p) => s + p.m, 0); + const comX = masses.reduce((s, p) => s + p.x * p.m, 0) / totalM; + + // Treemap bins + const leftBinX = MARGIN_L; + const leftBinW = split > 0 ? divXPos - BIN_GAP / 2 - leftBinX : 0; + const rightBinX = divXPos + BIN_GAP / 2; + const rightBinW = SVG_W - MARGIN_R - rightBinX; + + const modTm = squarify(mod, leftBinX, BIN_TOP, leftBinW, BIN_H); + const newTm = squarify(nw, rightBinX, BIN_TOP, rightBinW, BIN_H); + const tmMap = new Map(); + modTm.forEach(t => tmMap.set(t.file + '_mod', t)); + newTm.forEach(t => tmMap.set(t.file + '_new', t)); + + const yTicks = [200, 400, 600, 800].filter(v => BAR_FLOOR - (v / maxLines) * MAX_BAR_H >= 26); + const pctNew = totalAll > 0 ? Math.round(totalNew / totalAll * 100) : 0; + + return { + modified: mod, newFiles: nw, allFiles: all, splitIndex: split, + totalMod, totalNew, maxLines, BAR_W, GAP, offsetX, divXPos, + modMidX, newMidX, comX, leftBinX, leftBinW, rightBinX, rightBinW, + treemapMap: tmMap, yTicks, pctNew, + }; + }, [entries]); + + const { + modified, newFiles, allFiles, splitIndex, + totalMod, totalNew, maxLines, BAR_W, GAP, offsetX, divXPos, + modMidX, newMidX, comX, leftBinX, leftBinW, rightBinX, rightBinW, + treemapMap, yTicks, pctNew, + } = layout; + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!tooltip) return; + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + setTooltip(prev => prev ? { ...prev, x: e.clientX - rect.left + 14, y: e.clientY - rect.top - 40 } : null); + }, [tooltip]); + + const showTip = useCallback((e: React.MouseEvent, content: string) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + setTooltip({ x: e.clientX - rect.left + 14, y: e.clientY - rect.top - 40, content }); + }, []); + + const hideTip = useCallback(() => setTooltip(null), []); + + return ( +
+ {/* Toggle button */} +
+ + + {collapsed ? 'Showing binned mass' : 'Showing individual files'} + +
+ + + {/* Divider line (dashed) */} + {splitIndex > 0 && newFiles.length > 0 && ( + + )} + + {/* Beam line */} + + + {/* Header labels */} + {modified.length > 0 && ( + + Modified · {modified.length} files · {totalMod.toLocaleString()} lines + + )} + {newFiles.length > 0 && ( + + New · {newFiles.length} files · {totalNew.toLocaleString()} lines + + )} + + {/* Y-axis ticks */} + {yTicks.map(v => { + const y = BAR_FLOOR - (v / maxLines) * MAX_BAR_H; + return ( + + + + {v} + + + ); + })} + + {/* Center of mass indicator */} + + + + + + Center of mass + + + {pctNew >= 50 ? `${pctNew}% of weight is new code` : `${100 - pctNew}% of weight is modified code`} + + + + {/* Bin outlines (treemap mode) */} + {modified.length > 0 && ( + + )} + {newFiles.length > 0 && ( + + )} + + {/* Bars / Treemap rects */} + {allFiles.map((f, i) => { + const isMod = i < splitIndex; + const barX = offsetX + i * (BAR_W + GAP); + const barH = (f.lines / maxLines) * MAX_BAR_H; + const barY = BAR_FLOOR - barH; + const key = f.file + (isMod ? '_mod' : '_new'); + const tm = treemapMap.get(key); + const pad = 1.5; + + // Determine position based on mode + const rx = collapsed && tm ? tm.tx + pad : barX; + const ry = collapsed && tm ? tm.ty + pad : barY; + const rw = collapsed && tm ? Math.max(tm.tw - pad * 2, 1) : BAR_W; + const rh = collapsed && tm ? Math.max(tm.th - pad * 2, 1) : barH; + + const colorClass = isMod ? 'fill-blue-500/80' : 'fill-success'; + + return ( + + showTip(e, `${f.file}\n${f.lines} lines · ${isMod ? 'modified' : 'new'}`)} + onMouseLeave={hideTip} + /> + {/* Treemap label (only in collapsed mode for large enough cells) */} + {collapsed && tm && tm.tw > 55 && tm.th > 28 && ( + + + {f.file.length > Math.floor(tm.tw / 7) ? f.file.slice(0, Math.floor(tm.tw / 7) - 2) + '...' : f.file} + + + {f.lines} lines + + + )} + + ); + })} + + + {/* Tooltip */} + {tooltip && ( +
+ {tooltip.content} +
+ )} +
+ ); +}; diff --git a/packages/checklist-editor/hooks/useChecklistCoverage.ts b/packages/checklist-editor/hooks/useChecklistCoverage.ts index eef90c2c..2ffd5844 100644 --- a/packages/checklist-editor/hooks/useChecklistCoverage.ts +++ b/packages/checklist-editor/hooks/useChecklistCoverage.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import type { ChecklistItem, ChecklistItemResult } from '@plannotator/shared/checklist-types'; +import type { ChecklistItem, ChecklistItemResult, FileDiffInfo } from '@plannotator/shared/checklist-types'; export interface FileTreeNode { name: string; @@ -39,15 +39,20 @@ interface FileCoverageEntry { // never exceeds the file's total hunks. This means failed items always // dominate overlapping coverage — a passing check doesn't hide a failure. +/** Extract hunk count from a fileDiffs value (number or FileDiffInfo object). */ +function getHunks(val: number | FileDiffInfo): number { + return typeof val === 'number' ? val : val.hunks; +} + function computeFileCoverage( - fileDiffs: Record, + fileDiffs: Record, items: ChecklistItem[], results: Map, ): Map { const coverage = new Map(); - for (const [file, total] of Object.entries(fileDiffs)) { - coverage.set(file, { total, passed: 0, failed: 0, skipped: 0 }); + for (const [file, val] of Object.entries(fileDiffs)) { + coverage.set(file, { total: getHunks(val), passed: 0, failed: 0, skipped: 0 }); } for (const item of items) { @@ -152,7 +157,7 @@ function buildFileTree( } export function useChecklistCoverage( - fileDiffs: Record | undefined, + fileDiffs: Record | undefined, items: ChecklistItem[], results: Map, ): CoverageData | null { diff --git a/packages/server/checklist.ts b/packages/server/checklist.ts index 61697910..3c4bec23 100644 --- a/packages/server/checklist.ts +++ b/packages/server/checklist.ts @@ -114,14 +114,29 @@ export function validateChecklist(data: unknown): string[] { } } - // Validate optional fileDiffs field + // Validate optional fileDiffs field (accepts number or {hunks, lines, status}) if (obj.fileDiffs !== undefined) { if (typeof obj.fileDiffs !== "object" || obj.fileDiffs === null || Array.isArray(obj.fileDiffs)) { - errors.push('"fileDiffs" must be an object mapping file paths to hunk counts.'); + errors.push('"fileDiffs" must be an object mapping file paths to diff info.'); } else { for (const [key, val] of Object.entries(obj.fileDiffs as Record)) { - if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { - errors.push(`fileDiffs["${key}"] must be a positive integer.`); + if (typeof val === "number") { + if (val < 1 || !Number.isInteger(val)) { + errors.push(`fileDiffs["${key}"] must be a positive integer.`); + } + } else if (typeof val === "object" && val !== null) { + const info = val as Record; + if (typeof info.hunks !== "number" || info.hunks < 1 || !Number.isInteger(info.hunks)) { + errors.push(`fileDiffs["${key}"].hunks must be a positive integer.`); + } + if (typeof info.lines !== "number" || info.lines < 1 || !Number.isInteger(info.lines)) { + errors.push(`fileDiffs["${key}"].lines must be a positive integer.`); + } + if (info.status !== "new" && info.status !== "modified") { + errors.push(`fileDiffs["${key}"].status must be "new" or "modified".`); + } + } else { + errors.push(`fileDiffs["${key}"] must be a positive integer or {hunks, lines, status} object.`); } } } diff --git a/packages/shared/checklist-types.ts b/packages/shared/checklist-types.ts index 52eccf12..dd1dfd76 100644 --- a/packages/shared/checklist-types.ts +++ b/packages/shared/checklist-types.ts @@ -37,6 +37,16 @@ export interface ChecklistPR { provider: "github" | "gitlab" | "azure-devops"; } +/** Per-file diff metadata. Hunks drive coverage; lines + status drive PR Balance. */ +export interface FileDiffInfo { + /** Number of diff hunks (@@-delimited sections) */ + hunks: number; + /** Total lines changed (added + removed) */ + lines: number; + /** Whether the file is newly added or modified */ + status: "new" | "modified"; +} + export interface Checklist { /** Short title for the checklist */ title: string; @@ -46,9 +56,10 @@ export interface Checklist { items: ChecklistItem[]; /** Optional associated pull/merge request */ pr?: ChecklistPR; - /** Total diff hunks per file path (relative to repo root). - * Presence of this field enables the coverage toggle view. */ - fileDiffs?: Record; + /** Per-file diff metadata (relative to repo root). + * Presence enables coverage toggle + PR Balance visualization. + * Values can be a number (legacy: hunk count only) or FileDiffInfo object. */ + fileDiffs?: Record; } // --- Developer Response --- From 5669ba3c6313f01c39758fd277f93c27ea11a868 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 13 Mar 2026 10:59:22 -0700 Subject: [PATCH 11/15] fix: default coverage to side-by-side layout, remove redundant Tasks header Co-Authored-By: Claude Opus 4.6 --- packages/checklist-editor/components/CoverageView.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/checklist-editor/components/CoverageView.tsx b/packages/checklist-editor/components/CoverageView.tsx index 18103ac1..0a32090e 100644 --- a/packages/checklist-editor/components/CoverageView.tsx +++ b/packages/checklist-editor/components/CoverageView.tsx @@ -41,7 +41,7 @@ export const CoverageView: React.FC = ({ onSetStatus, onSelectItem, }) => { - const [layout, setLayout] = useState('stacked'); + const [layout, setLayout] = useState('side-by-side'); const isSideBySide = layout === 'side-by-side'; return ( @@ -99,11 +99,6 @@ export const CoverageView: React.FC = ({ {/* Task list */}
-
- - Tasks - -
Date: Fri, 13 Mar 2026 10:59:28 -0700 Subject: [PATCH 12/15] fix: auto-collapse checklist items on status change, enrich demo data Clicking pass/fail/skip (or pressing p/f/s) now collapses the expanded item so the reviewer flows naturally to the next check. PR Balance card gets a solid inner surface matching coverage view styling. Demo data expanded to 30 files with heavy new-code weighting. Co-Authored-By: Claude Opus 4.6 --- packages/checklist-editor/App.tsx | 90 ++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx index 75db3393..a5a37bb1 100644 --- a/packages/checklist-editor/App.tsx +++ b/packages/checklist-editor/App.tsx @@ -37,17 +37,38 @@ const DEMO_CHECKLIST: Checklist = { provider: 'github' as const, }, fileDiffs: { - 'src/middleware/csrf.ts': { hunks: 3, lines: 145, status: 'new' }, - 'src/middleware/auth.ts': { hunks: 5, lines: 320, status: 'modified' }, - 'src/middleware/session-migration.ts': { hunks: 4, lines: 210, status: 'new' }, - 'src/middleware/api-key.ts': { hunks: 2, lines: 85, status: 'modified' }, - 'src/routes/api.ts': { hunks: 2, lines: 60, status: 'modified' }, - 'src/lib/api-client.ts': { hunks: 6, lines: 480, status: 'modified' }, - 'src/hooks/useAuth.ts': { hunks: 4, lines: 190, status: 'new' }, - 'src/pages/login.tsx': { hunks: 8, lines: 650, status: 'modified' }, - 'src/auth/providers.ts': { hunks: 5, lines: 380, status: 'new' }, - 'src/components/AuthButton.tsx': { hunks: 3, lines: 120, status: 'modified' }, - 'src/components/ErrorMessage.tsx': { hunks: 2, lines: 75, status: 'new' }, + // Modified — smaller touch-ups to existing code + 'src/pages/login.tsx': { hunks: 8, lines: 320, status: 'modified' }, + 'src/lib/api-client.ts': { hunks: 5, lines: 180, status: 'modified' }, + 'src/middleware/auth.ts': { hunks: 4, lines: 140, status: 'modified' }, + 'src/pages/dashboard.tsx': { hunks: 3, lines: 110, status: 'modified' }, + 'src/components/AuthButton.tsx': { hunks: 3, lines: 90, status: 'modified' }, + 'src/middleware/api-key.ts': { hunks: 2, lines: 65, status: 'modified' }, + 'src/routes/api.ts': { hunks: 2, lines: 55, status: 'modified' }, + 'src/components/UserMenu.tsx': { hunks: 2, lines: 50, status: 'modified' }, + 'src/config/auth.ts': { hunks: 1, lines: 30, status: 'modified' }, + 'src/utils/redirect.ts': { hunks: 1, lines: 20, status: 'modified' }, + // New — bulk of the PR is fresh code + 'src/auth/providers.ts': { hunks: 10, lines: 920, status: 'new' }, + 'src/auth/oauth-flow.ts': { hunks: 8, lines: 780, status: 'new' }, + 'src/middleware/session-migration.ts': { hunks: 7, lines: 650, status: 'new' }, + 'src/hooks/useAuth.ts': { hunks: 6, lines: 580, status: 'new' }, + 'src/middleware/csrf.ts': { hunks: 5, lines: 480, status: 'new' }, + 'src/lib/token-store.ts': { hunks: 5, lines: 450, status: 'new' }, + 'src/components/OAuthCallback.tsx': { hunks: 5, lines: 420, status: 'new' }, + 'src/hooks/useTokenRefresh.ts': { hunks: 4, lines: 360, status: 'new' }, + 'src/auth/pkce.ts': { hunks: 4, lines: 340, status: 'new' }, + 'src/components/ErrorMessage.tsx': { hunks: 3, lines: 280, status: 'new' }, + 'src/components/SessionExpired.tsx': { hunks: 3, lines: 240, status: 'new' }, + 'src/auth/token-validator.ts': { hunks: 3, lines: 220, status: 'new' }, + 'src/utils/jwt-decode.ts': { hunks: 2, lines: 180, status: 'new' }, + 'src/types/auth.ts': { hunks: 2, lines: 150, status: 'new' }, + 'src/lib/csrf.ts': { hunks: 2, lines: 130, status: 'new' }, + 'tests/auth/providers.test.ts': { hunks: 5, lines: 480, status: 'new' }, + 'tests/auth/oauth-flow.test.ts': { hunks: 4, lines: 390, status: 'new' }, + 'tests/middleware/csrf.test.ts': { hunks: 3, lines: 310, status: 'new' }, + 'tests/hooks/useAuth.test.ts': { hunks: 3, lines: 280, status: 'new' }, + 'tests/middleware/session-migration.test.ts': { hunks: 2, lines: 220, status: 'new' }, }, items: [ { @@ -63,7 +84,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'CSRF protection is security-critical and automated tests may not catch middleware ordering issues.', files: ['src/middleware/csrf.ts', 'src/routes/api.ts'], - diffMap: { 'src/middleware/csrf.ts': 3, 'src/routes/api.ts': 2 }, + diffMap: { 'src/middleware/csrf.ts': 4, 'src/routes/api.ts': 3, 'src/lib/csrf.ts': 2 }, critical: true, }, { @@ -79,7 +100,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'Race conditions in token refresh can cause cascading 401s and logout the user.', files: ['src/lib/api-client.ts', 'src/hooks/useAuth.ts'], - diffMap: { 'src/lib/api-client.ts': 4, 'src/hooks/useAuth.ts': 3 }, + diffMap: { 'src/lib/api-client.ts': 5, 'src/hooks/useAuth.ts': 3, 'src/hooks/useTokenRefresh.ts': 3, 'src/lib/token-store.ts': 4 }, critical: true, }, { @@ -95,7 +116,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'Core user flow that must work correctly.', files: ['src/pages/login.tsx', 'src/middleware/auth.ts'], - diffMap: { 'src/pages/login.tsx': 5, 'src/middleware/auth.ts': 3 }, + diffMap: { 'src/pages/login.tsx': 6, 'src/middleware/auth.ts': 4, 'src/utils/redirect.ts': 1, 'src/pages/dashboard.tsx': 3 }, }, { id: 'auth-4', @@ -110,7 +131,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'OAuth flows involve third-party redirects that are difficult to test automatically.', files: ['src/auth/providers.ts'], - diffMap: { 'src/auth/providers.ts': 5, 'src/pages/login.tsx': 2 }, + diffMap: { 'src/auth/providers.ts': 6, 'src/pages/login.tsx': 3, 'src/components/OAuthCallback.tsx': 4, 'src/auth/pkce.ts': 2 }, }, { id: 'auth-5', @@ -125,7 +146,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'A forced logout would affect all active users and is unacceptable for a production migration.', files: ['src/middleware/session-migration.ts'], - diffMap: { 'src/middleware/session-migration.ts': 4, 'src/middleware/auth.ts': 2 }, + diffMap: { 'src/middleware/session-migration.ts': 5, 'src/middleware/auth.ts': 3, 'src/lib/token-store.ts': 2, 'src/components/SessionExpired.tsx': 2 }, critical: true, }, { @@ -140,7 +161,7 @@ const DEMO_CHECKLIST: Checklist = { ], reason: 'Breaking API key auth would disrupt automated integrations.', files: ['src/middleware/api-key.ts'], - diffMap: { 'src/middleware/api-key.ts': 2 }, + diffMap: { 'src/middleware/api-key.ts': 3, 'src/config/auth.ts': 2, 'src/routes/api.ts': 1 }, }, { id: 'auth-7', @@ -153,7 +174,7 @@ const DEMO_CHECKLIST: Checklist = { 'Trigger rate limiting (5+ failed attempts) and verify the message', ], reason: 'Error copy is hard to verify without visual inspection.', - diffMap: { 'src/components/ErrorMessage.tsx': 2, 'src/pages/login.tsx': 1 }, + diffMap: { 'src/components/ErrorMessage.tsx': 3, 'src/pages/login.tsx': 2, 'src/components/SessionExpired.tsx': 1 }, }, { id: 'auth-8', @@ -166,7 +187,7 @@ const DEMO_CHECKLIST: Checklist = { 'Verify there is no layout shift when the spinner appears', ], reason: 'Loading state polish requires visual verification.', - diffMap: { 'src/components/AuthButton.tsx': 3, 'src/lib/api-client.ts': 2, 'src/hooks/useAuth.ts': 1 }, + diffMap: { 'src/components/AuthButton.tsx': 4, 'src/lib/api-client.ts': 3, 'src/hooks/useAuth.ts': 2, 'src/components/UserMenu.tsx': 2, 'src/pages/dashboard.tsx': 2 }, }, ], }; @@ -293,6 +314,19 @@ const ChecklistAppInner: React.FC = ({ checklist, origin // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // One-time on mount + // Set status and auto-collapse the item + const handleSetStatus = useCallback((id: string, status: ChecklistItemStatus) => { + state.setStatus(id, status); + if (status !== 'pending') { + setExpandedItems(prev => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, [state.setStatus]); + // Toggle item expansion const handleToggleExpand = useCallback((id: string) => { setExpandedItems(prev => { @@ -454,21 +488,27 @@ const ChecklistAppInner: React.FC = ({ checklist, origin if (s.selectedItemId) { e.preventDefault(); const r = s.getResult(s.selectedItemId); - s.setStatus(s.selectedItemId, r.status === 'passed' ? 'pending' : 'passed'); + const next = r.status === 'passed' ? 'pending' : 'passed'; + s.setStatus(s.selectedItemId, next); + if (next !== 'pending') setExpandedItems(prev => { const n = new Set(prev); n.delete(s.selectedItemId!); return n; }); } break; case 'f': if (s.selectedItemId) { e.preventDefault(); const r = s.getResult(s.selectedItemId); - s.setStatus(s.selectedItemId, r.status === 'failed' ? 'pending' : 'failed'); + const next = r.status === 'failed' ? 'pending' : 'failed'; + s.setStatus(s.selectedItemId, next); + if (next !== 'pending') setExpandedItems(prev => { const n = new Set(prev); n.delete(s.selectedItemId!); return n; }); } break; case 's': if (s.selectedItemId) { e.preventDefault(); const r = s.getResult(s.selectedItemId); - s.setStatus(s.selectedItemId, r.status === 'skipped' ? 'pending' : 'skipped'); + const next = r.status === 'skipped' ? 'pending' : 'skipped'; + s.setStatus(s.selectedItemId, next); + if (next !== 'pending') setExpandedItems(prev => { const n = new Set(prev); n.delete(s.selectedItemId!); return n; }); } break; case 'n': @@ -572,7 +612,9 @@ const ChecklistAppInner: React.FC = ({ checklist, origin {balanceOpen && (
- +
+ +
)}
@@ -622,7 +664,7 @@ const ChecklistAppInner: React.FC = ({ checklist, origin onToggleExpand={handleToggleExpand} onOpenNote={handleOpenNote} getResult={state.getResult} - onSetStatus={state.setStatus} + onSetStatus={handleSetStatus} /> ); })} From 1d45cdf4b360c861a4843162045d57c1556a551a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 13 Mar 2026 11:18:07 -0700 Subject: [PATCH 13/15] feat: add checklist display width setting (compact/default/wide) Reuses PLAN_WIDTH_OPTIONS from the plan editor but stores under a separate cookie (plannotator-checklist-width). Settings dialog shows the Display tab in checklist mode with a width picker. Document container applies the selected maxWidth via inline style. Co-Authored-By: Claude Opus 4.6 --- packages/checklist-editor/App.tsx | 17 ++++++- .../components/ChecklistHeader.tsx | 9 +++- packages/ui/components/Settings.tsx | 46 +++++++++++++++++-- packages/ui/utils/uiPreferences.ts | 11 +++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx index a5a37bb1..441a41b0 100644 --- a/packages/checklist-editor/App.tsx +++ b/packages/checklist-editor/App.tsx @@ -17,6 +17,8 @@ import { useChecklistProgress } from './hooks/useChecklistProgress'; import { useChecklistDraft } from './hooks/useChecklistDraft'; import { useChecklistCoverage } from './hooks/useChecklistCoverage'; import { exportChecklistResults } from './utils/exportChecklist'; +import { getChecklistWidth, saveChecklistWidth, PLAN_WIDTH_OPTIONS } from '@plannotator/ui/utils/uiPreferences'; +import type { PlanWidth } from '@plannotator/ui/utils/uiPreferences'; import type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from './hooks/useChecklistState'; import type { ChecklistPR, ChecklistViewMode } from '@plannotator/shared/checklist-types'; import type { ChecklistAutomations } from './components/ChecklistAnnotationPanel'; @@ -268,6 +270,7 @@ const ChecklistAppInner: React.FC = ({ checklist, origin const [automations, setAutomations] = useState({ postToPR: false, approveIfAllPass: false }); const [viewMode, setViewMode] = useState('checklist'); const [balanceOpen, setBalanceOpen] = useState(false); + const [checklistWidth, setChecklistWidth] = useState(getChecklistWidth); const documentRef = useRef(null); const globalCommentButtonRef = useRef(null); @@ -314,6 +317,16 @@ const ChecklistAppInner: React.FC = ({ checklist, origin // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // One-time on mount + const handleChecklistWidthChange = useCallback((width: PlanWidth) => { + setChecklistWidth(width); + saveChecklistWidth(width); + }, []); + + const checklistMaxWidth = useMemo(() => { + const widths: Record = { compact: 832, default: 1040, wide: 1280 }; + return widths[checklistWidth] ?? 832; + }, [checklistWidth]); + // Set status and auto-collapse the item const handleSetStatus = useCallback((id: string, status: ChecklistItemStatus) => { state.setStatus(id, status); @@ -561,6 +574,8 @@ const ChecklistAppInner: React.FC = ({ checklist, origin onSubmit={handleSubmit} isPanelOpen={isPanelOpen} onTogglePanel={() => setIsPanelOpen(p => !p)} + checklistWidth={checklistWidth} + onChecklistWidthChange={handleChecklistWidthChange} /> {/* Draft restore banner */} @@ -579,7 +594,7 @@ const ChecklistAppInner: React.FC = ({ checklist, origin
{/* Checklist document */}
-
+
{/* Title */}

{checklist.title}

diff --git a/packages/checklist-editor/components/ChecklistHeader.tsx b/packages/checklist-editor/components/ChecklistHeader.tsx index 32759594..d8b9a16b 100644 --- a/packages/checklist-editor/components/ChecklistHeader.tsx +++ b/packages/checklist-editor/components/ChecklistHeader.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { Settings } from '@plannotator/ui/components/Settings'; +import type { PlanWidth } from '@plannotator/ui/utils/uiPreferences'; import type { StatusCounts, SubmitState } from '../hooks/useChecklistProgress'; import type { ChecklistPR } from '@plannotator/shared/checklist-types'; @@ -130,6 +131,8 @@ interface ChecklistHeaderProps { onSubmit: () => void; isPanelOpen: boolean; onTogglePanel: () => void; + checklistWidth: PlanWidth; + onChecklistWidthChange: (width: PlanWidth) => void; } export const ChecklistHeader: React.FC = ({ @@ -142,6 +145,8 @@ export const ChecklistHeader: React.FC = ({ onSubmit, isPanelOpen, onTogglePanel, + checklistWidth, + onChecklistWidthChange, }) => (
{/* Left side */} @@ -221,7 +226,9 @@ export const ChecklistHeader: React.FC = ({ onTaterModeChange={() => {}} onIdentityChange={() => {}} origin={origin} - mode="review" + mode="checklist" + checklistWidth={checklistWidth} + onChecklistWidthChange={onChecklistWidthChange} /> {/* Panel toggle */} + ))} +
+
+ {(PLAN_WIDTH_OPTIONS.find(o => o.id === checklistWidth) ?? PLAN_WIDTH_OPTIONS[0]).px}px — {(PLAN_WIDTH_OPTIONS.find(o => o.id === checklistWidth) ?? PLAN_WIDTH_OPTIONS[0]).hint} +
+
+ )} + + {/* Plan-only display settings */} + {mode === 'plan' && (<> {/* Auto-open Sidebar */}
@@ -657,6 +694,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange />
+ )} )} diff --git a/packages/ui/utils/uiPreferences.ts b/packages/ui/utils/uiPreferences.ts index 9670d2c2..e28773f4 100644 --- a/packages/ui/utils/uiPreferences.ts +++ b/packages/ui/utils/uiPreferences.ts @@ -3,6 +3,7 @@ import { storage } from './storage'; const STORAGE_KEY_TOC = 'plannotator-toc-enabled'; const STORAGE_KEY_STICKY_ACTIONS = 'plannotator-sticky-actions-enabled'; const STORAGE_KEY_PLAN_WIDTH = 'plannotator-plan-width'; +const STORAGE_KEY_CHECKLIST_WIDTH = 'plannotator-checklist-width'; export type PlanWidth = 'compact' | 'default' | 'wide'; @@ -32,3 +33,13 @@ export function saveUIPreferences(prefs: UIPreferences): void { storage.setItem(STORAGE_KEY_STICKY_ACTIONS, String(prefs.stickyActionsEnabled)); storage.setItem(STORAGE_KEY_PLAN_WIDTH, prefs.planWidth); } + +// Checklist has its own width setting (same options, separate cookie) +export function getChecklistWidth(): PlanWidth { + const w = storage.getItem(STORAGE_KEY_CHECKLIST_WIDTH); + return (w === 'compact' || w === 'default' || w === 'wide') ? w : 'compact'; +} + +export function saveChecklistWidth(width: PlanWidth): void { + storage.setItem(STORAGE_KEY_CHECKLIST_WIDTH, width); +} From a19eea134ba70d826064aacbef03e54f2ac65677 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 13 Mar 2026 11:44:18 -0700 Subject: [PATCH 14/15] fix: persist checklist view mode and coverage layout via cookies View mode (checklist vs coverage) and coverage layout (stacked vs side-by-side) now survive page reloads using cookie-based storage. Co-Authored-By: Claude Opus 4.6 --- packages/checklist-editor/App.tsx | 9 +++++--- .../components/CoverageView.tsx | 7 +++--- packages/ui/utils/uiPreferences.ts | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx index 441a41b0..0da0128a 100644 --- a/packages/checklist-editor/App.tsx +++ b/packages/checklist-editor/App.tsx @@ -17,7 +17,7 @@ import { useChecklistProgress } from './hooks/useChecklistProgress'; import { useChecklistDraft } from './hooks/useChecklistDraft'; import { useChecklistCoverage } from './hooks/useChecklistCoverage'; import { exportChecklistResults } from './utils/exportChecklist'; -import { getChecklistWidth, saveChecklistWidth, PLAN_WIDTH_OPTIONS } from '@plannotator/ui/utils/uiPreferences'; +import { getChecklistWidth, saveChecklistWidth, getChecklistView, saveChecklistView, PLAN_WIDTH_OPTIONS } from '@plannotator/ui/utils/uiPreferences'; import type { PlanWidth } from '@plannotator/ui/utils/uiPreferences'; import type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from './hooks/useChecklistState'; import type { ChecklistPR, ChecklistViewMode } from '@plannotator/shared/checklist-types'; @@ -268,7 +268,8 @@ const ChecklistAppInner: React.FC = ({ checklist, origin const [isPanelOpen, setIsPanelOpen] = useState(true); const [notePopover, setNotePopover] = useState(null); const [automations, setAutomations] = useState({ postToPR: false, approveIfAllPass: false }); - const [viewMode, setViewMode] = useState('checklist'); + const [viewMode, setViewModeRaw] = useState(getChecklistView); + const setViewMode = (mode: ChecklistViewMode) => { setViewModeRaw(mode); saveChecklistView(mode); }; const [balanceOpen, setBalanceOpen] = useState(false); const [checklistWidth, setChecklistWidth] = useState(getChecklistWidth); const documentRef = useRef(null); @@ -465,6 +466,8 @@ const ChecklistAppInner: React.FC = ({ checklist, origin isSubmittingRef.current = isSubmitting; const hasCoverageRef = useRef(hasCoverage); hasCoverageRef.current = hasCoverage; + const viewModeRef = useRef(viewMode); + viewModeRef.current = viewMode; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -537,7 +540,7 @@ const ChecklistAppInner: React.FC = ({ checklist, origin case 'v': if (hasCoverageRef.current) { e.preventDefault(); - setViewMode(prev => prev === 'checklist' ? 'coverage' : 'checklist'); + setViewMode(viewModeRef.current === 'checklist' ? 'coverage' : 'checklist'); } break; case 'Enter': diff --git a/packages/checklist-editor/components/CoverageView.tsx b/packages/checklist-editor/components/CoverageView.tsx index 0a32090e..abdab5a0 100644 --- a/packages/checklist-editor/components/CoverageView.tsx +++ b/packages/checklist-editor/components/CoverageView.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { CoverageFileTree } from './CoverageFileTree'; import { CoverageTaskList } from './CoverageTaskList'; +import { getCoverageLayout, saveCoverageLayout } from '@plannotator/ui/utils/uiPreferences'; +import type { CoverageLayout } from '@plannotator/ui/utils/uiPreferences'; import type { CoverageData } from '../hooks/useChecklistCoverage'; import type { ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from '@plannotator/shared/checklist-types'; -type CoverageLayout = 'stacked' | 'side-by-side'; - interface CoverageViewProps { coverageData: CoverageData; items: ChecklistItem[]; @@ -41,7 +41,8 @@ export const CoverageView: React.FC = ({ onSetStatus, onSelectItem, }) => { - const [layout, setLayout] = useState('side-by-side'); + const [layout, setLayoutRaw] = useState(getCoverageLayout); + const setLayout = (l: CoverageLayout) => { setLayoutRaw(l); saveCoverageLayout(l); }; const isSideBySide = layout === 'side-by-side'; return ( diff --git a/packages/ui/utils/uiPreferences.ts b/packages/ui/utils/uiPreferences.ts index e28773f4..3143631c 100644 --- a/packages/ui/utils/uiPreferences.ts +++ b/packages/ui/utils/uiPreferences.ts @@ -4,6 +4,10 @@ const STORAGE_KEY_TOC = 'plannotator-toc-enabled'; const STORAGE_KEY_STICKY_ACTIONS = 'plannotator-sticky-actions-enabled'; const STORAGE_KEY_PLAN_WIDTH = 'plannotator-plan-width'; const STORAGE_KEY_CHECKLIST_WIDTH = 'plannotator-checklist-width'; +const STORAGE_KEY_CHECKLIST_VIEW = 'plannotator-checklist-view'; +const STORAGE_KEY_COVERAGE_LAYOUT = 'plannotator-coverage-layout'; + +export type CoverageLayout = 'stacked' | 'side-by-side'; export type PlanWidth = 'compact' | 'default' | 'wide'; @@ -43,3 +47,21 @@ export function getChecklistWidth(): PlanWidth { export function saveChecklistWidth(width: PlanWidth): void { storage.setItem(STORAGE_KEY_CHECKLIST_WIDTH, width); } + +export function getChecklistView(): 'checklist' | 'coverage' { + const v = storage.getItem(STORAGE_KEY_CHECKLIST_VIEW); + return v === 'coverage' ? 'coverage' : 'checklist'; +} + +export function saveChecklistView(mode: 'checklist' | 'coverage'): void { + storage.setItem(STORAGE_KEY_CHECKLIST_VIEW, mode); +} + +export function getCoverageLayout(): CoverageLayout { + const v = storage.getItem(STORAGE_KEY_COVERAGE_LAYOUT); + return v === 'stacked' ? 'stacked' : 'side-by-side'; +} + +export function saveCoverageLayout(layout: CoverageLayout): void { + storage.setItem(STORAGE_KEY_COVERAGE_LAYOUT, layout); +} From d575cab8498096e627f5d8f677281596db80539e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 13 Mar 2026 11:44:22 -0700 Subject: [PATCH 15/15] fix: PR Balance label says "files" not "code" Co-Authored-By: Claude Opus 4.6 --- packages/checklist-editor/components/PRBalance.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/checklist-editor/components/PRBalance.tsx b/packages/checklist-editor/components/PRBalance.tsx index 2ed40069..f191b8f3 100644 --- a/packages/checklist-editor/components/PRBalance.tsx +++ b/packages/checklist-editor/components/PRBalance.tsx @@ -257,7 +257,7 @@ export const PRBalance: React.FC = ({ fileDiffs }) => { Center of mass - {pctNew >= 50 ? `${pctNew}% of weight is new code` : `${100 - pctNew}% of weight is modified code`} + {pctNew >= 50 ? `${pctNew}% of weight is new files` : `${100 - pctNew}% of weight is modified files`}