mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e5f3cfffbb
* feat: add Obsidian vault browser sidebar tab (#207) Add a third sidebar tab that lets users browse their Obsidian vault's file tree, click any markdown file to open and annotate it as part of plan feedback. Includes wikilink support for navigating between vault files. - New /api/reference/obsidian/files and /api/reference/obsidian/doc endpoints - useVaultBrowser hook and VaultBrowser sidebar component - Wikilink [[filename]] and [[filename|display]] detection in Viewer - "Vault Browser" toggle in Settings > Obsidian - Bare filename search for wikilink resolution within vault Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update docs and clear vault state on disable - Add new reference endpoints to CLAUDE.md API table - Update stale doc comments referencing "two tabs" in sidebar files - Clear vaultPath and activeFile when vault browser is disabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify vault browser prop passing and derived state - Move VaultNode to packages/ui/types.ts (shared type location) - Pass useVaultBrowser return as single object prop through SidebarContainer - Replace showVaultTab/vaultPath useState with useMemo derivations - Extract buildVaultDocUrl helper to deduplicate URL template - Wrap onVaultFetchTree in useCallback - Fix toggle switch size to match existing toggles (h-6 w-11) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1540 lines
60 KiB
TypeScript
1540 lines
60 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, extractFrontmatter, Frontmatter } from '@plannotator/ui/utils/parser';
|
|
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
|
|
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
|
|
import { ExportModal } from '@plannotator/ui/components/ExportModal';
|
|
import { ImportModal } from '@plannotator/ui/components/ImportModal';
|
|
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
|
|
import { Annotation, Block, EditorMode, type ImageAttachment } from '@plannotator/ui/types';
|
|
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
|
|
import { ModeToggle } from '@plannotator/ui/components/ModeToggle';
|
|
import { ModeSwitcher } from '@plannotator/ui/components/ModeSwitcher';
|
|
import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning';
|
|
import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
|
|
import { Settings } from '@plannotator/ui/components/Settings';
|
|
import { useSharing } from '@plannotator/ui/hooks/useSharing';
|
|
import { useAgents } from '@plannotator/ui/hooks/useAgents';
|
|
import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection';
|
|
import { storage } from '@plannotator/ui/utils/storage';
|
|
import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay';
|
|
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
|
|
import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian';
|
|
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 } from '@plannotator/ui/utils/uiPreferences';
|
|
import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode';
|
|
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
|
|
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
|
|
import {
|
|
getPermissionModeSettings,
|
|
needsPermissionModeSetup,
|
|
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 { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator';
|
|
import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
|
|
import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
|
|
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
|
|
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
|
|
import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser';
|
|
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
|
|
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 connections and operational transforms.
|
|
|
|
### 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
|
|
\`\`\`
|
|
|
|
## 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);
|
|
\`\`\`
|
|
|
|
## 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<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
|
|
}
|
|
}
|
|
}
|
|
\`\`\`
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
### 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
|
|
`;
|
|
|
|
const App: React.FC = () => {
|
|
const [markdown, setMarkdown] = useState(PLAN_CONTENT);
|
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
|
const [blocks, setBlocks] = useState<Block[]>([]);
|
|
const [frontmatter, setFrontmatter] = useState<Frontmatter | null>(null);
|
|
const [showExport, setShowExport] = useState(false);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false);
|
|
const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false);
|
|
const [showAgentWarning, setShowAgentWarning] = useState(false);
|
|
const [agentWarningMessage, setAgentWarningMessage] = useState('');
|
|
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
|
const [editorMode, setEditorMode] = useState<EditorMode>(getEditorMode);
|
|
const [taterMode, setTaterMode] = useState(() => {
|
|
const stored = storage.getItem('plannotator-tater-mode');
|
|
return stored === 'true';
|
|
});
|
|
const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences());
|
|
const [isApiMode, setIsApiMode] = useState(false);
|
|
const [origin, setOrigin] = useState<'claude-code' | 'opencode' | 'pi' | null>(null);
|
|
const [globalAttachments, setGlobalAttachments] = useState<ImageAttachment[]>([]);
|
|
const [annotateMode, setAnnotateMode] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
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 [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
|
|
const [sharingEnabled, setSharingEnabled] = useState(true);
|
|
const [shareBaseUrl, setShareBaseUrl] = useState<string | undefined>(undefined);
|
|
const [pasteApiUrl, setPasteApiUrl] = useState<string | undefined>(undefined);
|
|
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
|
|
const [showExportDropdown, setShowExportDropdown] = useState(false);
|
|
const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>();
|
|
const [noteSaveToast, setNoteSaveToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
// Plan diff state
|
|
const [isPlanDiffActive, setIsPlanDiffActive] = useState(false);
|
|
const [planDiffMode, setPlanDiffMode] = useState<PlanDiffMode>('clean');
|
|
const [previousPlan, setPreviousPlan] = useState<string | null>(null);
|
|
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
|
|
|
|
const viewerRef = useRef<ViewerHandle>(null);
|
|
const containerRef = useRef<HTMLElement>(null);
|
|
|
|
// Resizable panels
|
|
const panelResize = useResizablePanel({ storageKey: 'plannotator-panel-width' });
|
|
const tocResize = useResizablePanel({
|
|
storageKey: 'plannotator-toc-width',
|
|
defaultWidth: 240, minWidth: 160, maxWidth: 400, side: 'left',
|
|
});
|
|
const isResizing = panelResize.isDragging || tocResize.isDragging;
|
|
|
|
// Sidebar (shared TOC + Version Browser)
|
|
const sidebar = useSidebar(getUIPreferences().tocEnabled);
|
|
|
|
// Sync sidebar open state when preference changes in Settings
|
|
useEffect(() => {
|
|
if (uiPrefs.tocEnabled) {
|
|
sidebar.open('toc');
|
|
} else {
|
|
sidebar.close();
|
|
}
|
|
}, [uiPrefs.tocEnabled]);
|
|
|
|
// Clear diff view when switching away from versions tab
|
|
useEffect(() => {
|
|
if (sidebar.activeTab === 'toc' && isPlanDiffActive) {
|
|
setIsPlanDiffActive(false);
|
|
}
|
|
}, [sidebar.activeTab]);
|
|
|
|
// Clear diff view on Escape key
|
|
useEffect(() => {
|
|
if (!isPlanDiffActive) return;
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
setIsPlanDiffActive(false);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isPlanDiffActive]);
|
|
|
|
// Plan diff computation
|
|
const planDiff = usePlanDiff(markdown, previousPlan, versionInfo);
|
|
|
|
// Linked document navigation
|
|
const linkedDocHook = useLinkedDoc({
|
|
markdown, annotations, selectedAnnotationId, globalAttachments,
|
|
setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments,
|
|
viewerRef, sidebar,
|
|
});
|
|
|
|
// Obsidian vault browser
|
|
const vaultBrowser = useVaultBrowser();
|
|
|
|
const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]);
|
|
const vaultPath = useMemo(() => {
|
|
if (!showVaultTab) return '';
|
|
const settings = getObsidianSettings();
|
|
return getEffectiveVaultPath(settings);
|
|
}, [showVaultTab, uiPrefs]);
|
|
|
|
// Clear active file when vault browser is disabled
|
|
useEffect(() => {
|
|
if (!showVaultTab) vaultBrowser.setActiveFile(null);
|
|
}, [showVaultTab]);
|
|
|
|
// Auto-fetch vault tree when vault tab is first opened
|
|
useEffect(() => {
|
|
if (sidebar.activeTab === 'vault' && showVaultTab && vaultPath && vaultBrowser.tree.length === 0 && !vaultBrowser.isLoading) {
|
|
vaultBrowser.fetchTree(vaultPath);
|
|
}
|
|
}, [sidebar.activeTab, showVaultTab, vaultPath]);
|
|
|
|
const buildVaultDocUrl = React.useCallback(
|
|
(vp: string) => (path: string) =>
|
|
`/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(vp)}&path=${encodeURIComponent(path)}`,
|
|
[]
|
|
);
|
|
|
|
// Vault file selection: open via linked doc system with vault endpoint
|
|
const handleVaultFileSelect = React.useCallback((relativePath: string) => {
|
|
linkedDocHook.open(relativePath, buildVaultDocUrl(vaultPath));
|
|
vaultBrowser.setActiveFile(relativePath);
|
|
}, [vaultPath, linkedDocHook, vaultBrowser, buildVaultDocUrl]);
|
|
|
|
// Route linked doc opens through vault endpoint when viewing a vault file
|
|
const handleOpenLinkedDoc = React.useCallback((docPath: string) => {
|
|
if (vaultBrowser.activeFile && vaultPath) {
|
|
linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath));
|
|
} else {
|
|
linkedDocHook.open(docPath);
|
|
}
|
|
}, [vaultBrowser.activeFile, vaultPath, linkedDocHook, buildVaultDocUrl]);
|
|
|
|
// Wrap linked doc back to also clear vault active file
|
|
const handleLinkedDocBack = React.useCallback(() => {
|
|
linkedDocHook.back();
|
|
vaultBrowser.setActiveFile(null);
|
|
}, [linkedDocHook, vaultBrowser]);
|
|
|
|
const handleVaultFetchTree = React.useCallback(() => {
|
|
vaultBrowser.fetchTree(vaultPath);
|
|
}, [vaultBrowser, vaultPath]);
|
|
|
|
// Track active section for TOC highlighting
|
|
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
|
|
const activeSection = useActiveSection(containerRef, headingCount);
|
|
|
|
// URL-based sharing
|
|
const {
|
|
isSharedSession,
|
|
isLoadingShared,
|
|
shareUrl,
|
|
shareUrlSize,
|
|
shortShareUrl,
|
|
isGeneratingShortUrl,
|
|
shortUrlError,
|
|
pendingSharedAnnotations,
|
|
sharedGlobalAttachments,
|
|
clearPendingSharedAnnotations,
|
|
generateShortUrl,
|
|
importFromShareUrl,
|
|
shareLoadError,
|
|
clearShareLoadError,
|
|
} = useSharing(
|
|
markdown,
|
|
annotations,
|
|
globalAttachments,
|
|
setMarkdown,
|
|
setAnnotations,
|
|
setGlobalAttachments,
|
|
() => {
|
|
// When loaded from share, mark as loaded
|
|
setIsLoading(false);
|
|
},
|
|
shareBaseUrl,
|
|
pasteApiUrl
|
|
);
|
|
|
|
// Fetch available agents for OpenCode (for validation on approve)
|
|
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin);
|
|
|
|
// 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 handleTaterModeChange = (enabled: boolean) => {
|
|
setTaterMode(enabled);
|
|
storage.setItem('plannotator-tater-mode', String(enabled));
|
|
};
|
|
|
|
const handleEditorModeChange = (mode: EditorMode) => {
|
|
setEditorMode(mode);
|
|
saveEditorMode(mode);
|
|
};
|
|
|
|
// 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; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => {
|
|
if (data.plan) setMarkdown(data.plan);
|
|
setIsApiMode(true);
|
|
if (data.mode === 'annotate') {
|
|
setAnnotateMode(true);
|
|
}
|
|
if (data.sharingEnabled !== undefined) {
|
|
setSharingEnabled(data.sharingEnabled);
|
|
}
|
|
if (data.shareBaseUrl) {
|
|
setShareBaseUrl(data.shareBaseUrl);
|
|
}
|
|
if (data.pasteApiUrl) {
|
|
setPasteApiUrl(data.pasteApiUrl);
|
|
}
|
|
if (data.repoInfo) {
|
|
setRepoInfo(data.repoInfo);
|
|
}
|
|
// Capture plan version history data
|
|
if (data.previousPlan !== undefined) {
|
|
setPreviousPlan(data.previousPlan);
|
|
}
|
|
if (data.versionInfo) {
|
|
setVersionInfo(data.versionInfo);
|
|
}
|
|
if (data.origin) {
|
|
setOrigin(data.origin);
|
|
// 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);
|
|
}
|
|
// Load saved permission mode preference
|
|
setPermissionMode(getPermissionModeSettings().mode);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Not in API mode - use default content
|
|
setIsApiMode(false);
|
|
})
|
|
.finally(() => setIsLoading(false));
|
|
}, [isLoadingShared, isSharedSession]);
|
|
|
|
useEffect(() => {
|
|
const { frontmatter: fm } = extractFrontmatter(markdown);
|
|
setFrontmatter(fm);
|
|
setBlocks(parseMarkdownToBlocks(markdown));
|
|
}, [markdown]);
|
|
|
|
// Global paste listener for image attachments
|
|
useEffect(() => {
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
// Derive name before showing annotator so user sees it immediately
|
|
const initialName = deriveImageName(file.name, globalAttachments.map(g => g.name));
|
|
const blobUrl = URL.createObjectURL(file);
|
|
setPendingPasteImage({ file, blobUrl, initialName });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('paste', handlePaste);
|
|
return () => document.removeEventListener('paste', handlePaste);
|
|
}, [globalAttachments]);
|
|
|
|
// Handle paste annotator accept — name comes from ImageAnnotator
|
|
const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => {
|
|
if (!pendingPasteImage) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
const fileToUpload = hasDrawings
|
|
? new File([blob], 'annotated.png', { type: 'image/png' })
|
|
: pendingPasteImage.file;
|
|
formData.append('file', fileToUpload);
|
|
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setGlobalAttachments(prev => [...prev, { path: data.path, name }]);
|
|
}
|
|
} catch {
|
|
// Upload failed silently
|
|
} finally {
|
|
URL.revokeObjectURL(pendingPasteImage.blobUrl);
|
|
setPendingPasteImage(null);
|
|
}
|
|
};
|
|
|
|
const handlePasteAnnotatorClose = () => {
|
|
if (pendingPasteImage) {
|
|
URL.revokeObjectURL(pendingPasteImage.blobUrl);
|
|
setPendingPasteImage(null);
|
|
}
|
|
};
|
|
|
|
// API mode handlers
|
|
const handleApprove = async () => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
const obsidianSettings = getObsidianSettings();
|
|
const bearSettings = getBearSettings();
|
|
const agentSwitchSettings = getAgentSwitchSettings();
|
|
const planSaveSettings = getPlanSaveSettings();
|
|
|
|
// Build request body - include integrations if enabled
|
|
const body: { obsidian?: object; bear?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
|
|
|
|
// Include permission mode for Claude Code
|
|
if (origin === 'claude-code') {
|
|
body.permissionMode = permissionMode;
|
|
}
|
|
|
|
// Include agent switch setting for OpenCode (effective name handles custom agents)
|
|
const effectiveAgent = getEffectiveAgentName(agentSwitchSettings);
|
|
if (effectiveAgent) {
|
|
body.agentSwitch = effectiveAgent;
|
|
}
|
|
|
|
// Include plan save settings
|
|
body.planSave = {
|
|
enabled: planSaveSettings.enabled,
|
|
...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }),
|
|
};
|
|
|
|
const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings);
|
|
if (obsidianSettings.enabled && effectiveVaultPath) {
|
|
body.obsidian = {
|
|
vaultPath: effectiveVaultPath,
|
|
folder: obsidianSettings.folder || 'plannotator',
|
|
plan: markdown,
|
|
...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }),
|
|
};
|
|
}
|
|
|
|
if (bearSettings.enabled) {
|
|
body.bear = { plan: markdown };
|
|
}
|
|
|
|
// Include annotations as feedback if any exist (for OpenCode "approve with notes")
|
|
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
|
|
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
|
|
);
|
|
if (annotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations) {
|
|
body.feedback = annotationsOutput;
|
|
}
|
|
|
|
await fetch('/api/approve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
setSubmitted('approved');
|
|
} catch {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeny = async () => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
const planSaveSettings = getPlanSaveSettings();
|
|
await fetch('/api/deny', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
feedback: annotationsOutput,
|
|
planSave: {
|
|
enabled: planSaveSettings.enabled,
|
|
...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }),
|
|
},
|
|
})
|
|
});
|
|
setSubmitted('denied');
|
|
} catch {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Annotate mode handler — sends feedback via /api/feedback
|
|
const handleAnnotateFeedback = async () => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
await fetch('/api/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
feedback: annotationsOutput,
|
|
annotations,
|
|
}),
|
|
});
|
|
setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay
|
|
} catch {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Global keyboard shortcuts (Cmd/Ctrl+Enter to submit)
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Only handle Cmd/Ctrl+Enter
|
|
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return;
|
|
|
|
// Don't intercept if typing in an input/textarea
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
|
|
// Don't intercept if any modal is open
|
|
if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning ||
|
|
showAgentWarning || showPermissionModeSetup || showUIFeaturesSetup || showPlanDiffMarketing || pendingPasteImage) return;
|
|
|
|
// Don't intercept if already submitted or submitting
|
|
if (submitted || isSubmitting) return;
|
|
|
|
// Don't intercept in demo/share mode (no API)
|
|
if (!isApiMode) return;
|
|
|
|
// Don't submit while viewing a linked doc
|
|
if (linkedDocHook.isActive) return;
|
|
|
|
e.preventDefault();
|
|
|
|
// Annotate mode: always send feedback
|
|
if (annotateMode) {
|
|
if (annotations.length === 0) {
|
|
setShowFeedbackPrompt(true);
|
|
} else {
|
|
handleAnnotateFeedback();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No annotations → Approve, otherwise → Send Feedback
|
|
if (annotations.length === 0) {
|
|
// Check if agent exists for OpenCode users
|
|
if (origin === 'opencode') {
|
|
const warning = getAgentWarning();
|
|
if (warning) {
|
|
setAgentWarningMessage(warning);
|
|
setShowAgentWarning(true);
|
|
return;
|
|
}
|
|
}
|
|
handleApprove();
|
|
} else {
|
|
handleDeny();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [
|
|
showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning,
|
|
showPermissionModeSetup, showUIFeaturesSetup, showPlanDiffMarketing, pendingPasteImage,
|
|
submitted, isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, annotateMode,
|
|
origin, getAgentWarning,
|
|
]);
|
|
|
|
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 handleEditAnnotation = (id: string, updates: Partial<Annotation>) => {
|
|
setAnnotations(prev => prev.map(ann =>
|
|
ann.id === id ? { ...ann, ...updates } : ann
|
|
));
|
|
};
|
|
|
|
const handleIdentityChange = (oldIdentity: string, newIdentity: string) => {
|
|
setAnnotations(prev => prev.map(ann =>
|
|
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
|
|
));
|
|
};
|
|
|
|
const handleAddGlobalAttachment = (image: ImageAttachment) => {
|
|
setGlobalAttachments(prev => [...prev, image]);
|
|
};
|
|
|
|
const handleRemoveGlobalAttachment = (path: string) => {
|
|
setGlobalAttachments(prev => prev.filter(p => p.path !== path));
|
|
};
|
|
|
|
|
|
const handleTocNavigate = (blockId: string) => {
|
|
// Navigation handled by TableOfContents component
|
|
// This is just a placeholder for future custom logic
|
|
};
|
|
|
|
const annotationsOutput = useMemo(() => {
|
|
const docAnnotations = linkedDocHook.getDocAnnotations();
|
|
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
|
|
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
|
|
);
|
|
const hasPlanAnnotations = annotations.length > 0 || globalAttachments.length > 0;
|
|
|
|
if (!hasPlanAnnotations && !hasDocAnnotations) {
|
|
return 'No changes detected.';
|
|
}
|
|
|
|
let output = hasPlanAnnotations
|
|
? exportAnnotations(blocks, annotations, globalAttachments)
|
|
: '';
|
|
|
|
if (hasDocAnnotations) {
|
|
output += exportLinkedDocAnnotations(docAnnotations);
|
|
}
|
|
|
|
return output;
|
|
}, [blocks, annotations, globalAttachments, linkedDocHook.getDocAnnotations]);
|
|
|
|
// Quick-save handlers for export dropdown and keyboard shortcut
|
|
const handleDownloadAnnotations = () => {
|
|
setShowExportDropdown(false);
|
|
const blob = new Blob([annotationsOutput], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'annotations.md';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
setNoteSaveToast({ type: 'success', message: 'Downloaded annotations' });
|
|
setTimeout(() => setNoteSaveToast(null), 3000);
|
|
};
|
|
|
|
const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear') => {
|
|
setShowExportDropdown(false);
|
|
const body: { obsidian?: object; bear?: object } = {};
|
|
|
|
if (target === 'obsidian') {
|
|
const s = getObsidianSettings();
|
|
const vaultPath = getEffectiveVaultPath(s);
|
|
if (vaultPath) {
|
|
body.obsidian = {
|
|
vaultPath,
|
|
folder: s.folder || 'plannotator',
|
|
plan: markdown,
|
|
...(s.filenameFormat && { filenameFormat: s.filenameFormat }),
|
|
};
|
|
}
|
|
}
|
|
if (target === 'bear') {
|
|
body.bear = { plan: markdown };
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/save-notes', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
const result = data.results?.[target];
|
|
if (result?.success) {
|
|
setNoteSaveToast({ type: 'success', message: `Saved to ${target === 'obsidian' ? 'Obsidian' : 'Bear'}` });
|
|
} else {
|
|
setNoteSaveToast({ type: 'error', message: result?.error || 'Save failed' });
|
|
}
|
|
} catch {
|
|
setNoteSaveToast({ type: 'error', message: 'Save failed' });
|
|
}
|
|
setTimeout(() => setNoteSaveToast(null), 3000);
|
|
};
|
|
|
|
// Cmd/Ctrl+S keyboard shortcut — save to default notes app
|
|
useEffect(() => {
|
|
const handleSaveShortcut = (e: KeyboardEvent) => {
|
|
if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return;
|
|
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
|
|
if (showExport || showFeedbackPrompt || showClaudeCodeWarning ||
|
|
showAgentWarning || showPermissionModeSetup || showUIFeaturesSetup || showPlanDiffMarketing || pendingPasteImage) return;
|
|
|
|
if (submitted || !isApiMode) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const defaultApp = getDefaultNotesApp();
|
|
const obsOk = isObsidianConfigured();
|
|
const bearOk = getBearSettings().enabled;
|
|
|
|
if (defaultApp === 'download') {
|
|
handleDownloadAnnotations();
|
|
} else if (defaultApp === 'obsidian' && obsOk) {
|
|
handleQuickSaveToNotes('obsidian');
|
|
} else if (defaultApp === 'bear' && bearOk) {
|
|
handleQuickSaveToNotes('bear');
|
|
} else {
|
|
setInitialExportTab('notes');
|
|
setShowExport(true);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleSaveShortcut);
|
|
return () => window.removeEventListener('keydown', handleSaveShortcut);
|
|
}, [
|
|
showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning,
|
|
showPermissionModeSetup, showUIFeaturesSetup, showPlanDiffMarketing, pendingPasteImage,
|
|
submitted, isApiMode, markdown, annotationsOutput,
|
|
]);
|
|
|
|
// Close export dropdown on click outside
|
|
useEffect(() => {
|
|
if (!showExportDropdown) return;
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (!target.closest('[data-export-dropdown]')) {
|
|
setShowExportDropdown(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [showExportDropdown]);
|
|
|
|
const agentName = useMemo(() => {
|
|
if (origin === 'opencode') return 'OpenCode';
|
|
if (origin === 'claude-code') return 'Claude Code';
|
|
if (origin === 'pi') return 'Pi';
|
|
return 'Coding Agent';
|
|
}, [origin]);
|
|
|
|
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 sticky top-0 z-20">
|
|
<div className="flex items-center gap-2 md:gap-3">
|
|
<a
|
|
href="https://plannotator.ai"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
|
|
>
|
|
<span className="text-sm font-semibold tracking-tight">Plannotator</span>
|
|
</a>
|
|
<a
|
|
href="https://github.com/backnotprop/plannotator/releases"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-muted-foreground font-mono opacity-60 hidden md:inline hover:opacity-100 transition-opacity"
|
|
>
|
|
v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'}
|
|
</a>
|
|
{origin && (
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium hidden md:inline ${
|
|
origin === 'claude-code'
|
|
? 'bg-orange-500/15 text-orange-400'
|
|
: origin === 'pi'
|
|
? 'bg-violet-500/15 text-violet-400'
|
|
: 'bg-zinc-500/20 text-zinc-400'
|
|
}`}>
|
|
{agentName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 md:gap-2">
|
|
{isApiMode && !linkedDocHook.isActive && (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
if (annotations.length === 0) {
|
|
setShowFeedbackPrompt(true);
|
|
} else if (annotateMode) {
|
|
handleAnnotateFeedback();
|
|
} 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="Send 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...' : annotateMode ? 'Send Annotations' : 'Send Feedback'}</span>
|
|
</button>
|
|
|
|
{!annotateMode && <div className="relative group/approve">
|
|
<button
|
|
onClick={() => {
|
|
// Show warning for Claude Code users with annotations
|
|
if (origin === 'claude-code' && annotations.length > 0) {
|
|
setShowClaudeCodeWarning(true);
|
|
return;
|
|
}
|
|
|
|
// Check if agent exists for OpenCode users
|
|
if (origin === 'opencode') {
|
|
const warning = getAgentWarning();
|
|
if (warning) {
|
|
setAgentWarningMessage(warning);
|
|
setShowAgentWarning(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
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'
|
|
: origin === 'claude-code' && annotations.length > 0
|
|
? 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground'
|
|
: 'bg-success text-success-foreground hover:opacity-90'
|
|
}`}
|
|
>
|
|
<span className="md:hidden">{isSubmitting ? '...' : 'OK'}</span>
|
|
<span className="hidden md:inline">{isSubmitting ? 'Approving...' : 'Approve'}</span>
|
|
</button>
|
|
{origin === 'claude-code' && annotations.length > 0 && (
|
|
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
|
|
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
|
|
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
|
|
{agentName} doesn't support feedback on approval. Your annotations won't be seen.
|
|
</div>
|
|
)}
|
|
</div>}
|
|
|
|
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
|
|
</>
|
|
)}
|
|
|
|
<ModeToggle />
|
|
{!linkedDocHook.isActive && <Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} origin={origin} onUIPreferencesChange={setUiPrefs} />}
|
|
|
|
<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>
|
|
|
|
<div className="relative flex" data-export-dropdown>
|
|
<button
|
|
onClick={() => { setInitialExportTab(undefined); setShowExport(true); }}
|
|
className="p-1.5 md:px-2.5 md:py-1 rounded-l-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>
|
|
<button
|
|
onClick={() => setShowExportDropdown(prev => !prev)}
|
|
className="px-1 md:px-1.5 rounded-r-md text-xs bg-muted hover:bg-muted/80 border-l border-border/50 transition-colors flex items-center"
|
|
title="Quick save options"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{showExportDropdown && (
|
|
<div className="absolute top-full right-0 mt-1 w-48 bg-popover border border-border rounded-lg shadow-xl z-50 py-1">
|
|
{sharingEnabled && (
|
|
<button
|
|
onClick={async () => {
|
|
setShowExportDropdown(false);
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
setNoteSaveToast({ type: 'success', message: 'Share link copied' });
|
|
} catch {
|
|
setNoteSaveToast({ type: 'error', message: 'Failed to copy' });
|
|
}
|
|
setTimeout(() => setNoteSaveToast(null), 3000);
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
Copy Share Link
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleDownloadAnnotations}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground" 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-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
Download Annotations
|
|
</button>
|
|
{isApiMode && isObsidianConfigured() && (
|
|
<button
|
|
onClick={() => handleQuickSaveToNotes('obsidian')}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
|
</svg>
|
|
Save to Obsidian
|
|
</button>
|
|
)}
|
|
{isApiMode && getBearSettings().enabled && (
|
|
<button
|
|
onClick={() => handleQuickSaveToNotes('bear')}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
|
</svg>
|
|
Save to Bear
|
|
</button>
|
|
)}
|
|
{isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && (
|
|
<div className="px-3 py-2 text-[10px] text-muted-foreground">
|
|
No notes apps configured.
|
|
</div>
|
|
)}
|
|
{sharingEnabled && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
<button
|
|
onClick={() => {
|
|
setShowExportDropdown(false);
|
|
setShowImport(true);
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
|
|
>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground" 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-1m4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
Import Review
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Linked document error banner */}
|
|
{linkedDocHook.error && (
|
|
<div className="bg-destructive/10 border-b border-destructive/20 px-4 py-2 flex items-center gap-2 flex-shrink-0">
|
|
<span className="text-xs text-destructive">{linkedDocHook.error}</span>
|
|
<button
|
|
onClick={linkedDocHook.dismissError}
|
|
className="ml-auto text-xs text-destructive/60 hover:text-destructive"
|
|
>
|
|
dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content */}
|
|
<div className={`flex-1 flex overflow-hidden ${isResizing ? 'select-none' : ''}`}>
|
|
{/* Left Sidebar: collapsed tab flags (when sidebar is closed) */}
|
|
{!sidebar.isOpen && (
|
|
<SidebarTabs
|
|
activeTab={sidebar.activeTab}
|
|
onToggleTab={sidebar.toggleTab}
|
|
hasDiff={planDiff.hasPreviousVersion}
|
|
showVaultTab={showVaultTab}
|
|
className="hidden lg:flex"
|
|
/>
|
|
)}
|
|
|
|
{/* Left Sidebar: open state (TOC or Version Browser) */}
|
|
{sidebar.isOpen && (
|
|
<>
|
|
<SidebarContainer
|
|
activeTab={sidebar.activeTab}
|
|
onTabChange={sidebar.toggleTab}
|
|
onClose={sidebar.close}
|
|
width={tocResize.width}
|
|
blocks={blocks}
|
|
annotations={annotations}
|
|
activeSection={activeSection}
|
|
onTocNavigate={handleTocNavigate}
|
|
linkedDocFilepath={linkedDocHook.filepath}
|
|
onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined}
|
|
showVaultTab={showVaultTab}
|
|
vaultPath={vaultPath}
|
|
vaultBrowser={vaultBrowser}
|
|
onVaultSelectFile={handleVaultFileSelect}
|
|
onVaultFetchTree={handleVaultFetchTree}
|
|
versionInfo={versionInfo}
|
|
versions={planDiff.versions}
|
|
projectPlans={planDiff.projectPlans}
|
|
selectedBaseVersion={planDiff.diffBaseVersion}
|
|
onSelectBaseVersion={planDiff.selectBaseVersion}
|
|
isPlanDiffActive={isPlanDiffActive}
|
|
hasPreviousVersion={planDiff.hasPreviousVersion}
|
|
onActivatePlanDiff={() => setIsPlanDiffActive(true)}
|
|
isLoadingVersions={planDiff.isLoadingVersions}
|
|
isSelectingVersion={planDiff.isSelectingVersion}
|
|
fetchingVersion={planDiff.fetchingVersion}
|
|
onFetchVersions={planDiff.fetchVersions}
|
|
onFetchProjectPlans={planDiff.fetchProjectPlans}
|
|
/>
|
|
<ResizeHandle {...tocResize.handleProps} className="hidden lg:block" />
|
|
</>
|
|
)}
|
|
|
|
{/* Document Area */}
|
|
<main ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
|
|
<div className="min-h-full flex flex-col items-center px-4 py-3 md:px-10 md:py-8 xl:px-16">
|
|
{/* Mode Switcher (hidden during plan diff) */}
|
|
{!isPlanDiffActive && (
|
|
<div className="w-full max-w-[832px] 2xl:max-w-5xl mb-3 md:mb-4 flex justify-start">
|
|
<ModeSwitcher mode={editorMode} onChange={handleEditorModeChange} taterMode={taterMode} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Plan Diff View or Normal Plan View */}
|
|
{isPlanDiffActive && planDiff.diffBlocks && planDiff.diffStats ? (
|
|
<PlanDiffViewer
|
|
diffBlocks={planDiff.diffBlocks}
|
|
diffStats={planDiff.diffStats}
|
|
diffMode={planDiffMode}
|
|
onDiffModeChange={setPlanDiffMode}
|
|
onPlanDiffToggle={() => setIsPlanDiffActive(false)}
|
|
repoInfo={repoInfo}
|
|
baseVersionLabel={planDiff.diffBaseVersion != null ? `v${planDiff.diffBaseVersion}` : undefined}
|
|
baseVersion={planDiff.diffBaseVersion ?? undefined}
|
|
/>
|
|
) : (
|
|
<Viewer
|
|
key={linkedDocHook.isActive ? `doc:${linkedDocHook.filepath}` : 'plan'}
|
|
ref={viewerRef}
|
|
blocks={blocks}
|
|
markdown={markdown}
|
|
frontmatter={frontmatter}
|
|
annotations={annotations}
|
|
onAddAnnotation={handleAddAnnotation}
|
|
onSelectAnnotation={setSelectedAnnotationId}
|
|
selectedAnnotationId={selectedAnnotationId}
|
|
mode={editorMode}
|
|
taterMode={taterMode}
|
|
globalAttachments={globalAttachments}
|
|
onAddGlobalAttachment={handleAddGlobalAttachment}
|
|
onRemoveGlobalAttachment={handleRemoveGlobalAttachment}
|
|
repoInfo={repoInfo}
|
|
stickyActions={uiPrefs.stickyActionsEnabled}
|
|
planDiffStats={linkedDocHook.isActive ? null : planDiff.diffStats}
|
|
isPlanDiffActive={isPlanDiffActive}
|
|
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
|
|
hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion}
|
|
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
|
|
onOpenLinkedDoc={handleOpenLinkedDoc}
|
|
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : undefined } : null}
|
|
/>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Resize Handle */}
|
|
{isPanelOpen && <ResizeHandle {...panelResize.handleProps} />}
|
|
|
|
{/* Annotation Panel */}
|
|
<AnnotationPanel
|
|
isOpen={isPanelOpen}
|
|
blocks={blocks}
|
|
annotations={annotations}
|
|
selectedId={selectedAnnotationId}
|
|
onSelect={setSelectedAnnotationId}
|
|
onDelete={handleDeleteAnnotation}
|
|
onEdit={handleEditAnnotation}
|
|
shareUrl={shareUrl}
|
|
sharingEnabled={sharingEnabled}
|
|
width={panelResize.width}
|
|
/>
|
|
</div>
|
|
|
|
{/* Export Modal */}
|
|
<ExportModal
|
|
isOpen={showExport}
|
|
onClose={() => { setShowExport(false); setInitialExportTab(undefined); }}
|
|
shareUrl={shareUrl}
|
|
shareUrlSize={shareUrlSize}
|
|
shortShareUrl={shortShareUrl}
|
|
isGeneratingShortUrl={isGeneratingShortUrl}
|
|
shortUrlError={shortUrlError}
|
|
onGenerateShortUrl={generateShortUrl}
|
|
annotationsOutput={annotationsOutput}
|
|
annotationCount={annotations.length}
|
|
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
|
|
sharingEnabled={sharingEnabled}
|
|
markdown={markdown}
|
|
isApiMode={isApiMode}
|
|
initialTab={initialExportTab}
|
|
/>
|
|
|
|
{/* Import Modal */}
|
|
<ImportModal
|
|
isOpen={showImport}
|
|
onClose={() => setShowImport(false)}
|
|
onImport={importFromShareUrl}
|
|
shareBaseUrl={shareBaseUrl}
|
|
/>
|
|
|
|
{/* Feedback prompt dialog */}
|
|
<ConfirmDialog
|
|
isOpen={showFeedbackPrompt}
|
|
onClose={() => setShowFeedbackPrompt(false)}
|
|
title="Add Annotations First"
|
|
message={`To provide feedback, select text in the plan and add annotations. ${agentName} will use your annotations to revise the plan.`}
|
|
variant="info"
|
|
/>
|
|
|
|
{/* Claude Code annotation warning dialog */}
|
|
<ConfirmDialog
|
|
isOpen={showClaudeCodeWarning}
|
|
onClose={() => setShowClaudeCodeWarning(false)}
|
|
onConfirm={() => {
|
|
setShowClaudeCodeWarning(false);
|
|
handleApprove();
|
|
}}
|
|
title="Annotations Won't Be Sent"
|
|
message={<>{agentName} doesn't yet support feedback on approval. Your {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} will be lost.</>}
|
|
subMessage={
|
|
<>
|
|
To send feedback, use <strong>Send Feedback</strong> instead.
|
|
<br /><br />
|
|
Want this feature? Upvote these issues:
|
|
<br />
|
|
<a href="https://github.com/anthropics/claude-code/issues/16001" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">#16001</a>
|
|
{' · '}
|
|
<a href="https://github.com/anthropics/claude-code/issues/15755" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">#15755</a>
|
|
</>
|
|
}
|
|
confirmText="Approve Anyway"
|
|
cancelText="Cancel"
|
|
variant="warning"
|
|
showCancel
|
|
/>
|
|
|
|
{/* OpenCode agent not found warning dialog */}
|
|
<ConfirmDialog
|
|
isOpen={showAgentWarning}
|
|
onClose={() => setShowAgentWarning(false)}
|
|
onConfirm={() => {
|
|
setShowAgentWarning(false);
|
|
handleApprove();
|
|
}}
|
|
title="Agent Not Found"
|
|
message={agentWarningMessage}
|
|
subMessage={
|
|
<>
|
|
You can change the agent in <strong>Settings</strong>, or approve anyway and OpenCode will use the default agent.
|
|
</>
|
|
}
|
|
confirmText="Approve Anyway"
|
|
cancelText="Cancel"
|
|
variant="warning"
|
|
showCancel
|
|
/>
|
|
|
|
{/* Shared URL load failure warning */}
|
|
<ConfirmDialog
|
|
isOpen={!!shareLoadError && !isApiMode}
|
|
onClose={clearShareLoadError}
|
|
title="Shared Plan Could Not Be Loaded"
|
|
message={shareLoadError}
|
|
subMessage="You are viewing a demo plan. This is sample content — it is not your data or anyone else's."
|
|
variant="warning"
|
|
/>
|
|
|
|
{/* Save-to-notes toast */}
|
|
{noteSaveToast && (
|
|
<div className={`fixed top-16 right-4 z-50 px-3 py-2 rounded-lg text-xs font-medium shadow-lg transition-opacity ${
|
|
noteSaveToast.type === 'success'
|
|
? 'bg-success/15 text-success border border-success/30'
|
|
: 'bg-destructive/15 text-destructive border border-destructive/30'
|
|
}`}>
|
|
{noteSaveToast.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Completion overlay - shown after approve/deny */}
|
|
<CompletionOverlay
|
|
submitted={submitted}
|
|
title={submitted === 'approved' ? 'Plan Approved' : annotateMode ? 'Annotations Sent' : 'Feedback Sent'}
|
|
subtitle={
|
|
submitted === 'approved'
|
|
? `${agentName} will proceed with the implementation.`
|
|
: annotateMode
|
|
? `${agentName} will address your annotations on the file.`
|
|
: `${agentName} will revise the plan based on your annotations.`
|
|
}
|
|
agentLabel={agentName}
|
|
/>
|
|
|
|
{/* Update notification */}
|
|
<UpdateBanner origin={origin} />
|
|
|
|
{/* Image Annotator for pasted images */}
|
|
<ImageAnnotator
|
|
isOpen={!!pendingPasteImage}
|
|
imageSrc={pendingPasteImage?.blobUrl ?? ''}
|
|
initialName={pendingPasteImage?.initialName}
|
|
onAccept={handlePasteAnnotatorAccept}
|
|
onClose={handlePasteAnnotatorClose}
|
|
/>
|
|
|
|
{/* Permission Mode Setup (Claude Code first-time) */}
|
|
<PermissionModeSetup
|
|
isOpen={showPermissionModeSetup}
|
|
onComplete={(mode) => {
|
|
setPermissionMode(mode);
|
|
setShowPermissionModeSetup(false);
|
|
if (needsUIFeaturesSetup()) {
|
|
setShowUIFeaturesSetup(true);
|
|
} else if (needsPlanDiffMarketingDialog()) {
|
|
setShowPlanDiffMarketing(true);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* UI Features Setup (TOC & Sticky Actions) */}
|
|
<UIFeaturesSetup
|
|
isOpen={showUIFeaturesSetup}
|
|
onComplete={(prefs) => {
|
|
setUiPrefs(prefs);
|
|
setShowUIFeaturesSetup(false);
|
|
if (needsPlanDiffMarketingDialog()) {
|
|
setShowPlanDiffMarketing(true);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Plan Diff Marketing (feature announcement) */}
|
|
<PlanDiffMarketing
|
|
isOpen={showPlanDiffMarketing}
|
|
origin={origin}
|
|
onComplete={() => {
|
|
setShowPlanDiffMarketing(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
</ThemeProvider>
|
|
);
|
|
};
|
|
|
|
export default App;
|