diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index f12c5cd6..73ef1d9e 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -23,7 +23,7 @@ import { getBearSettings } from '@plannotator/ui/utils/bear'; import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp'; import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; -import { getUIPreferences, needsUIFeaturesSetup, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences'; +import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences'; import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode'; import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod'; import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch'; @@ -36,11 +36,6 @@ import { type PermissionMode, } from '@plannotator/ui/utils/permissionMode'; import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; -import { UIFeaturesSetup } from '@plannotator/ui/components/UIFeaturesSetup'; -import { PlanDiffMarketing } from '@plannotator/ui/components/plan-diff/PlanDiffMarketing'; -import { needsPlanDiffMarketingDialog } from '@plannotator/ui/utils/planDiffMarketing'; -import { WhatsNewV011 } from '@plannotator/ui/components/WhatsNewV011'; -import { needsWhatsNewDialog } from '@plannotator/ui/utils/whatsNew'; import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; @@ -54,344 +49,10 @@ import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; - -const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration - -## Overview -Add real-time collaboration features to the editor using **[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)** and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. - -## 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 - - Initialize WebSocket with authentication token - - Set up heartbeat ping/pong every 30 seconds - - Handle connection state changes (connecting, open, closing, closed) -- Implement reconnection logic with exponential backoff - - Start with 1 second delay - - Double delay on each retry (max 30 seconds) - - Reset delay on successful connection -- Handle offline state gracefully - - Queue local changes in IndexedDB - - Show offline indicator in UI - - Sync queued changes on reconnect - -### 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); -\`\`\` - -### Architecture - -\`\`\`mermaid -flowchart LR - subgraph Client["Client Browser"] - UI[React UI] --> OT[OT Engine] - OT <--> WS[WebSocket Client] - end - - subgraph Server["Backend"] - WSS[WebSocket Server] <--> OTS[OT Transform] - OTS <--> DB[(PostgreSQL)] - end - - WS <--> WSS -\`\`\` - -### Service Dependencies (Graphviz) - -\`\`\`graphviz -digraph CollaborationStack { - rankdir=LR; - node [shape=box, style="rounded"]; - - Browser [label="Client Browser"]; - API [label="WebSocket API"]; - OT [label="OT Engine"]; - Redis [label="Presence Cache"]; - Postgres [label="PostgreSQL"]; - - Browser -> API; - API -> OT; - OT -> Redis; - OT -> Postgres; -} -\`\`\` - -## 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 - - Same position: use user ID for deterministic ordering - - Different positions: adjust offset of later operation -- Transform insert against delete - - Insert before delete: no change needed - - Insert inside deleted range: special handling required - - Option A: Move insert to delete start position - - Option B: Discard the insert entirely - - Insert after delete: adjust insert position -- Transform delete against delete - - Non-overlapping: adjust positions - - Overlapping: merge or split operations -- Maintain cursor positions across transforms - - Track cursor as a zero-width insert operation - - Update cursor position after each transform - -### 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 - - Render cursor as colored vertical line - - Add name label above cursor - - Animate cursor movement smoothly -2. Display presence indicators - - Avatar stack in header - - Dropdown with full collaborator list - - Show online/away status - - Display last activity time - - Allow @mentioning collaborators -3. Add conflict resolution UI - - Highlight conflicting regions - - Show diff comparison panel - - Provide merge options: - - Accept mine - - Accept theirs - - Manual merge -4. Implement undo/redo stack per user - - Track operations by user ID - - Allow undoing only own changes - - Show undo history in sidebar - -### 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 - } - } -} -\`\`\` - ---- - -## Pre-launch Checklist - -- [ ] Infrastructure ready - - [x] WebSocket server deployed - - [x] Database migrations applied - - [ ] Load balancer configured - - [ ] SSL certificates installed - - [ ] Health checks enabled - - [ ] /health endpoint returns 200 - - [ ] /ready endpoint checks DB connection - - [ ] Primary database - - [ ] Read replicas - - [ ] us-east-1 replica - - [ ] eu-west-1 replica -- [ ] Security audit complete - - [x] Authentication flow reviewed - - [ ] Rate limiting implemented - - [x] 100 req/min for anonymous users - - [ ] 1000 req/min for authenticated users - - [ ] Input sanitization verified -- [x] Documentation updated - - [x] API reference generated - - [x] Integration guide written - - [ ] Video tutorials recorded - -### API Endpoints - -| Method | Endpoint | Description | Auth | -|--------|----------|-------------|------| -| GET | /api/documents | List all documents | Required | -| POST | /api/documents | Create new document | Required | -| GET | /api/documents/:id | Fetch document | Required | -| PUT | /api/documents/:id | Update document | Owner/Editor | -| DELETE | /api/documents/:id | Delete document | Owner only | -| POST | /api/documents/:id/share | Share document | Owner only | -| GET | /api/documents/:id/collaborators | List collaborators | Required | - -### Performance Targets - -| Metric | Target | Current | Status | -|--------|--------|---------|--------| -| WebSocket latency | < 50ms | 42ms | On track | -| Time to first cursor | < 200ms | 310ms | **At risk** | -| Concurrent users/doc | 50 | 25 | In progress | -| Operation transform | < 5ms | 3ms | On track | -| Reconnect time | < 2s | 1.8s | On track | - -### Mixed List Styles - -* Asterisk item at level 0 - - Dash item at level 1 - * Asterisk at level 2 - - Dash at level 3 - * Asterisk at level 4 - - Maximum reasonable depth -1. Numbered item - - Sub-bullet under numbered - - Another sub-bullet - 1. Nested numbered list - 2. Second nested number - ---- - -**Target:** Ship MVP in next sprint -`; +import { DEMO_PLAN_CONTENT } from './demoPlan'; const App: React.FC = () => { - const [markdown, setMarkdown] = useState(PLAN_CONTENT); + const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [blocks, setBlocks] = useState([]); @@ -421,9 +82,6 @@ const App: React.FC = () => { const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); - const [showUIFeaturesSetup, setShowUIFeaturesSetup] = useState(false); - const [showPlanDiffMarketing, setShowPlanDiffMarketing] = useState(false); - const [showWhatsNew, setShowWhatsNew] = useState(false); const [permissionMode, setPermissionMode] = useState('bypassPermissions'); const [sharingEnabled, setSharingEnabled] = useState(true); const [shareBaseUrl, setShareBaseUrl] = useState(undefined); @@ -690,12 +348,6 @@ const App: React.FC = () => { // For Claude Code, check if user needs to configure permission mode if (data.origin === 'claude-code' && needsPermissionModeSetup()) { setShowPermissionModeSetup(true); - } else if (needsUIFeaturesSetup()) { - setShowUIFeaturesSetup(true); - } else if (needsPlanDiffMarketingDialog()) { - setShowPlanDiffMarketing(true); - } else if (needsWhatsNewDialog()) { - setShowWhatsNew(true); } // Load saved permission mode preference setPermissionMode(getPermissionModeSettings().mode); @@ -927,7 +579,7 @@ const App: React.FC = () => { // Don't intercept if any modal is open if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || showUIFeaturesSetup || showPlanDiffMarketing || showWhatsNew || pendingPasteImage) return; + showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; // Don't intercept if already submitted or submitting if (submitted || isSubmitting) return; @@ -971,7 +623,7 @@ const App: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [ showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, - showPermissionModeSetup, showUIFeaturesSetup, showPlanDiffMarketing, showWhatsNew, pendingPasteImage, + showPermissionModeSetup, pendingPasteImage, submitted, isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, annotateMode, origin, getAgentWarning, ]); @@ -1110,7 +762,7 @@ const App: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || showUIFeaturesSetup || showPlanDiffMarketing || showWhatsNew || pendingPasteImage) return; + showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; if (submitted || !isApiMode) return; @@ -1136,7 +788,7 @@ const App: React.FC = () => { return () => window.removeEventListener('keydown', handleSaveShortcut); }, [ showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, - showPermissionModeSetup, showUIFeaturesSetup, showPlanDiffMarketing, showWhatsNew, pendingPasteImage, + showPermissionModeSetup, pendingPasteImage, submitted, isApiMode, markdown, annotationsOutput, ]); @@ -1708,47 +1360,6 @@ const App: React.FC = () => { onComplete={(mode) => { setPermissionMode(mode); setShowPermissionModeSetup(false); - if (needsUIFeaturesSetup()) { - setShowUIFeaturesSetup(true); - } else if (needsPlanDiffMarketingDialog()) { - setShowPlanDiffMarketing(true); - } else if (needsWhatsNewDialog()) { - setShowWhatsNew(true); - } - }} - /> - - {/* UI Features Setup (TOC & Sticky Actions) */} - { - setUiPrefs(prefs); - setShowUIFeaturesSetup(false); - if (needsPlanDiffMarketingDialog()) { - setShowPlanDiffMarketing(true); - } else if (needsWhatsNewDialog()) { - setShowWhatsNew(true); - } - }} - /> - - {/* Plan Diff Marketing (feature announcement) */} - { - setShowPlanDiffMarketing(false); - if (needsWhatsNewDialog()) { - setShowWhatsNew(true); - } - }} - /> - - {/* What's New v0.11.0 (feature announcement) */} - { - setShowWhatsNew(false); }} />
diff --git a/packages/editor/demoPlan.ts b/packages/editor/demoPlan.ts new file mode 100644 index 00000000..27b67050 --- /dev/null +++ b/packages/editor/demoPlan.ts @@ -0,0 +1,334 @@ +export const DEMO_PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration + +## Overview +Add real-time collaboration features to the editor using **[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)** and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*. + +## 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 + - Initialize WebSocket with authentication token + - Set up heartbeat ping/pong every 30 seconds + - Handle connection state changes (connecting, open, closing, closed) +- Implement reconnection logic with exponential backoff + - Start with 1 second delay + - Double delay on each retry (max 30 seconds) + - Reset delay on successful connection +- Handle offline state gracefully + - Queue local changes in IndexedDB + - Show offline indicator in UI + - Sync queued changes on reconnect + +### 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); +\`\`\` + +### Architecture + +\`\`\`mermaid +flowchart LR + subgraph Client["Client Browser"] + UI[React UI] --> OT[OT Engine] + OT <--> WS[WebSocket Client] + end + + subgraph Server["Backend"] + WSS[WebSocket Server] <--> OTS[OT Transform] + OTS <--> DB[(PostgreSQL)] + end + + WS <--> WSS +\`\`\` + +### Service Dependencies (Graphviz) + +\`\`\`graphviz +digraph CollaborationStack { + rankdir=LR; + node [shape=box, style="rounded"]; + + Browser [label="Client Browser"]; + API [label="WebSocket API"]; + OT [label="OT Engine"]; + Redis [label="Presence Cache"]; + Postgres [label="PostgreSQL"]; + + Browser -> API; + API -> OT; + OT -> Redis; + OT -> Postgres; +} +\`\`\` + +## 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 + - Same position: use user ID for deterministic ordering + - Different positions: adjust offset of later operation +- Transform insert against delete + - Insert before delete: no change needed + - Insert inside deleted range: special handling required + - Option A: Move insert to delete start position + - Option B: Discard the insert entirely + - Insert after delete: adjust insert position +- Transform delete against delete + - Non-overlapping: adjust positions + - Overlapping: merge or split operations +- Maintain cursor positions across transforms + - Track cursor as a zero-width insert operation + - Update cursor position after each transform + +### 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 + - Render cursor as colored vertical line + - Add name label above cursor + - Animate cursor movement smoothly +2. Display presence indicators + - Avatar stack in header + - Dropdown with full collaborator list + - Show online/away status + - Display last activity time + - Allow @mentioning collaborators +3. Add conflict resolution UI + - Highlight conflicting regions + - Show diff comparison panel + - Provide merge options: + - Accept mine + - Accept theirs + - Manual merge +4. Implement undo/redo stack per user + - Track operations by user ID + - Allow undoing only own changes + - Show undo history in sidebar + +### 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 + } + } +} +\`\`\` + +--- + +## Pre-launch Checklist + +- [ ] Infrastructure ready + - [x] WebSocket server deployed + - [x] Database migrations applied + - [ ] Load balancer configured + - [ ] SSL certificates installed + - [ ] Health checks enabled + - [ ] /health endpoint returns 200 + - [ ] /ready endpoint checks DB connection + - [ ] Primary database + - [ ] Read replicas + - [ ] us-east-1 replica + - [ ] eu-west-1 replica +- [ ] Security audit complete + - [x] Authentication flow reviewed + - [ ] Rate limiting implemented + - [x] 100 req/min for anonymous users + - [ ] 1000 req/min for authenticated users + - [ ] Input sanitization verified +- [x] Documentation updated + - [x] API reference generated + - [x] Integration guide written + - [ ] Video tutorials recorded + +### API Endpoints + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| GET | /api/documents | List all documents | Required | +| POST | /api/documents | Create new document | Required | +| GET | /api/documents/:id | Fetch document | Required | +| PUT | /api/documents/:id | Update document | Owner/Editor | +| DELETE | /api/documents/:id | Delete document | Owner only | +| POST | /api/documents/:id/share | Share document | Owner only | +| GET | /api/documents/:id/collaborators | List collaborators | Required | + +### Performance Targets + +| Metric | Target | Current | Status | +|--------|--------|---------|--------| +| WebSocket latency | < 50ms | 42ms | On track | +| Time to first cursor | < 200ms | 310ms | **At risk** | +| Concurrent users/doc | 50 | 25 | In progress | +| Operation transform | < 5ms | 3ms | On track | +| Reconnect time | < 2s | 1.8s | On track | + +### Mixed List Styles + +* Asterisk item at level 0 + - Dash item at level 1 + * Asterisk at level 2 + - Dash at level 3 + * Asterisk at level 4 + - Maximum reasonable depth +1. Numbered item + - Sub-bullet under numbered + - Another sub-bullet + 1. Nested numbered list + 2. Second nested number + +--- + +**Target:** Ship MVP in next sprint +`; diff --git a/packages/ui/components/UIFeaturesSetup.tsx b/packages/ui/components/UIFeaturesSetup.tsx deleted file mode 100644 index 700231de..00000000 --- a/packages/ui/components/UIFeaturesSetup.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useState } from 'react'; -import { createPortal } from 'react-dom'; -import { - saveUIPreferences, - markUIFeaturesSetupDone, - type UIPreferences, -} from '../utils/uiPreferences'; - -const PREVIEW_IMAGE_URL = 'https://plannotator.ai/assets/toc-sticky-preview.png'; - -interface UIFeaturesSetupProps { - isOpen: boolean; - onComplete: (prefs: UIPreferences) => void; -} - -export const UIFeaturesSetup: React.FC = ({ - isOpen, - onComplete, -}) => { - const [tocEnabled, setTocEnabled] = useState(true); - const [stickyActionsEnabled, setStickyActionsEnabled] = useState(true); - const [imageLoaded, setImageLoaded] = useState(true); - - if (!isOpen) return null; - - const handleConfirm = () => { - const prefs: UIPreferences = { tocEnabled, stickyActionsEnabled }; - saveUIPreferences(prefs); - markUIFeaturesSetupDone(); - onComplete(prefs); - }; - - return createPortal( -
-
- {/* Header */} -
-
-
- - - -
-

