Files
backnotprop__plannotator/App.tsx
T
Michael Ramos 92d4f73b66 Initial commit: Plannotator - Plan review UI for Claude Code
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 <noreply@anthropic.com>
2025-12-27 18:14:10 -08:00

574 lines
21 KiB
TypeScript

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<HTMLDivElement>;
}
export const CursorOverlay: React.FC<CursorOverlayProps> = ({
documentId,
containerRef
}) => {
const { collaborators, currentUser } = useCollaboration(documentId);
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
useEffect(() => {
const updatePositions = () => {
const newPositions = new Map<string, DOMRect>();
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]) => (
<div
key={userId}
className="absolute pointer-events-none transition-all duration-75"
style={{
left: rect.left,
top: rect.top,
height: rect.height,
}}
>
<div className="w-0.5 h-full bg-blue-500 animate-pulse" />
<div className="absolute -top-5 left-0 px-1.5 py-0.5 bg-blue-500
text-white text-xs rounded whitespace-nowrap">
{collaborators.find(c => c.userId === userId)?.userName}
</div>
</div>
))}
</>
);
};
\`\`\`
### 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<Annotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [blocks, setBlocks] = useState<Block[]>([]);
const [showExport, setShowExport] = useState(false);
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [editorMode, setEditorMode] = useState<EditorMode>('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<ViewerHandle>(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 (
<ThemeProvider defaultTheme="dark">
<Landing onEnter={goToEditor} />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="dark">
<div className="h-screen flex flex-col bg-background overflow-hidden">
{/* Tater sprites */}
{taterMode && <TaterSpriteRunning />}
{/* Minimal Header */}
<header className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl z-50">
<div className="flex items-center gap-2 md:gap-3">
<button
onClick={() => { window.history.pushState({}, '', '/'); setPage('landing'); }}
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
>
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span className="text-sm font-semibold tracking-tight hidden sm:inline">Plannotator</span>
</button>
<span className="text-xs text-muted-foreground font-mono opacity-60 hidden md:inline">v0.1</span>
</div>
<div className="flex items-center gap-1 md:gap-2">
{isApiMode && (
<>
<button
onClick={() => {
if (annotations.length === 0) {
setShowFeedbackPrompt(true);
} else {
handleDeny();
}
}}
disabled={isSubmitting}
className={`p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-all ${
isSubmitting
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
: 'bg-accent/15 text-accent hover:bg-accent/25 border border-accent/30'
}`}
title="Provide Feedback"
>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden md:inline">{isSubmitting ? 'Sending...' : 'Provide Feedback'}</span>
</button>
<button
onClick={handleApprove}
disabled={isSubmitting}
className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${
isSubmitting
? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground'
: 'bg-green-600 text-white hover:bg-green-500'
}`}
>
<span className="md:hidden">{isSubmitting ? '...' : 'OK'}</span>
<span className="hidden md:inline">{isSubmitting ? 'Approving...' : 'Approve'}</span>
</button>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
<ModeToggle />
<Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} />
<button
onClick={() => setIsPanelOpen(!isPanelOpen)}
className={`p-1.5 rounded-md text-xs font-medium transition-all ${
isPanelOpen
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
<button
onClick={() => setShowExport(true)}
className="p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
title="Export"
>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span className="hidden md:inline">Export</span>
</button>
</div>
</header>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Document Area */}
<main className="flex-1 overflow-y-auto bg-grid">
<div className="min-h-full flex flex-col items-center p-3 md:p-8">
{/* Mode Switcher */}
<div className="w-full max-w-3xl mb-3 md:mb-4 flex justify-start">
<ModeSwitcher mode={editorMode} onChange={setEditorMode} taterMode={taterMode} />
</div>
<Viewer
ref={viewerRef}
blocks={blocks}
annotations={annotations}
onAddAnnotation={handleAddAnnotation}
onSelectAnnotation={setSelectedAnnotationId}
selectedAnnotationId={selectedAnnotationId}
mode={editorMode}
taterMode={taterMode}
/>
</div>
</main>
{/* Annotation Panel */}
<AnnotationPanel
isOpen={isPanelOpen}
blocks={blocks}
annotations={annotations}
selectedId={selectedAnnotationId}
onSelect={setSelectedAnnotationId}
onDelete={handleDeleteAnnotation}
shareUrl={shareUrl}
/>
</div>
{/* Export Modal */}
<ExportModal
isOpen={showExport}
onClose={() => setShowExport(false)}
shareUrl={shareUrl}
shareUrlSize={shareUrlSize}
diffOutput={diffOutput}
annotationCount={annotations.length}
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
/>
{/* Feedback prompt dialog */}
{showFeedbackPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-xl w-full max-w-sm shadow-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
<svg className="w-5 h-5 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</div>
<h3 className="font-semibold">Add Annotations First</h3>
</div>
<p className="text-sm text-muted-foreground mb-6">
To provide feedback, select text in the plan and add annotations. Claude will use your annotations to revise the plan.
</p>
<div className="flex justify-end">
<button
onClick={() => setShowFeedbackPrompt(false)}
className="px-4 py-2 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
>
Got it
</button>
</div>
</div>
</div>
)}
{/* Completion overlay - shown after approve/deny */}
{submitted && (
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md px-8">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
submitted === 'approved'
? 'bg-green-500/20 text-green-500'
: 'bg-accent/20 text-accent'
}`}>
{submitted === 'approved' ? (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
{submitted === 'approved' ? 'Plan Approved' : 'Feedback Sent'}
</h2>
<p className="text-muted-foreground">
{submitted === 'approved'
? 'Claude will proceed with the implementation.'
: 'Claude will revise the plan based on your annotations.'}
</p>
</div>
<div className="pt-4 border-t border-border">
<p className="text-sm text-muted-foreground">
Return to your <span className="text-foreground font-medium">Claude Code terminal</span> to continue.
</p>
</div>
</div>
</div>
)}
</div>
</ThemeProvider>
);
};
export default App;