From 8019f73a4443e852095b78d1e9f48a466bbd2acf Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 12 Jan 2026 19:36:09 -0800 Subject: [PATCH] Feat/code review system (#57) ## Summary Complete code review system for reviewing git diffs with annotations. ### Features - Interactive diff viewer with split/unified views - Line-level annotation system - Diff type selector (uncommitted, last commit, vs main branch) - Dynamic default branch detection - Empty state handling - Simplified UX with streamlined feedback flow Closes #51 Closes #56 --- .github/workflows/release.yml | 4 +- .gitignore | 3 +- CLAUDE.md | 66 +- apps/hook/commands/plannotator-review.md | 12 + apps/hook/package.json | 2 +- apps/hook/server/index.ts | 196 ++- .../commands/plannotator-review.md | 6 + apps/opencode-plugin/index.ts | 103 ++ apps/opencode-plugin/package.json | 4 +- apps/review/index.html | 18 + apps/review/index.tsx | 16 + apps/review/package.json | 28 + apps/review/server/index.ts | 101 ++ apps/review/tsconfig.json | 30 + apps/review/vite.config.ts | 36 + bun.lock | 139 +- package.json | 5 + packages/editor/App.tsx | 8 +- packages/review-editor/App.tsx | 839 +++++++++ .../review-editor/components/DiffViewer.tsx | 348 ++++ .../review-editor/components/FileTree.tsx | 191 ++ .../review-editor/components/ReviewPanel.tsx | 210 +++ packages/review-editor/demoData.ts | 251 +++ packages/review-editor/index.css | 377 ++++ packages/review-editor/package.json | 16 + packages/server/git.ts | 147 ++ packages/server/package.json | 8 + packages/server/review.ts | 274 +++ packages/ui/components/Settings.tsx | 340 ++-- packages/ui/components/UpdateBanner.tsx | 117 +- packages/ui/hooks/useUpdateCheck.ts | 35 + packages/ui/types.ts | 32 + scripts/install.cmd | 23 + scripts/install.ps1 | 24 + scripts/install.sh | 23 + tests/manual/local/sandbox-opencode.sh | 1531 +++++++++++++++++ tests/manual/local/test-opencode-review.sh | 38 + tests/manual/test-review-server.ts | 107 ++ 38 files changed, 5455 insertions(+), 253 deletions(-) create mode 100644 apps/hook/commands/plannotator-review.md create mode 100644 apps/opencode-plugin/commands/plannotator-review.md create mode 100644 apps/review/index.html create mode 100644 apps/review/index.tsx create mode 100644 apps/review/package.json create mode 100644 apps/review/server/index.ts create mode 100644 apps/review/tsconfig.json create mode 100644 apps/review/vite.config.ts create mode 100644 packages/review-editor/App.tsx create mode 100644 packages/review-editor/components/DiffViewer.tsx create mode 100644 packages/review-editor/components/FileTree.tsx create mode 100644 packages/review-editor/components/ReviewPanel.tsx create mode 100644 packages/review-editor/demoData.ts create mode 100644 packages/review-editor/index.css create mode 100644 packages/review-editor/package.json create mode 100644 packages/server/git.ts create mode 100644 packages/server/review.ts create mode 100755 tests/manual/local/sandbox-opencode.sh create mode 100755 tests/manual/local/test-opencode-review.sh create mode 100644 tests/manual/test-review-server.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6778190a..d5d4a27e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,9 @@ jobs: run: bun install - name: Build UI - run: bun run build:hook + run: | + bun run build:review + bun run build:hook - name: Compile binaries (cross-compile all targets) run: | diff --git a/.gitignore b/.gitignore index 95e670a7..6e8eafb8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,9 @@ dist-ssr # npm pack output *.tgz -# OpenCode plugin build artifact (generated from hook dist) +# OpenCode plugin build artifacts (generated from hook/review dist) apps/opencode-plugin/plannotator.html +apps/opencode-plugin/review-editor.html # Editor directories and files .vscode/* diff --git a/CLAUDE.md b/CLAUDE.md index 4db1d799..ffdad615 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Plannotator -A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. +A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs. ## Project Structure @@ -9,15 +9,23 @@ plannotator/ ├── apps/ │ ├── hook/ # Claude Code plugin │ │ ├── .claude-plugin/plugin.json +│ │ ├── commands/ # Slash commands (plannotator-review.md) │ │ ├── hooks/hooks.json # PermissionRequest hook config -│ │ ├── server/index.ts # Entry point (reads stdin, outputs decision) -│ │ └── dist/index.html # Built single-file app -│ └── opencode-plugin/ # OpenCode plugin -│ ├── index.ts # Plugin entry with submit_plan tool -│ └── plannotator.html # Built single-file app (copied from hook) +│ │ ├── server/index.ts # Entry point (plan + review subcommand) +│ │ └── dist/ # Built single-file apps (index.html, review.html) +│ ├── opencode-plugin/ # OpenCode plugin +│ │ ├── commands/ # Slash commands (plannotator-review.md) +│ │ ├── index.ts # Plugin entry with submit_plan tool + review event handler +│ │ ├── plannotator.html # Built plan review app +│ │ └── review-editor.html # Built code review app +│ └── review/ # Standalone review server (for development) +│ ├── index.html +│ ├── index.tsx +│ └── vite.config.ts ├── packages/ │ ├── server/ # Shared server implementation │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() +│ │ ├── review.ts # startReviewServer(), handleReviewServerReady() │ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.) │ │ ├── remote.ts # isRemoteSession(), getServerPort() │ │ ├── browser.ts # openBrowser() @@ -28,7 +36,12 @@ plannotator/ │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts │ │ ├── hooks/ # useSharing.ts │ │ └── types.ts -│ └── editor/ # Main App.tsx +│ ├── 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 ├── .claude-plugin/marketplace.json # For marketplace install └── legacy/ # Old pre-monorepo code (reference only) ``` @@ -63,7 +76,7 @@ export PLANNOTATOR_REMOTE=1 export PLANNOTATOR_PORT=9999 ``` -## Hook Flow +## Plan Review Flow ``` Claude calls ExitPlanMode @@ -80,8 +93,28 @@ Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}} Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}} ``` +## Code Review Flow + +``` +User runs /plannotator-review command + ↓ +Claude Code: plannotator review subcommand runs +OpenCode: event handler intercepts command + ↓ +git diff captures unstaged changes + ↓ +Review server starts, opens browser with diff viewer + ↓ +User annotates code, provides feedback + ↓ +Send Feedback → feedback sent to agent session +Approve → "LGTM" sent to agent session +``` + ## Server API +### Plan Server (`packages/server/index.ts`) + | Endpoint | Method | Purpose | | --------------------- | ------ | ------------------------------------------ | | `/api/plan` | GET | Returns `{ plan, origin }` | @@ -91,9 +124,16 @@ Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","messag | `/api/upload` | POST | Upload image, returns temp path | | `/api/obsidian/vaults`| GET | Detect available Obsidian vaults | -**Location:** `packages/server/index.ts` +### Review Server (`packages/server/review.ts`) -Both plugins use `startPlannotatorServer()` from `packages/server`. Port is random locally or fixed (`19432`) in remote mode. +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` | +| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) | +| `/api/image` | GET | Serve image by path query param | +| `/api/upload` | POST | Upload image, returns temp path | + +Both servers use random ports locally or fixed port (`19432`) in remote mode. ## Data Types @@ -213,7 +253,8 @@ Code blocks use bundled `highlight.js`. Language is extracted from fence (```rus bun install # Run any app -bun run dev:hook # Hook server +bun run dev:hook # Hook server (plan review) +bun run dev:review # Review editor (code review) bun run dev:portal # Portal editor bun run dev:marketing # Marketing site ``` @@ -222,8 +263,11 @@ bun run dev:marketing # Marketing site ```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:portal # Static build for share.plannotator.ai bun run build:marketing # Static build for plannotator.ai +bun run build # Build hook + opencode (main targets) ``` ## Test plugin locally diff --git a/apps/hook/commands/plannotator-review.md b/apps/hook/commands/plannotator-review.md new file mode 100644 index 00000000..a8d8dd69 --- /dev/null +++ b/apps/hook/commands/plannotator-review.md @@ -0,0 +1,12 @@ +--- +description: Open interactive code review for current changes +allowed-tools: Bash(plannotator:*) +--- + +## Code Review Feedback + +!`plannotator review` + +## Your task + +Address the code review feedback above. The user has reviewed your changes in the Plannotator UI and provided specific annotations and comments. diff --git a/apps/hook/package.json b/apps/hook/package.json index aa6b0eb2..ccf9c71d 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", + "build": "vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html", "serve": "bun run server/index.ts" }, "dependencies": { diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index fdfe6d15..88122ea5 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,88 +1,154 @@ /** - * Plannotator Ephemeral Server for Claude Code + * Plannotator CLI for Claude Code * - * Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions. - * Supports both local and remote sessions (SSH, devcontainer). + * Supports two modes: + * + * 1. Plan Review (default, no args): + * - Spawned by ExitPlanMode hook + * - Reads hook event from stdin, extracts plan content + * - Serves UI, returns approve/deny decision to stdout + * + * 2. Code Review (`plannotator review`): + * - Triggered by /review slash command + * - Runs git diff, opens review UI + * - Outputs feedback to stdout (captured by slash command) * * Environment variables: * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (preferred) * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) - * - * Reads hook event from stdin, extracts plan content, serves UI, returns decision. */ import { startPlannotatorServer, handleServerReady, } from "@plannotator/server"; +import { + startReviewServer, + handleReviewServerReady, +} from "@plannotator/server/review"; +import { getGitContext, runGitDiff } from "@plannotator/server/git"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text -import indexHtml from "../dist/index.html" with { type: "text" }; -const htmlContent = indexHtml as unknown as string; +import planHtml from "../dist/index.html" with { type: "text" }; +const planHtmlContent = planHtml as unknown as string; -// Read hook event from stdin -const eventJson = await Bun.stdin.text(); +// @ts-ignore - Bun import attribute for text +import reviewHtml from "../dist/review.html" with { type: "text" }; +const reviewHtmlContent = reviewHtml as unknown as string; -let planContent = ""; -try { - const event = JSON.parse(eventJson); - planContent = event.tool_input?.plan || ""; -} catch { - console.error("Failed to parse hook event from stdin"); - process.exit(1); -} +// Check for subcommand +const args = process.argv.slice(2); -if (!planContent) { - console.error("No plan content in hook event"); - process.exit(1); -} +if (args[0] === "review") { + // ============================================ + // CODE REVIEW MODE + // ============================================ -// Start the shared server -const origin = "claude-code"; + // Get git context (branches, available diff options) + const gitContext = await getGitContext(); -const server = await startPlannotatorServer({ - plan: planContent, - origin, - htmlContent, - onReady: (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - }, -}); - -// Wait for user decision (blocks until approve/deny) -const result = await server.waitForDecision(); - -// Give browser time to receive response and update UI -await Bun.sleep(1500); - -// Cleanup -server.stop(); - -// Output JSON for PermissionRequest hook decision control -if (result.approved) { - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PermissionRequest", - decision: { - behavior: "allow", - }, - }, - }) + // Run git diff HEAD (uncommitted changes - default) + const { patch: rawPatch, label: gitRef } = await runGitDiff( + "uncommitted", + gitContext.defaultBranch ); + + if (!rawPatch.trim()) { + console.log("No changes to review."); + process.exit(0); + } + + // Start review server + const server = await startReviewServer({ + rawPatch, + gitRef, + origin: "claude-code", + diffType: "uncommitted", + gitContext, + htmlContent: reviewHtmlContent, + onReady: handleReviewServerReady, + }); + + // Wait for user feedback + 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) + console.log(result.feedback || "No feedback provided."); + process.exit(0); + } else { - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PermissionRequest", - decision: { - behavior: "deny", - message: result.feedback || "Plan changes requested", - }, - }, - }) - ); -} + // ============================================ + // PLAN REVIEW MODE (default) + // ============================================ -process.exit(0); + // Read hook event from stdin + const eventJson = await Bun.stdin.text(); + + let planContent = ""; + try { + const event = JSON.parse(eventJson); + planContent = event.tool_input?.plan || ""; + } catch { + console.error("Failed to parse hook event from stdin"); + process.exit(1); + } + + if (!planContent) { + console.error("No plan content in hook event"); + process.exit(1); + } + + // Start the plan review server + const server = await startPlannotatorServer({ + plan: planContent, + origin: "claude-code", + htmlContent: planHtmlContent, + onReady: (url, isRemote, port) => { + handleServerReady(url, isRemote, port); + }, + }); + + // Wait for user decision (blocks until approve/deny) + const result = await server.waitForDecision(); + + // Give browser time to receive response and update UI + await Bun.sleep(1500); + + // Cleanup + server.stop(); + + // Output JSON for PermissionRequest hook decision control + if (result.approved) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + }, + }, + }) + ); + } else { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: result.feedback || "Plan changes requested", + }, + }, + }) + ); + } + + process.exit(0); +} diff --git a/apps/opencode-plugin/commands/plannotator-review.md b/apps/opencode-plugin/commands/plannotator-review.md new file mode 100644 index 00000000..4e040efd --- /dev/null +++ b/apps/opencode-plugin/commands/plannotator-review.md @@ -0,0 +1,6 @@ +--- +description: Open interactive code review for current changes +--- + +The Plannotator Code Review has been triggered. Opening the review UI... +Acknowledge "Opening code review..." and wait for the user's feedback. diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index f4f20f2a..d1ea87d6 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -17,11 +17,20 @@ import { startPlannotatorServer, handleServerReady, } from "@plannotator/server"; +import { + startReviewServer, + handleReviewServerReady, +} from "@plannotator/server/review"; +import { getGitContext, runGitDiff } from "@plannotator/server/git"; // @ts-ignore - Bun import attribute for text import indexHtml from "./plannotator.html" with { type: "text" }; const htmlContent = indexHtml as unknown as string; +// @ts-ignore - Bun import attribute for text +import reviewHtml from "./review-editor.html" with { type: "text" }; +const reviewHtmlContent = reviewHtml as unknown as string; + export const PlannotatorPlugin: Plugin = async (ctx) => { return { "experimental.chat.system.transform": async (_input, output) => { @@ -42,6 +51,100 @@ Do NOT proceed with implementation until your plan is approved. `); }, + // Listen for /plannotator-review command + event: async ({ event }) => { + // Check for command execution event + const isCommandEvent = + event.type === "command.executed" || + event.type === "tui.command.execute"; + + // @ts-ignore - Event structure: event.properties.name for command.executed + const commandName = event.properties?.name || event.command || event.payload?.name; + const isReviewCommand = commandName === "plannotator-review"; + + if (isCommandEvent && isReviewCommand) { + ctx.client.app.log({ + level: "info", + message: "Opening code review UI...", + }); + + // Get git context (branches, available diff options) + const gitContext = await getGitContext(); + + // Run git diff HEAD (uncommitted changes - default) + const { patch: rawPatch, label: gitRef } = await runGitDiff( + "uncommitted", + gitContext.defaultBranch + ); + + if (!rawPatch.trim()) { + ctx.client.app.log({ + level: "warn", + message: "No changes to review", + }); + return; + } + + const server = await startReviewServer({ + rawPatch, + gitRef, + origin: "opencode", + diffType: "uncommitted", + gitContext, + htmlContent: reviewHtmlContent, + onReady: handleReviewServerReady, + }); + + 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; + + // Only try to send feedback if we have a valid session ID + if (sessionId) { + // Check agent switch setting (defaults to 'build' if not set) + const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; + const targetAgent = result.agentSwitch || 'build'; + + if (shouldSwitchAgent) { + // Switch TUI display to target agent + try { + await ctx.client.tui.executeCommand({ + body: { command: "agent_cycle" }, + }); + } catch { + // Silently fail + } + } + + // Use noReply: true to ensure message is created before we return + // (same pattern as submit_plan tool to avoid race conditions) + try { + await ctx.client.session.prompt({ + path: { id: sessionId }, + body: { + ...(shouldSwitchAgent && { agent: targetAgent }), + noReply: true, + parts: [ + { + type: "text", + text: `# Code Review Feedback\n\n${result.feedback}`, + }, + ], + }, + }); + } catch { + // Session may not be available + } + } + } + } + }, + tool: { submit_plan: tool({ description: diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index dbf9e536..e6cc70d9 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -24,10 +24,12 @@ "main": "dist/index.js", "files": [ "dist", + "commands", "README.md" ], "scripts": { - "build": "cp ../hook/dist/index.html ./plannotator.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 && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", + "postinstall": "mkdir -p ~/.config/opencode/command && cp ./commands/*.md ~/.config/opencode/command/ 2>/dev/null || true", "prepublishOnly": "bun run build" }, "dependencies": { diff --git a/apps/review/index.html b/apps/review/index.html new file mode 100644 index 00000000..4809d0d1 --- /dev/null +++ b/apps/review/index.html @@ -0,0 +1,18 @@ + + + + + + Code Review + + + + + + + + +
+ + + diff --git a/apps/review/index.tsx b/apps/review/index.tsx new file mode 100644 index 00000000..ccd0f6d1 --- /dev/null +++ b/apps/review/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from '@plannotator/review-editor'; +import '@plannotator/review-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/review/package.json b/apps/review/package.json new file mode 100644 index 00000000..c37acc6b --- /dev/null +++ b/apps/review/package.json @@ -0,0 +1,28 @@ +{ + "name": "@plannotator/review", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "bun run server/index.ts" + }, + "dependencies": { + "@plannotator/review-editor": "workspace:*", + "@plannotator/server": "workspace:*", + "@plannotator/ui": "workspace:*", + "@pierre/diffs": "^1.0.4", + "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/review/server/index.ts b/apps/review/server/index.ts new file mode 100644 index 00000000..90c9855e --- /dev/null +++ b/apps/review/server/index.ts @@ -0,0 +1,101 @@ +/** + * Code Review Ephemeral Server + * + * Spawned to serve the code review UI for git diffs. + * Supports both local and remote sessions. + * + * Usage: + * bun apps/review/server/index.ts # Unstaged changes (git diff) + * bun apps/review/server/index.ts --staged # Staged changes + * bun apps/review/server/index.ts main # Diff against ref + * bun apps/review/server/index.ts HEAD~5..HEAD # Commit range + * + * Environment variables: + * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode + * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) + */ + +import { $ } from "bun"; +import { + startReviewServer, + handleReviewServerReady, +} from "@plannotator/server/review"; + +// Embed the built HTML at compile time +// @ts-ignore - Bun import attribute for text +import indexHtml from "../dist/index.html" with { type: "text" }; +const htmlContent = indexHtml as unknown as string; + +// Parse CLI arguments +const args = process.argv.slice(2); +const isStaged = args.includes("--staged"); +const gitRef = args.filter((arg) => arg !== "--staged").join(" ").trim(); + +// Build git diff command +let diffCommand: string[]; +if (isStaged) { + diffCommand = ["git", "diff", "--staged"]; +} else if (gitRef) { + diffCommand = ["git", "diff", gitRef]; +} else { + diffCommand = ["git", "diff"]; +} + +// Execute git diff +let rawPatch = ""; +try { + const result = await $`${diffCommand}`.quiet(); + rawPatch = result.text(); +} catch (err) { + console.error("Failed to get git diff:", err); + process.exit(1); +} + +if (!rawPatch.trim()) { + console.error("No changes to review"); + process.exit(0); +} + +// Determine display ref for UI +let displayRef: string; +if (isStaged) { + displayRef = "--staged"; +} else if (gitRef) { + displayRef = gitRef; +} else { + displayRef = "working tree"; +} + +// Start the review server +const server = await startReviewServer({ + rawPatch, + gitRef: displayRef, + htmlContent, + onReady: (url, isRemote, port) => { + handleReviewServerReady(url, isRemote, port); + console.error(`Code review at ${url}`); + if (isRemote) { + console.error(`(Remote mode - manually open the URL above)`); + } + }, +}); + +// Wait for user feedback submission +const result = await server.waitForDecision(); + +// Give browser time to receive response and update UI +await Bun.sleep(500); + +// Cleanup +server.stop(); + +// Output the feedback as JSON +console.log( + JSON.stringify({ + gitRef: displayRef, + feedback: result.feedback, + annotations: result.annotations, + }, null, 2) +); + +process.exit(0); diff --git a/apps/review/tsconfig.json b/apps/review/tsconfig.json new file mode 100644 index 00000000..ac2688ea --- /dev/null +++ b/apps/review/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": ["./*"], + "@plannotator/ui/*": ["../../packages/ui/*"], + "@plannotator/review-editor": ["../../packages/review-editor/App.tsx"], + "@plannotator/review-editor/*": ["../../packages/review-editor/*"] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/apps/review/vite.config.ts b/apps/review/vite.config.ts new file mode 100644 index 00000000..72bedcb6 --- /dev/null +++ b/apps/review/vite.config.ts @@ -0,0 +1,36 @@ +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: 3001, + 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/review-editor/styles': path.resolve(__dirname, '../../packages/review-editor/index.css'), + '@plannotator/review-editor': path.resolve(__dirname, '../../packages/review-editor/App.tsx'), + } + }, + build: { + target: 'esnext', + assetsInlineLimit: 100000000, + chunkSizeWarningLimit: 100000000, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/bun.lock b/bun.lock index d5dd6337..13ff9725 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,9 @@ "workspaces": { "": { "name": "plannotator", + "dependencies": { + "@pierre/diffs": "^1.0.4", + }, }, "apps/hook": { "name": "@plannotator/hooks", @@ -44,7 +47,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.4.2", + "version": "0.4.16", "dependencies": { "@opencode-ai/plugin": "^1.0.218", }, @@ -70,6 +73,27 @@ "vite": "^6.2.0", }, }, + "apps/review": { + "name": "@plannotator/review", + "version": "0.0.1", + "dependencies": { + "@pierre/diffs": "^1.0.4", + "@plannotator/review-editor": "workspace:*", + "@plannotator/server": "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", + }, + }, "packages/editor": { "name": "@plannotator/editor", "version": "0.0.1", @@ -80,9 +104,20 @@ "tailwindcss": "^4.1.18", }, }, + "packages/review-editor": { + "name": "@plannotator/review-editor", + "version": "0.0.1", + "dependencies": { + "@pierre/diffs": "^1.0.4", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + }, + }, "packages/server": { "name": "@plannotator/server", - "version": "0.4.2", + "version": "0.4.16", "peerDependencies": { "bun": ">=1.0.0", }, @@ -227,6 +262,8 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="], + "@pierre/diffs": ["@pierre/diffs@1.0.4", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-jH0uzP3syMFcz0rp4uDDGTkV8sbMeh0W8Gq+e3P1rIiJ+gdGISfG+Qhwpe//W2W5Ac9G5vBAbMflywz+lLaJlQ=="], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], @@ -237,6 +274,10 @@ "@plannotator/portal": ["@plannotator/portal@workspace:apps/portal"], + "@plannotator/review": ["@plannotator/review@workspace:apps/review"], + + "@plannotator/review-editor": ["@plannotator/review-editor@workspace:packages/review-editor"], + "@plannotator/server": ["@plannotator/server@workspace:packages/server"], "@plannotator/ui": ["@plannotator/ui@workspace:packages/ui"], @@ -287,6 +328,22 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="], + "@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], + + "@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], + + "@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="], + + "@shikijs/transformers": ["@shikijs/transformers@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/types": "3.21.0" } }, "sha512-CZwvCWWIiRRiFk9/JKzdEooakAP8mQDtBOQ1TKiCaS2E1bYtyBCOkUzS8akO34/7ufICQ29oeSfkb3tT5KtrhA=="], + + "@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -327,8 +384,16 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], @@ -341,12 +406,26 @@ "caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], @@ -365,8 +444,14 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -403,8 +488,22 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -413,6 +512,10 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "perfect-freehand": ["perfect-freehand@1.2.2", "", {}, "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -421,20 +524,34 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -443,14 +560,30 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unique-username-generator": ["unique-username-generator@1.5.1", "", { "bin": { "usernamegen": "dist/cli.js", "usergen": "dist/cli.js", "unique-username": "dist/cli.js", "uuname": "dist/cli.js" } }, "sha512-Q0pSKPyij4L7Tm6Bo3XsWeFG9qbyWTtwb3jTN+XgGhCFdlvQn6Fj0DCfYElw0kp/Xp7Jv1Q+CL+aA8S07RMChA=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.0", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.44.1", "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" } }, "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A=="], @@ -461,6 +594,8 @@ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], diff --git a/package.json b/package.json index c1677e7c..4176679d 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,16 @@ "dev:hook": "bun run --cwd apps/hook dev", "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", + "dev:review": "bun run --cwd apps/review 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": "bun run build:hook && bun run build:opencode", "test": "bun test" + }, + "dependencies": { + "@pierre/diffs": "^1.0.4" } } diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c28ae468..06f8690d 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -604,12 +604,12 @@ const App: React.FC = () => { ? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground' : 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30' }`} - title="Deny with Feedback" + title="Send Feedback" > - {isSubmitting ? 'Sending...' : 'Deny with Feedback'} + {isSubmitting ? 'Sending...' : 'Send Feedback'}
@@ -748,7 +748,7 @@ const App: React.FC = () => { message={<>{agentName} doesn't yet support feedback on approval. Your {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} will be lost.} subMessage={ <> - To send feedback, use Deny with Feedback instead. + To send feedback, use Send Feedback instead.

Want this feature? Upvote these issues:
@@ -807,7 +807,7 @@ const App: React.FC = () => { )} {/* Update notification */} - + {/* Image Annotator for pasted images */} (); + for (const ann of annotations) { + const existing = grouped.get(ann.filePath) || []; + existing.push(ann); + grouped.set(ann.filePath, existing); + } + + let output = '# Code Review Feedback\n\n'; + + for (const [filePath, fileAnnotations] of grouped) { + output += `## ${filePath}\n\n`; + + const sorted = [...fileAnnotations].sort((a, b) => a.lineStart - b.lineStart); + + for (let i = 0; i < sorted.length; i++) { + const ann = sorted[i]; + const lineRange = ann.lineStart === ann.lineEnd + ? `Line ${ann.lineStart}` + : `Lines ${ann.lineStart}-${ann.lineEnd}`; + + output += `### ${lineRange} (${ann.side})\n`; + + if (ann.text) { + output += `${ann.text}\n`; + } + + if (ann.suggestedCode) { + output += `\n**Suggested code:**\n\`\`\`\n${ann.suggestedCode}\n\`\`\`\n`; + } + + output += '\n'; + } + } + + return output; +} + +const ReviewApp: React.FC = () => { + const [diffData, setDiffData] = useState(null); + const [files, setFiles] = useState([]); + const [activeFileIndex, setActiveFileIndex] = useState(0); + const [annotations, setAnnotations] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [pendingSelection, setPendingSelection] = useState(null); + const [showExportModal, setShowExportModal] = useState(false); + const [showNoAnnotationsDialog, setShowNoAnnotationsDialog] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>(() => { + return (storage.getItem('review-diff-style') as 'split' | 'unified') || 'split'; + }); + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [copyFeedback, setCopyFeedback] = useState(null); + const [viewedFiles, setViewedFiles] = useState>(new Set()); + const [origin, setOrigin] = useState<'opencode' | 'claude-code' | null>(null); + const [diffType, setDiffType] = useState('uncommitted'); + const [gitContext, setGitContext] = useState(null); + const [isLoadingDiff, setIsLoadingDiff] = useState(false); + const [isSendingFeedback, setIsSendingFeedback] = useState(false); + const [isApproving, setIsApproving] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false); + const [showApproveWarning, setShowApproveWarning] = useState(false); + + const identity = useMemo(() => getIdentity(), []); + + // Mark file as viewed when it becomes active + useEffect(() => { + const activeFile = files[activeFileIndex]; + if (activeFile && !viewedFiles.has(activeFile.path)) { + setViewedFiles(prev => new Set([...prev, activeFile.path])); + } + }, [activeFileIndex, files]); + + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Escape closes modals + if (e.key === 'Escape') { + if (showExportModal) { + setShowExportModal(false); + } + } + // Cmd/Ctrl+Shift+C to copy diff + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') { + e.preventDefault(); + handleCopyDiff(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showExportModal]); + + // Get annotations for active file + const activeFileAnnotations = useMemo(() => { + const activeFile = files[activeFileIndex]; + if (!activeFile) return []; + return annotations.filter(a => a.filePath === activeFile.path); + }, [annotations, files, activeFileIndex]); + + // Load diff content - try API first, fall back to demo + useEffect(() => { + fetch('/api/diff') + .then(res => { + if (!res.ok) throw new Error('Not in API mode'); + return res.json(); + }) + .then((data: { + rawPatch: string; + gitRef: string; + origin?: 'opencode' | 'claude-code'; + diffType?: string; + gitContext?: GitContext; + }) => { + const apiFiles = parseDiffToFiles(data.rawPatch); + setDiffData({ + files: apiFiles, + rawPatch: data.rawPatch, + gitRef: data.gitRef, + origin: data.origin, + diffType: data.diffType, + gitContext: data.gitContext, + }); + setFiles(apiFiles); + if (data.origin) setOrigin(data.origin); + if (data.diffType) setDiffType(data.diffType); + if (data.gitContext) setGitContext(data.gitContext); + }) + .catch(() => { + // Not in API mode - use demo content + const demoFiles = parseDiffToFiles(DEMO_DIFF); + setDiffData({ + files: demoFiles, + rawPatch: DEMO_DIFF, + gitRef: 'demo', + }); + setFiles(demoFiles); + }) + .finally(() => setIsLoading(false)); + }, []); + + // Handle diff style change + const handleDiffStyleChange = useCallback((style: 'split' | 'unified') => { + setDiffStyle(style); + storage.setItem('review-diff-style', style); + }, []); + + // Handle line selection from diff viewer + const handleLineSelection = useCallback((range: SelectedLineRange | null) => { + setPendingSelection(range); + }, []); + + // Add annotation + const handleAddAnnotation = useCallback(( + type: CodeAnnotationType, + text?: string, + suggestedCode?: string + ) => { + if (!pendingSelection || !files[activeFileIndex]) return; + + // Normalize line range (in case user selected bottom-to-top) + const lineStart = Math.min(pendingSelection.start, pendingSelection.end); + const lineEnd = Math.max(pendingSelection.start, pendingSelection.end); + + const newAnnotation: CodeAnnotation = { + id: generateId(), + type, + filePath: files[activeFileIndex].path, + lineStart, + lineEnd, + side: pendingSelection.side === 'additions' ? 'new' : 'old', + text, + suggestedCode, + createdAt: Date.now(), + author: identity, + }; + + setAnnotations(prev => [...prev, newAnnotation]); + setPendingSelection(null); + }, [pendingSelection, files, activeFileIndex, identity]); + + // Delete annotation + const handleDeleteAnnotation = useCallback((id: string) => { + setAnnotations(prev => prev.filter(a => a.id !== id)); + if (selectedAnnotationId === id) { + setSelectedAnnotationId(null); + } + }, [selectedAnnotationId]); + + // Handle identity change - update author on existing annotations + const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { + setAnnotations(prev => prev.map(ann => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann + )); + }, []); + + // Switch file - clears pending selection to avoid invalid line ranges + const handleFileSwitch = useCallback((index: number) => { + if (index !== activeFileIndex) { + setPendingSelection(null); // Clear selection when switching files + setActiveFileIndex(index); + } + }, [activeFileIndex]); + + // Switch diff type (uncommitted, staged, last-commit, branch, etc.) + const handleDiffSwitch = useCallback(async (newDiffType: string) => { + if (newDiffType === diffType || newDiffType === 'separator') return; + + setIsLoadingDiff(true); + try { + const res = await fetch('/api/diff/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ diffType: newDiffType }), + }); + + if (!res.ok) throw new Error('Failed to switch diff'); + + const data = await res.json() as { + rawPatch: string; + gitRef: string; + diffType: string; + }; + + const newFiles = parseDiffToFiles(data.rawPatch); + setFiles(newFiles); + setDiffType(data.diffType); + setActiveFileIndex(0); + setPendingSelection(null); + // Note: We keep existing annotations - they may still be relevant + // or user can clear them manually + } catch (err) { + console.error('Failed to switch diff:', err); + } finally { + setIsLoadingDiff(false); + } + }, [diffType]); + + // Select annotation - switches file if needed and scrolls to it + const handleSelectAnnotation = useCallback((id: string | null) => { + if (!id) { + setSelectedAnnotationId(null); + return; + } + + // Find the annotation + const annotation = annotations.find(a => a.id === id); + if (!annotation) { + setSelectedAnnotationId(id); + return; + } + + // Find and switch to the file containing this annotation + const fileIndex = files.findIndex(f => f.path === annotation.filePath); + if (fileIndex !== -1 && fileIndex !== activeFileIndex) { + handleFileSwitch(fileIndex); + } + + setSelectedAnnotationId(id); + }, [annotations, files, activeFileIndex, handleFileSwitch]); + + // Copy raw diff to clipboard + const handleCopyDiff = useCallback(async () => { + if (!diffData) return; + try { + await navigator.clipboard.writeText(diffData.rawPatch); + setCopyFeedback('Diff copied!'); + setTimeout(() => setCopyFeedback(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + setCopyFeedback('Failed to copy'); + setTimeout(() => setCopyFeedback(null), 2000); + } + }, [diffData]); + + // Copy feedback markdown to clipboard + const handleCopyFeedback = useCallback(async () => { + if (annotations.length === 0) { + setShowNoAnnotationsDialog(true); + return; + } + try { + const feedback = exportReviewFeedback(annotations, files); + await navigator.clipboard.writeText(feedback); + setCopyFeedback('Feedback copied!'); + setTimeout(() => setCopyFeedback(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + setCopyFeedback('Failed to copy'); + setTimeout(() => setCopyFeedback(null), 2000); + } + }, [annotations, files]); + + // Send feedback to OpenCode via API + const handleSendFeedback = useCallback(async () => { + if (annotations.length === 0) { + setShowNoAnnotationsDialog(true); + return; + } + setIsSendingFeedback(true); + try { + const feedback = exportReviewFeedback(annotations, files); + const agentSwitchSettings = getAgentSwitchSettings(); + const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); + + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback, + annotations, + ...(effectiveAgent && { agentSwitch: effectiveAgent }), + }), + }); + if (res.ok) { + setSubmitted('feedback'); + } else { + throw new Error('Failed to send'); + } + } catch (err) { + console.error('Failed to send feedback:', err); + setCopyFeedback('Failed to send'); + setTimeout(() => setCopyFeedback(null), 2000); + setIsSendingFeedback(false); + } + }, [annotations, files]); + + // Approve without feedback (LGTM) + const handleApprove = useCallback(async () => { + setIsApproving(true); + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: 'LGTM - no changes requested.', + annotations: [], + }), + }); + if (res.ok) { + setSubmitted('approved'); + } else { + throw new Error('Failed to send'); + } + } catch (err) { + console.error('Failed to approve:', err); + setCopyFeedback('Failed to send'); + setTimeout(() => setCopyFeedback(null), 2000); + setIsApproving(false); + } + }, []); + + const activeFile = files[activeFileIndex]; + const feedbackMarkdown = useMemo(() => + exportReviewFeedback(annotations, files), + [annotations, files] + ); + + if (isLoading) { + return ( + +
+
Loading diff...
+
+
+ ); + } + + return ( + +
+ {/* Header */} +
+ + +
+ {/* Diff style toggle */} +
+ + +
+ + {/* Primary actions */} + + + {origin ? ( + <> + {/* Send Feedback button - accent color, disabled if no annotations */} + + + {/* Approve button - green/success, dimmed if annotations exist */} +
+ + {annotations.length > 0 && ( +
+
+
+ Your {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} won't be sent if you approve. +
+ )} +
+ + ) : ( + + )} + +
+ + {/* Utilities */} + + {}} + onIdentityChange={handleIdentityChange} + origin={origin} + mode="review" + /> + + {/* Panel toggle */} + + + {/* Export */} + +
+
+ + {/* Main content */} +
+ {/* File tree sidebar */} + {files.length > 1 && ( + + )} + + {/* Diff viewer */} +
+ {activeFile ? ( + + ) : ( +
+
+
+ + + +
+
+

No changes

+

+ {diffType === 'uncommitted' && "No uncommitted changes to review."} + {diffType === 'staged' && "No staged changes. Stage some files with git add."} + {diffType === 'unstaged' && "No unstaged changes. All changes are staged."} + {diffType === 'last-commit' && "No changes in the last commit."} + {diffType === 'branch' && `No changes between this branch and ${gitContext?.defaultBranch || 'main'}.`} +

+
+ {gitContext?.diffOptions && gitContext.diffOptions.length > 1 && ( +

+ Try selecting a different view from the dropdown. +

+ )} +
+
+ )} +
+ + {/* Annotations panel */} + setIsPanelOpen(!isPanelOpen)} + annotations={annotations} + files={files} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onDeleteAnnotation={handleDeleteAnnotation} + feedbackMarkdown={feedbackMarkdown} + /> +
+ + {/* Export Modal */} + {showExportModal && ( +
+
+
+

Export Review Feedback

+ +
+
+
+ {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} +
+
+                  {feedbackMarkdown}
+                
+
+
+ +
+
+
+ )} + + {/* No annotations dialog */} + setShowNoAnnotationsDialog(false)} + title="No Annotations" + message="You haven't made any annotations yet. There's nothing to copy." + variant="info" + /> + + {/* Approve with annotations warning */} + setShowApproveWarning(false)} + onConfirm={() => { + setShowApproveWarning(false); + handleApprove(); + }} + title="Annotations Won't Be Sent" + message={<>You have {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} that will be lost if you approve.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Approve Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + {/* Completion overlay - shown after approve/feedback */} + {submitted && ( +
+
+
+ {submitted === 'approved' ? ( + + + + ) : ( + + + + )} +
+ +
+

+ {submitted === 'approved' ? 'Changes Approved' : 'Feedback Sent'} +

+

+ {submitted === 'approved' + ? `${origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} will proceed with the changes.` + : `${origin === 'claude-code' ? 'Claude Code' : 'OpenCode'} will address your review feedback.`} +

+
+ +
+

+ You can close this tab and return to {origin === 'claude-code' ? 'Claude Code' : 'OpenCode'}. +

+

+ Your response has been sent. +

+
+
+
+ )} +
+
+ ); +}; + +export default ReviewApp; diff --git a/packages/review-editor/components/DiffViewer.tsx b/packages/review-editor/components/DiffViewer.tsx new file mode 100644 index 00000000..b763901d --- /dev/null +++ b/packages/review-editor/components/DiffViewer.tsx @@ -0,0 +1,348 @@ +import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { PatchDiff } from '@pierre/diffs/react'; +import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata } from '@plannotator/ui/types'; +import { useTheme } from '@plannotator/ui/components/ThemeProvider'; + +interface DiffViewerProps { + patch: string; + filePath: string; + diffStyle: 'split' | 'unified'; + annotations: CodeAnnotation[]; + selectedAnnotationId: string | null; + pendingSelection: SelectedLineRange | null; + onLineSelection: (range: SelectedLineRange | null) => void; + onAddAnnotation: (type: CodeAnnotationType, text?: string, suggestedCode?: string) => void; + onSelectAnnotation: (id: string | null) => void; + onDeleteAnnotation: (id: string) => void; +} + +interface ToolbarState { + position: { top: number; left: number }; + range: SelectedLineRange; +} + +export const DiffViewer: React.FC = ({ + patch, + filePath, + diffStyle, + annotations, + selectedAnnotationId, + pendingSelection, + onLineSelection, + onAddAnnotation, + onSelectAnnotation, + onDeleteAnnotation, +}) => { + const { theme } = useTheme(); + const containerRef = useRef(null); + const [toolbarState, setToolbarState] = useState(null); + const [commentText, setCommentText] = useState(''); + const [suggestedCode, setSuggestedCode] = useState(''); + const [showSuggestedCode, setShowSuggestedCode] = useState(false); + const [copied, setCopied] = useState(false); + const lastMousePosition = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + // Track mouse position continuously for toolbar placement + const handleMouseMove = useCallback((e: React.MouseEvent) => { + lastMousePosition.current = { x: e.clientX, y: e.clientY }; + }, []); + + // Clear pending selection when file changes + const prevFilePathRef = useRef(filePath); + useEffect(() => { + if (prevFilePathRef.current !== filePath) { + prevFilePathRef.current = filePath; + onLineSelection(null); // Clear selection when switching files + } + }, [filePath, onLineSelection]); + + // Scroll to selected annotation when it changes + useEffect(() => { + if (!selectedAnnotationId || !containerRef.current) return; + + // Small delay to allow render after file switch + const timeoutId = setTimeout(() => { + const annotationEl = containerRef.current?.querySelector( + `[data-annotation-id="${selectedAnnotationId}"]` + ); + if (annotationEl) { + annotationEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + + return () => clearTimeout(timeoutId); + }, [selectedAnnotationId]); + + // Map annotations to @pierre/diffs format + // Place annotation under the last line of the range (GitHub-style) + const lineAnnotations = useMemo(() => { + return annotations.map(ann => ({ + side: ann.side === 'new' ? 'additions' : 'deletions' as const, + lineNumber: ann.lineEnd, + metadata: { + annotationId: ann.id, + type: ann.type, + text: ann.text, + suggestedCode: ann.suggestedCode, + author: ann.author, + } as DiffAnnotationMetadata, + })); + }, [annotations]); + + // Handle line selection end + const handleLineSelectionEnd = useCallback((range: SelectedLineRange | null) => { + if (!range || !containerRef.current) { + setToolbarState(null); + onLineSelection(null); + return; + } + + // Position toolbar near where user clicked/released + const mousePos = lastMousePosition.current; + + setToolbarState({ + position: { + top: mousePos.y + 10, // Just below the click + left: mousePos.x, + }, + range, + }); + onLineSelection(range); + }, [onLineSelection]); + + // Handle annotation submission + const handleSubmitAnnotation = useCallback(() => { + if (!toolbarState || !commentText.trim()) return; + + onAddAnnotation( + 'comment', + commentText, + suggestedCode.trim() || undefined + ); + + // Reset state + setToolbarState(null); + setCommentText(''); + setSuggestedCode(''); + setShowSuggestedCode(false); + }, [toolbarState, commentText, suggestedCode, onAddAnnotation]); + + // Handle cancel + const handleCancel = useCallback(() => { + setToolbarState(null); + setCommentText(''); + setSuggestedCode(''); + setShowSuggestedCode(false); + onLineSelection(null); + }, [onLineSelection]); + + // Render annotation in diff - returns React element + const renderAnnotation = useCallback((annotation: { side: string; lineNumber: number; metadata?: DiffAnnotationMetadata }) => { + if (!annotation.metadata) return null; + + const meta = annotation.metadata; + + return ( +
onSelectAnnotation(meta.annotationId)} + > +
+ {meta.author && {meta.author}} + +
+ {meta.text && ( +
{meta.text}
+ )} + {meta.suggestedCode && ( +
+
Suggested:
+
{meta.suggestedCode}
+
+ )} +
+ ); + }, [onSelectAnnotation, onDeleteAnnotation]); + + // Render hover utility (+ button) - returns React element + const renderHoverUtility = useCallback((getHoveredLine: () => { lineNumber: number; side: 'deletions' | 'additions' } | undefined) => { + const line = getHoveredLine(); + if (!line) return null; + + return ( + + ); + }, [handleLineSelectionEnd]); + + // Determine theme for @pierre/diffs + const pierreTheme = useMemo(() => { + const effectiveTheme = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') + : theme; + return effectiveTheme === 'light' ? 'pierre-light' : 'pierre-dark'; + }, [theme]); + + return ( +
+ {/* File header */} +
+ {filePath} + +
+ + {/* Diff content */} +
+ +
+ + {/* Annotation toolbar - single-step comment input */} + {toolbarState && ( +
+
+
+ + {toolbarState.range.start === toolbarState.range.end + ? `Line ${toolbarState.range.start}` + : `Lines ${Math.min(toolbarState.range.start, toolbarState.range.end)}-${Math.max(toolbarState.range.start, toolbarState.range.end)}`} + + +
+ +