New: Display Options

-
-

- We've added two features that help navigate larger plans. -

-
- - {/* Preview + Options */} -
- {imageLoaded && ( - Table of Contents and Sticky Actions preview setImageLoaded(false)} - /> - )} - - - - -
- - {/* Footer */} -
-

- You can change this later in Settings. -

- -
-
-
, - document.body - ); -}; diff --git a/packages/ui/components/WhatsNewV011.tsx b/packages/ui/components/WhatsNewV011.tsx deleted file mode 100644 index d91f83fb..00000000 --- a/packages/ui/components/WhatsNewV011.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react'; -import { createPortal } from 'react-dom'; -import { markWhatsNewSeen } from '../utils/whatsNew'; - -const RELEASE_URLS = { - v0100: 'https://github.com/backnotprop/plannotator/releases/tag/v0.10.0', - v0110: 'https://github.com/backnotprop/plannotator/releases/tag/v0.11.0', -}; - -interface WhatsNewV011Props { - isOpen: boolean; - onComplete: () => void; -} - -export const WhatsNewV011: React.FC = ({ - isOpen, - onComplete, -}) => { - if (!isOpen) return null; - - const handleDismiss = () => { - markWhatsNewSeen(); - onComplete(); - }; - - return createPortal( -
-
- {/* Header */} -
-
-
- - - -
-

