mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
92d4f73b66
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>
574 lines
21 KiB
TypeScript
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;
|