From 92d4f73b6634620ee18f9d53c0ee33e31783de89 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 27 Dec 2025 18:14:10 -0800 Subject: [PATCH] Initial commit: Plannotator - Plan review UI for Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A React-based annotation tool that intercepts Claude Code's ExitPlanMode to provide visual plan review with collaborative features. Features: - Markdown plan rendering with syntax highlighting - Text selection annotations (delete, replace, insert, comment) - Redline mode for quick deletion marking - URL-based sharing via compressed hash (no server storage) - Tater identity system for anonymous collaboration - Export to diff format - Dark/light theme support - Tater sprite easter egg 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 24 + App.tsx | 573 +++++++ CLAUDE.md | 240 +++ README.md | 20 + components/AnnotationPanel.tsx | 235 +++ components/AnnotationSidebar.tsx | 88 ++ components/DecisionBar.tsx | 131 ++ components/ExportModal.tsx | 190 +++ components/Landing.tsx | 273 ++++ components/ModeSwitcher.tsx | 112 ++ components/ModeToggle.tsx | 68 + components/Settings.tsx | 117 ++ components/TaterSpritePullup.tsx | 33 + components/TaterSpriteRunning.tsx | 53 + components/TaterSpriteSitting.tsx | 36 + components/ThemeProvider.tsx | 87 ++ components/Toolbar.tsx | 168 ++ components/Viewer.tsx | 747 +++++++++ hooks/exit-plan-mode.sh | 39 + hooks/useSharing.ts | 141 ++ index.css | 213 +++ index.html | 33 + index.tsx | 15 + install.sh | 129 ++ metadata.json | 5 + package-lock.json | 2383 +++++++++++++++++++++++++++++ package.json | 26 + plannotator.webp | Bin 0 -> 52706 bytes server/index.ts | 138 ++ services/geminiService.ts | 4 + tsconfig.json | 29 + types.ts | 46 + utils/identity.ts | 79 + utils/parser.ts | 184 +++ utils/sharing.ts | 168 ++ vite-env.d.ts | 11 + vite.config.ts | 36 + 37 files changed, 6874 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 components/AnnotationPanel.tsx create mode 100644 components/AnnotationSidebar.tsx create mode 100644 components/DecisionBar.tsx create mode 100644 components/ExportModal.tsx create mode 100644 components/Landing.tsx create mode 100644 components/ModeSwitcher.tsx create mode 100644 components/ModeToggle.tsx create mode 100644 components/Settings.tsx create mode 100644 components/TaterSpritePullup.tsx create mode 100644 components/TaterSpriteRunning.tsx create mode 100644 components/TaterSpriteSitting.tsx create mode 100644 components/ThemeProvider.tsx create mode 100644 components/Toolbar.tsx create mode 100644 components/Viewer.tsx create mode 100755 hooks/exit-plan-mode.sh create mode 100644 hooks/useSharing.ts create mode 100644 index.css create mode 100644 index.html create mode 100644 index.tsx create mode 100755 install.sh create mode 100644 metadata.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plannotator.webp create mode 100644 server/index.ts create mode 100644 services/geminiService.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils/identity.ts create mode 100644 utils/parser.ts create mode 100644 utils/sharing.ts create mode 100644 vite-env.d.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 00000000..b6f3853f --- /dev/null +++ b/App.tsx @@ -0,0 +1,573 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { parseMarkdownToBlocks, exportDiff } from './utils/parser'; +import { Viewer, ViewerHandle } from './components/Viewer'; +import { AnnotationPanel } from './components/AnnotationPanel'; +import { Landing } from './components/Landing'; +import { ExportModal } from './components/ExportModal'; +import { Annotation, Block, AnnotationType, EditorMode } from './types'; +import { ThemeProvider } from './components/ThemeProvider'; +import { ModeToggle } from './components/ModeToggle'; +import { ModeSwitcher } from './components/ModeSwitcher'; +import { TaterSpriteRunning } from './components/TaterSpriteRunning'; +import { TaterSpritePullup } from './components/TaterSpritePullup'; +import { Settings } from './components/Settings'; +import { useSharing } from './hooks/useSharing'; + +const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration + +## Overview +Add real-time collaboration features to the editor using WebSocket connections and operational transforms. + +## Phase 1: Infrastructure + +### WebSocket Server +Set up a WebSocket server to handle concurrent connections: + +\`\`\`typescript +const server = new WebSocketServer({ port: 8080 }); + +server.on('connection', (socket, request) => { + const sessionId = generateSessionId(); + sessions.set(sessionId, socket); + + socket.on('message', (data) => { + broadcast(sessionId, data); + }); +}); +\`\`\` + +### Client Connection +- Establish persistent connection on document load +- Implement reconnection logic with exponential backoff +- Handle offline state gracefully + +### Database Schema + +\`\`\`sql +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE collaborators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID REFERENCES documents(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + role VARCHAR(50) DEFAULT 'editor', + cursor_position JSONB, + last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_collaborators_document ON collaborators(document_id); +\`\`\` + +## Phase 2: Operational Transforms + +> The key insight is that we need to transform operations against concurrent operations to maintain consistency. + +Key requirements: +- Transform insert against insert +- Transform insert against delete +- Transform delete against delete +- Maintain cursor positions across transforms + +### Transform Implementation + +\`\`\`typescript +interface Operation { + type: 'insert' | 'delete'; + position: number; + content?: string; + length?: number; + userId: string; + timestamp: number; +} + +class OperationalTransform { + private pendingOps: Operation[] = []; + private history: Operation[] = []; + + transform(op1: Operation, op2: Operation): [Operation, Operation] { + if (op1.type === 'insert' && op2.type === 'insert') { + if (op1.position <= op2.position) { + return [op1, { ...op2, position: op2.position + (op1.content?.length || 0) }]; + } else { + return [{ ...op1, position: op1.position + (op2.content?.length || 0) }, op2]; + } + } + + if (op1.type === 'delete' && op2.type === 'delete') { + // Complex delete vs delete transformation + const op1End = op1.position + (op1.length || 0); + const op2End = op2.position + (op2.length || 0); + + if (op1End <= op2.position) { + return [op1, { ...op2, position: op2.position - (op1.length || 0) }]; + } + // ... more cases + } + + return [op1, op2]; + } + + apply(doc: string, op: Operation): string { + if (op.type === 'insert') { + return doc.slice(0, op.position) + op.content + doc.slice(op.position); + } else { + return doc.slice(0, op.position) + doc.slice(op.position + (op.length || 0)); + } + } +} +\`\`\` + +## Phase 3: UI Updates + +1. Show collaborator cursors in real-time +2. Display presence indicators +3. Add conflict resolution UI +4. Implement undo/redo stack per user + +### React Component for Cursors + +\`\`\`tsx +import React, { useEffect, useState } from 'react'; +import { useCollaboration } from '../hooks/useCollaboration'; + +interface CursorOverlayProps { + documentId: string; + containerRef: React.RefObject; +} + +export const CursorOverlay: React.FC = ({ + documentId, + containerRef +}) => { + const { collaborators, currentUser } = useCollaboration(documentId); + const [positions, setPositions] = useState>(new Map()); + + useEffect(() => { + const updatePositions = () => { + const newPositions = new Map(); + collaborators.forEach(collab => { + if (collab.userId !== currentUser.id && collab.cursorPosition) { + const rect = getCursorRect(containerRef.current, collab.cursorPosition); + if (rect) newPositions.set(collab.userId, rect); + } + }); + setPositions(newPositions); + }; + + const interval = setInterval(updatePositions, 50); + return () => clearInterval(interval); + }, [collaborators, currentUser, containerRef]); + + return ( + <> + {Array.from(positions.entries()).map(([userId, rect]) => ( +
+
+
+ {collaborators.find(c => c.userId === userId)?.userName} +
+
+ ))} + + ); +}; +\`\`\` + +### Configuration + +\`\`\`json +{ + "collaboration": { + "enabled": true, + "maxCollaborators": 10, + "cursorColors": ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"], + "syncInterval": 100, + "reconnect": { + "maxAttempts": 5, + "backoffMultiplier": 1.5, + "initialDelay": 1000 + } + } +} +\`\`\` + +--- + +**Target:** Ship MVP in next sprint +`; + +const App: React.FC = () => { + const [page, setPage] = useState<'landing' | 'editor'>(() => { + const path = window.location.pathname; + const hasShareHash = window.location.hash.length > 1; + // Go directly to editor if path indicates or if we have a share hash + return path === '/editor' || path === '/app' || hasShareHash ? 'editor' : 'landing'; + }); + const [markdown, setMarkdown] = useState(PLAN_CONTENT); + const [annotations, setAnnotations] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [blocks, setBlocks] = useState([]); + const [showExport, setShowExport] = useState(false); + const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [editorMode, setEditorMode] = useState('selection'); + const [taterMode, setTaterMode] = useState(() => { + const stored = localStorage.getItem('plannotator-tater-mode'); + return stored === 'true'; + }); + const [isApiMode, setIsApiMode] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); + const viewerRef = useRef(null); + + // URL-based sharing + const { + isSharedSession, + isLoadingShared, + shareUrl, + shareUrlSize, + pendingSharedAnnotations, + clearPendingSharedAnnotations, + } = useSharing( + markdown, + annotations, + setMarkdown, + setAnnotations, + () => { + // When loaded from share, go directly to editor + setPage('editor'); + setIsLoading(false); + } + ); + + // Apply shared annotations to DOM after they're loaded + useEffect(() => { + if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) { + // Small delay to ensure DOM is rendered + const timer = setTimeout(() => { + // Clear existing highlights first (important when loading new share URL) + viewerRef.current?.clearAllHighlights(); + viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations); + clearPendingSharedAnnotations(); + }, 100); + return () => clearTimeout(timer); + } + }, [pendingSharedAnnotations, clearPendingSharedAnnotations]); + + const goToEditor = () => { + window.history.pushState({}, '', '/editor'); + setPage('editor'); + }; + + const handleTaterModeChange = (enabled: boolean) => { + setTaterMode(enabled); + localStorage.setItem('plannotator-tater-mode', String(enabled)); + }; + + // Check if we're in API mode (served from Bun hook server) + // Skip if we loaded from a shared URL + useEffect(() => { + if (isLoadingShared) return; // Wait for share check to complete + if (isSharedSession) return; // Already loaded from share + + fetch('/api/plan') + .then(res => { + if (!res.ok) throw new Error('Not in API mode'); + return res.json(); + }) + .then((data: { plan: string }) => { + setMarkdown(data.plan); + setIsApiMode(true); + setPage('editor'); // Skip landing page in API mode + }) + .catch(() => { + // Not in API mode - use default content + setIsApiMode(false); + }) + .finally(() => setIsLoading(false)); + }, [isLoadingShared, isSharedSession]); + + useEffect(() => { + setBlocks(parseMarkdownToBlocks(markdown)); + }, [markdown]); + + // API mode handlers + const handleApprove = async () => { + setIsSubmitting(true); + try { + await fetch('/api/approve', { method: 'POST' }); + setSubmitted('approved'); + } catch { + setIsSubmitting(false); + } + }; + + const handleDeny = async () => { + setIsSubmitting(true); + try { + await fetch('/api/deny', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feedback: diffOutput }) + }); + setSubmitted('denied'); + } catch { + setIsSubmitting(false); + } + }; + + const handleAddAnnotation = (ann: Annotation) => { + setAnnotations(prev => [...prev, ann]); + setSelectedAnnotationId(ann.id); + setIsPanelOpen(true); + }; + + const handleDeleteAnnotation = (id: string) => { + viewerRef.current?.removeHighlight(id); + setAnnotations(prev => prev.filter(a => a.id !== id)); + if (selectedAnnotationId === id) setSelectedAnnotationId(null); + }; + + const handleIdentityChange = (oldIdentity: string, newIdentity: string) => { + setAnnotations(prev => prev.map(ann => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann + )); + }; + + const diffOutput = useMemo(() => exportDiff(blocks, annotations), [blocks, annotations]); + + if (page === 'landing') { + return ( + + + + ); + } + + return ( + +
+ {/* Tater sprites */} + {taterMode && } + {/* Minimal Header */} +
+
+ + v0.1 +
+ +
+ {isApiMode && ( + <> + + + + +
+ + )} + + + + + + + +
+
+ + {/* Main Content */} +
+ {/* Document Area */} +
+
+ {/* Mode Switcher */} +
+ +
+ + +
+
+ + {/* Annotation Panel */} + +
+ + {/* Export Modal */} + setShowExport(false)} + shareUrl={shareUrl} + shareUrlSize={shareUrlSize} + diffOutput={diffOutput} + annotationCount={annotations.length} + taterSprite={taterMode ? : undefined} + /> + + {/* Feedback prompt dialog */} + {showFeedbackPrompt && ( +
+
+
+
+ + + +
+

Add Annotations First

+
+

+ To provide feedback, select text in the plan and add annotations. Claude will use your annotations to revise the plan. +

+
+ +
+
+
+ )} + + {/* Completion overlay - shown after approve/deny */} + {submitted && ( +
+
+
+ {submitted === 'approved' ? ( + + + + ) : ( + + + + )} +
+ +
+

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

+

+ {submitted === 'approved' + ? 'Claude will proceed with the implementation.' + : 'Claude will revise the plan based on your annotations.'} +

+
+ +
+

+ Return to your Claude Code terminal to continue. +

+
+
+
+ )} +
+
+ ); +}; + +export default App; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e42c0f42 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,240 @@ +# Plannotator + +A plan review UI for Claude Code that intercepts `ExitPlanMode` and lets you approve or request changes with annotated feedback. + +## How It Works + +### Hook Flow + +``` +Claude calls ExitPlanMode + │ + ▼ +PermissionRequest hook fires + │ + ▼ +hooks/exit-plan-mode.sh reads plan from stdin JSON + │ + ▼ +Bun server starts (random port) + │ + ▼ +Browser opens to Plannotator UI + │ + ▼ +User reviews plan, optionally adds annotations + │ + ├─── Approve ───► JSON output: {"decision": {"behavior": "allow"}} + │ │ + │ ▼ + │ Claude proceeds with implementation + │ + └─── Request Changes ───► JSON output: {"decision": {"behavior": "deny", "message": "..."}} + │ + ▼ + Claude receives feedback, revises plan +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `hooks/exit-plan-mode.sh` | Shell script invoked by Claude Code hook system | +| `server/index.ts` | Bun server that serves UI and handles approve/deny API | +| `App.tsx` | React app with API mode detection | +| `dist/index.html` | Built single-file app served by Bun | + +### Hook Configuration + +The hook is configured in `.claude/settings.local.json`: + +```json +{ + "hooks": { + "PermissionRequest": [ + { + "matcher": "ExitPlanMode", + "hooks": [ + { + "type": "command", + "command": "/path/to/planning-hook/hooks/exit-plan-mode.sh", + "timeout": 600 + } + ] + } + ] + } +} +``` + +**Why PermissionRequest instead of PreToolUse?** + +- `PermissionRequest` fires when Claude Code is about to show a permission dialog +- Hook can return JSON to `allow` or `deny` directly — no second dialog +- `PreToolUse` would require a second approval step in Claude Code UI + +### Server API + +The Bun server exposes three endpoints: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/plan` | GET | Returns plan content as JSON | +| `/api/approve` | POST | User approved the plan | +| `/api/deny` | POST | User denied with feedback in request body | + +### Multi-Instance Support + +Each Plannotator instance runs on a random port (`Bun.serve({ port: 0 })`). The browser's same-origin policy ensures each tab talks to its own server — no cross-instance interference. + +### Hook Output Format + +The server outputs JSON to stdout for the PermissionRequest hook: + +**Approve:** +```json +{ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "allow" + } + } +} +``` + +**Deny:** +```json +{ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "deny", + "message": "# Plan Feedback\n\n..." + } + } +} +``` + +## Annotation Data Model + +### Types (`types.ts`) + +```typescript +enum AnnotationType { + DELETION = 'DELETION', // Remove this text + INSERTION = 'INSERTION', // Add new text (originalText is context) + REPLACEMENT = 'REPLACEMENT', // Replace originalText with text + COMMENT = 'COMMENT', // Comment on originalText +} + +interface Annotation { + id: string; // From web-highlighter or `codeblock-{timestamp}` + blockId: string; // References Block.id (legacy, for sorting) + startOffset: number; // Char offset in block (legacy) + endOffset: number; // End char offset (legacy) + type: AnnotationType; + text?: string; // User text for comment/replacement/insertion + originalText: string; // The selected/highlighted text + createdA: number; // Timestamp for ordering + author?: string; // Tater identity for collaborative sharing + startMeta?: { parentTagName, parentIndex, textOffset }; // web-highlighter DOM pos + endMeta?: { parentTagName, parentIndex, textOffset }; +} + +interface Block { + id: string; // e.g., "block-0" + type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr'; + content: string; + level?: number; // For headings (1-6) + order: number; + startLine: number; // 1-based line in original markdown +} +``` + +### Annotation Creation Flow + +1. User selects text → `web-highlighter` fires `CREATE` event +2. In **selection mode**: Toolbar appears with type options +3. In **redline mode**: Auto-creates `DELETION` annotation +4. `createAnnotationFromSource()` builds `Annotation` object +5. Annotation stored in `App.tsx` state: `useState([])` + +### Code Block Annotations + +Code blocks can't use web-highlighter (content not directly selectable). Instead: +- Hover triggers `CodeBlockToolbar` +- Manual `` wrapper created with `range.surroundContents()` +- ID format: `codeblock-{timestamp}` +- Must handle removal separately via `data-bind-id` attribute + +## URL-Based Sharing + +Plannotator supports decentralized sharing via URL hash. The entire plan and annotations are compressed into the URL — no server storage required. + +### Implementation + +| File | Purpose | +|------|---------| +| `utils/sharing.ts` | Compress/decompress using native `CompressionStream` (deflate-raw) + base64url | +| `hooks/useSharing.ts` | URL state management, hash change detection | +| `components/ExportModal.tsx` | Tabbed modal with Share and Raw Diff tabs | + +### Share Format + +```typescript +interface SharePayload { + p: string; // Plan markdown + a: ShareableAnnotation[]; // Minimal annotation tuples +} + +// Annotations compressed to tuples: [type, originalText, text?, author?] +type ShareableAnnotation = + | ['D', string, string | null] // Deletion + | ['R', string, string, string | null] // Replacement + | ['C', string, string, string | null] // Comment + | ['I', string, string, string | null]; // Insertion +``` + +### How It Works + +**Sharing:** +1. User clicks Share → payload compressed with `deflate-raw` → base64url encoded +2. URL becomes `plannotator.app/#` +3. Typical plan + annotations: ~1-2KB URL + +**Loading:** +1. On mount, check `location.hash` +2. If present, decompress → restore plan and annotations +3. Find annotation text in DOM using `TreeWalker` +4. Apply `` highlights programmatically + +### Tater Identity System + +Anonymous identities for collaborative annotation: +- Format: `{adjective}-{noun}-tater` (e.g., "swift-falcon-tater") +- Generated via `unique-username-generator` library +- Stored in localStorage, shown on annotation cards +- Regenerate anytime in Settings + +## Development + +```bash +# Install dependencies +npm install + +# Run dev server (for UI development) +npm run dev + +# Build for production +npm run build + +# Test server standalone +bun run server/index.ts "# Test Plan\n\nSome content" +``` + +## Requirements + +- Bun runtime +- Claude Code with hooks support +- macOS (uses `open` command for browser) diff --git a/README.md b/README.md new file mode 100644 index 00000000..8a6351a4 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1T58blWi735nbP9jxqohfRrCa584GUPIB + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/AnnotationPanel.tsx b/components/AnnotationPanel.tsx new file mode 100644 index 00000000..67dbccec --- /dev/null +++ b/components/AnnotationPanel.tsx @@ -0,0 +1,235 @@ +import React, { useState } from 'react'; +import { Annotation, AnnotationType, Block } from '../types'; +import { isCurrentUser } from '../utils/identity'; + +interface PanelProps { + isOpen: boolean; + annotations: Annotation[]; + blocks: Block[]; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + selectedId: string | null; + shareUrl?: string; +} + +export const AnnotationPanel: React.FC = ({ + isOpen, + annotations, + blocks, + onSelect, + onDelete, + selectedId, + shareUrl +}) => { + const [copied, setCopied] = useState(false); + const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA); + + const handleQuickShare = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + + if (!isOpen) return null; + + return ( + + ); +}; + +function formatTimestamp(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'now'; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +const AnnotationCard: React.FC<{ + annotation: Annotation; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; +}> = ({ annotation, isSelected, onSelect, onDelete }) => { + const typeConfig = { + [AnnotationType.DELETION]: { + label: 'Delete', + color: 'text-destructive', + bg: 'bg-destructive/10', + icon: ( + + + + ) + }, + [AnnotationType.INSERTION]: { + label: 'Insert', + color: 'text-secondary', + bg: 'bg-secondary/10', + icon: ( + + + + ) + }, + [AnnotationType.REPLACEMENT]: { + label: 'Replace', + color: 'text-primary', + bg: 'bg-primary/10', + icon: ( + + + + ) + }, + [AnnotationType.COMMENT]: { + label: 'Comment', + color: 'text-accent', + bg: 'bg-accent/10', + icon: ( + + + + ) + } + }; + + const config = typeConfig[annotation.type]; + + return ( +
+ {/* Author */} + {annotation.author && ( +
+ + + + {annotation.author}{isCurrentUser(annotation.author) && ' (me)'} +
+ )} + + {/* Type Badge + Timestamp + Delete */} +
+
+
+ + {config.icon} + + + {config.label} + +
+ + {formatTimestamp(annotation.createdA)} + +
+ +
+ + {/* Original Text */} +
+ "{annotation.originalText}" +
+ + {/* Comment/Replacement Text */} + {annotation.text && annotation.type !== AnnotationType.DELETION && ( +
+ {annotation.text} +
+ )} +
+ ); +}; diff --git a/components/AnnotationSidebar.tsx b/components/AnnotationSidebar.tsx new file mode 100644 index 00000000..bd1b5657 --- /dev/null +++ b/components/AnnotationSidebar.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Annotation, AnnotationType, Block } from '../types'; + +interface SidebarProps { + annotations: Annotation[]; + blocks: Block[]; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + selectedId: string | null; +} + +export const AnnotationSidebar: React.FC = ({ + annotations, + blocks, + onSelect, + onDelete, + selectedId +}) => { + const sortedAnnotations = [...annotations].sort((a, b) => { + const blockA = blocks.findIndex(blk => blk.id === a.blockId); + const blockB = blocks.findIndex(blk => blk.id === b.blockId); + if (blockA !== blockB) return blockA - blockB; + return a.startOffset - b.startOffset; + }); + + return ( +
+
+

Review Changes

+ + {annotations.length} + +
+ +
+ {sortedAnnotations.length === 0 ? ( +
+

No annotations yet.

+

Select text in the document to add comments or suggest changes.

+
+ ) : ( + sortedAnnotations.map(ann => ( +
onSelect(ann.id)} + className={` + group relative p-3 rounded-lg border transition-all cursor-pointer + ${selectedId === ann.id + ? 'bg-accent/20 border-accent shadow-sm' + : 'bg-card border-border hover:border-accent hover:shadow-sm' + } + `} + > +
+ + {ann.type} + + +
+ +
+ "{ann.originalText}" +
+ + {(ann.text && ann.type !== AnnotationType.DELETION) && ( +
+ {ann.type === AnnotationType.REPLACEMENT ? '→ ' : ''}{ann.text} +
+ )} +
+ )) + )} +
+
+ ); +}; diff --git a/components/DecisionBar.tsx b/components/DecisionBar.tsx new file mode 100644 index 00000000..fd895090 --- /dev/null +++ b/components/DecisionBar.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; + +interface DecisionBarProps { + onApprove: () => Promise; + onDeny: (feedback: string) => Promise; + annotationCount: number; + getFeedback: () => string; +} + +export const DecisionBar: React.FC = ({ + onApprove, + onDeny, + annotationCount, + getFeedback +}) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); + + const handleApprove = async () => { + setIsSubmitting(true); + try { + await onApprove(); + setSubmitted('approved'); + } catch (error) { + console.error('Failed to approve:', error); + setIsSubmitting(false); + } + }; + + const handleDeny = async () => { + const feedback = getFeedback(); + setIsSubmitting(true); + try { + await onDeny(feedback); + setSubmitted('denied'); + } catch (error) { + console.error('Failed to deny:', error); + setIsSubmitting(false); + } + }; + + if (submitted) { + return ( +
+
+ {/* Icon */} +
+ {submitted === 'approved' ? ( + + + + ) : ( + + + + )} +
+ + {/* Message */} +
+

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

+

+ {submitted === 'approved' + ? 'Claude will proceed with the implementation.' + : 'Claude will revise the plan based on your annotations.'} +

+
+ + {/* Instruction */} +
+

+ Return to your Claude Code terminal to continue. +

+
+
+
+ ); + } + + return ( +
+
+ {/* Status info */} +
+ {annotationCount > 0 ? ( + {annotationCount} annotation{annotationCount !== 1 ? 's' : ''} to send as feedback + ) : ( + Review the plan, then approve or request changes + )} +
+ + {/* Action buttons */} +
+ + + +
+
+
+ ); +}; diff --git a/components/ExportModal.tsx b/components/ExportModal.tsx new file mode 100644 index 00000000..4b5e7602 --- /dev/null +++ b/components/ExportModal.tsx @@ -0,0 +1,190 @@ +/** + * Export Modal with tabs for Share and Raw Diff + * + * Share tab (default): Shows shareable URL with copy button + * Raw Diff tab: Shows human-readable diff output with copy/download + */ + +import React, { useState } from 'react'; + +interface ExportModalProps { + isOpen: boolean; + onClose: () => void; + shareUrl: string; + shareUrlSize: string; + diffOutput: string; + annotationCount: number; + taterSprite?: React.ReactNode; +} + +type Tab = 'share' | 'diff'; + +export const ExportModal: React.FC = ({ + isOpen, + onClose, + shareUrl, + shareUrlSize, + diffOutput, + annotationCount, + taterSprite, +}) => { + const [activeTab, setActiveTab] = useState('share'); + const [copied, setCopied] = useState(false); + + if (!isOpen) return null; + + const handleCopyUrl = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + + const handleCopyDiff = async () => { + try { + await navigator.clipboard.writeText(diffOutput); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + + const handleDownloadDiff = () => { + const blob = new Blob([diffOutput], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'annotations.diff'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
e.stopPropagation()} + > + {taterSprite} + + {/* Header with tabs */} +
+
+

Export

+
+ + {annotationCount} annotation{annotationCount !== 1 ? 's' : ''} + + +
+
+ + {/* Tabs */} +
+ + +
+
+ + {/* Tab content */} +
+ {activeTab === 'share' ? ( +
+
+ +
+