What's New in Plannotator

-
-

- Here's what's been added since your last update. -

-
- - {/* Content */} -
- {/* Feature bullets */} -
-
-
-
-
-

- Auto-save annotation drafts{' '} - — Your annotations are now automatically saved in the background. If the browser refreshes or the server crashes, you'll be prompted to restore your work. Sorry it took so long to implement this. -

-
-
-
-
-
-

- Short link sharing{' '} - — Share plans with shorter, more portable links that work across Slack and other platforms, with end-to-end encryption. Currently enabled for large plans that don't fit in the URL; we'll be rolling this out as the default soon. -

-
-
-
-
-
-

- Obsidian vault browser{' '} - — For Obsidian users, we're building deeper integrations starting with the vault browser. Browse and annotate vault files directly during plan review. Toggle it on under Settings > Saving > Obsidian. -

-
-
- - {/* Release notes callout */} -

- Plus many more improvements and bug fixes. See the full release notes:{' '} - - v0.10.0 - - {', '} - - v0.11.0 - -

-
- - {/* Footer */} -
- -
-
-
, - document.body - ); -}; diff --git a/packages/ui/components/plan-diff/PlanDiffMarketing.tsx b/packages/ui/components/plan-diff/PlanDiffMarketing.tsx deleted file mode 100644 index f6fa4d16..00000000 --- a/packages/ui/components/plan-diff/PlanDiffMarketing.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React, { useState } from 'react'; -import { createPortal } from 'react-dom'; -import { markPlanDiffMarketingSeen } from '../../utils/planDiffMarketing'; - -const PREVIEW_IMAGE_URL = 'https://plannotator.ai/assets/plan-diff-preview.png'; -const FEEDBACK_URL = 'https://github.com/backnotprop/plannotator/issues'; - -const VIDEO_URLS: Record = { - 'claude-code': 'https://youtu.be/uIWkFCg60Lk', - 'opencode': 'https://youtu.be/uIWkFCg60Lk', - 'pi': 'https://youtu.be/uIWkFCg60Lk', -}; -const DEFAULT_VIDEO_URL = 'https://youtu.be/uIWkFCg60Lk'; - -interface PlanDiffMarketingProps { - isOpen: boolean; - origin: 'claude-code' | 'opencode' | 'pi' | null; - onComplete: () => void; -} - -export const PlanDiffMarketing: React.FC = ({ - isOpen, - origin, - onComplete, -}) => { - const [imageError, setImageError] = useState(false); - const videoUrl = (origin && VIDEO_URLS[origin]) || DEFAULT_VIDEO_URL; - - if (!isOpen) return null; - - const handleDismiss = () => { - markPlanDiffMarketingSeen(); - onComplete(); - }; - - return createPortal( -
-
- {/* Header */} -
-
-
- - - -
-

New: Plan Diff Mode

-
-

- See exactly what changed when a coding agent revises your plan. -

-
- - {/* Content */} -
- {/* Banner image */} - {imageError ? ( -
-
- - - -

Plan Diff screenshot

-
-
- ) : ( - Plan Diff Mode preview showing visual and raw diff views setImageError(true)} - /> - )} - - {/* Feature summary */} -
-
-
-
-
-

- Two view modes{' '} - — a rendered visual diff with color-coded borders for quick scanning, and a raw markdown diff for precision. -

-
-
-
-
-
-

- Version history{' '} - — compare against any previous version from the sidebar. Plans are automatically versioned as your agent iterates. -

-
-
- - {/* Video demo link */} - -
- - - -
- Watch Video Demo of Plan Diff - - - -
- - {/* Feedback callout */} -

- This is the first release of Plan Diff — rough edges are expected, especially around plan name matching across sessions. If something feels off or you have ideas, open an{' '} - - issue - . Let's make this better together. -

-
- - {/* Footer */} -
- -
-
-
, - document.body - ); -}; diff --git a/packages/ui/utils/planDiffMarketing.ts b/packages/ui/utils/planDiffMarketing.ts deleted file mode 100644 index d37f2014..00000000 --- a/packages/ui/utils/planDiffMarketing.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { storage } from './storage'; - -const STORAGE_KEY = 'plannotator-plan-diff-marketing-seen'; - -export function needsPlanDiffMarketingDialog(): boolean { - return storage.getItem(STORAGE_KEY) !== 'true'; -} - -export function markPlanDiffMarketingSeen(): void { - storage.setItem(STORAGE_KEY, 'true'); -} diff --git a/packages/ui/utils/uiPreferences.ts b/packages/ui/utils/uiPreferences.ts index 3557e4ee..9670d2c2 100644 --- a/packages/ui/utils/uiPreferences.ts +++ b/packages/ui/utils/uiPreferences.ts @@ -2,7 +2,6 @@ import { storage } from './storage'; const STORAGE_KEY_TOC = 'plannotator-toc-enabled'; const STORAGE_KEY_STICKY_ACTIONS = 'plannotator-sticky-actions-enabled'; -const STORAGE_KEY_UI_FEATURES_CONFIGURED = 'plannotator-ui-features-configured'; const STORAGE_KEY_PLAN_WIDTH = 'plannotator-plan-width'; export type PlanWidth = 'compact' | 'default' | 'wide'; @@ -33,11 +32,3 @@ export function saveUIPreferences(prefs: UIPreferences): void { storage.setItem(STORAGE_KEY_STICKY_ACTIONS, String(prefs.stickyActionsEnabled)); storage.setItem(STORAGE_KEY_PLAN_WIDTH, prefs.planWidth); } - -export function needsUIFeaturesSetup(): boolean { - return storage.getItem(STORAGE_KEY_UI_FEATURES_CONFIGURED) !== 'true'; -} - -export function markUIFeaturesSetupDone(): void { - storage.setItem(STORAGE_KEY_UI_FEATURES_CONFIGURED, 'true'); -} diff --git a/packages/ui/utils/whatsNew.ts b/packages/ui/utils/whatsNew.ts deleted file mode 100644 index b1247784..00000000 --- a/packages/ui/utils/whatsNew.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { storage } from './storage'; - -const STORAGE_KEY = 'plannotator-whats-new-v011-seen'; - -export function needsWhatsNewDialog(): boolean { - return storage.getItem(STORAGE_KEY) !== 'true'; -} - -export function markWhatsNewSeen(): void { - storage.setItem(STORAGE_KEY, 'true'); -}