diff --git a/AGENTS.md b/AGENTS.md index 3914db3c..732b5c1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,9 +49,10 @@ plannotator/ │ ├── ui/ # Shared React components + theme │ │ ├── theme.css # Single source of truth for color tokens + Tailwind bridge │ │ ├── components/ # Viewer, Toolbar, Settings, etc. +│ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.) │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views │ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser -│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts +│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts │ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c4309b4a..5056dbaf 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { type Origin, getAgentName, getAgentBadge } from '@plannotator/shared/agents'; +import { type Origin, getAgentName } from '@plannotator/shared/agents'; import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter } from '@plannotator/ui/utils/parser'; import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; @@ -8,7 +8,6 @@ import { ImportModal } from '@plannotator/ui/components/ImportModal'; import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment } from '@plannotator/ui/types'; import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; -import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip'; import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; @@ -34,13 +33,12 @@ import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode' import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod'; import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch'; import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode'; -import { modKey } from '@plannotator/ui/utils/platform'; import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport'; import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; -import { MobileMenu } from '@plannotator/ui/components/MobileMenu'; +import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu'; import { getPermissionModeSettings, needsPermissionModeSetup, @@ -58,6 +56,8 @@ import { useArchive } from '@plannotator/ui/hooks/useArchive'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; +import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; +import { hasNewSettings, markNewSettingsSeen } from '@plannotator/ui/utils/newSettingsHint'; import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser'; @@ -89,6 +89,7 @@ const App: React.FC = () => { const [agentWarningMessage, setAgentWarningMessage] = useState(''); const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false); + const [hasNewSettingsHints, setHasNewSettingsHints] = useState(() => hasNewSettings()); const [editorMode, setEditorMode] = useState(getEditorMode); const [inputMethod, setInputMethod] = useState(getInputMethod); const [taterMode, setTaterMode] = useState(() => { @@ -120,7 +121,6 @@ const App: React.FC = () => { document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator"; }, [repoInfo]); - const [showExportDropdown, setShowExportDropdown] = useState(false); const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>(); const [noteSaveToast, setNoteSaveToast] = useState(null); const [isPlanDiffActive, setIsPlanDiffActive] = useState(false); @@ -1066,7 +1066,6 @@ const App: React.FC = () => { // 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'); @@ -1079,7 +1078,6 @@ const App: React.FC = () => { }; const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => { - setShowExportDropdown(false); const body: { obsidian?: object; bear?: object; octarine?: object } = {}; if (target === 'obsidian') { @@ -1132,6 +1130,31 @@ const App: React.FC = () => { setTimeout(() => setNoteSaveToast(null), 3000); }; + // Agent Instructions — copy a clipboard payload teaching external agents + // (Claude Code, Codex, etc.) how to POST annotations into this session via + // /api/external-annotations. The instruction body lives in a separate module + // (utils/agentInstructions.ts) so it's easy to edit independently of UI code. + const handleCopyAgentInstructions = async () => { + const payload = buildPlanAgentInstructions(window.location.origin); + try { + await navigator.clipboard.writeText(payload); + setNoteSaveToast({ type: 'success', message: 'Agent instructions copied' }); + } catch { + setNoteSaveToast({ type: 'error', message: 'Failed to copy' }); + } + setTimeout(() => setNoteSaveToast(null), 3000); + }; + + const handleCopyShareLink = async () => { + 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); + }; + // Cmd/Ctrl+S keyboard shortcut — save to default notes app useEffect(() => { const handleSaveShortcut = (e: KeyboardEvent) => { @@ -1198,19 +1221,6 @@ const App: React.FC = () => { showPermissionModeSetup, pendingPasteImage, submitted, ]); - // Close export dropdown on click outside - useEffect(() => { - if (!showExportDropdown) return; - const handleClickOutside = (e: PointerEvent) => { - const target = e.target as HTMLElement; - if (!target.closest('[data-export-dropdown]')) { - setShowExportDropdown(false); - } - }; - document.addEventListener('pointerdown', handleClickOutside); - return () => document.removeEventListener('pointerdown', handleClickOutside); - }, [showExportDropdown]); - const agentName = useMemo(() => getAgentName(origin), [origin]); const planMaxWidth = useMemo(() => { @@ -1233,19 +1243,6 @@ const App: React.FC = () => { > Plannotator - - v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - - {origin && ( - - )}
@@ -1290,7 +1287,7 @@ const App: React.FC = () => { )} - {isApiMode && !linkedDocHook.isActive && !archive.archiveMode && ( + {isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( <> { @@ -1348,165 +1345,60 @@ const App: React.FC = () => { )} - {/* Desktop buttons — hidden on mobile */} -
- - {!linkedDocHook.isActive && setMobileSettingsOpen(false)} gitUser={gitUser} />} + {/* Annotations panel toggle — top-level header button */} + - - -
- - - - {showExportDropdown && ( -
- {sharingEnabled && ( - - )} - - -
- {isApiMode && isObsidianConfigured() && ( - - )} - {isApiMode && getBearSettings().enabled && ( - - )} - {isApiMode && isOctarineConfigured() && ( - - )} - {isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && !isOctarineConfigured() && ( -
- No notes apps configured. -
- )} - {sharingEnabled && ( - <> -
- - - )} -
- )} -
+ {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */} +
+ setMobileSettingsOpen(false)} + gitUser={gitUser} + />
- {/* Mobile hamburger menu */} - setIsPanelOpen(!isPanelOpen)} - annotationCount={allAnnotations.length + editorAnnotations.length} - onOpenExport={() => { setInitialExportTab(undefined); setShowExport(true); }} - onOpenSettings={() => setMobileSettingsOpen(true)} - onDownloadAnnotations={handleDownloadAnnotations} - onCopyShareLink={async () => { - try { - await navigator.clipboard.writeText(shareUrl); - setNoteSaveToast({ type: 'success', message: 'Share link copied' }); - } catch { - setNoteSaveToast({ type: 'error', message: 'Failed to copy' }); + { + if (hasNewSettingsHints) { + markNewSettingsSeen(); + setHasNewSettingsHints(false); } - setTimeout(() => setNoteSaveToast(null), 3000); + setMobileSettingsOpen(true); }} - onOpenImport={() => setShowImport(true)} + onOpenExport={() => { setInitialExportTab(undefined); setShowExport(true); }} + onCopyAgentInstructions={handleCopyAgentInstructions} + onDownloadAnnotations={handleDownloadAnnotations} onPrint={() => window.print()} + onCopyShareLink={handleCopyShareLink} + onOpenImport={() => setShowImport(true)} + onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')} + onSaveToBear={() => handleQuickSaveToNotes('bear')} + onSaveToOctarine={() => handleQuickSaveToNotes('octarine')} sharingEnabled={sharingEnabled} + isApiMode={isApiMode} + agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} + obsidianConfigured={isObsidianConfigured()} + bearConfigured={getBearSettings().enabled} + octarineConfigured={isOctarineConfigured()} />
diff --git a/packages/editor/index.css b/packages/editor/index.css index d4794ca7..f10a9317 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -182,8 +182,3 @@ pre code.hljs .hljs-code { .sidebar-tab-flag:hover { transform: translateX(2px); } - -@keyframes settings-ping { - 0%, 100% { opacity: 0; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(2); } -} diff --git a/packages/shared/external-annotation.ts b/packages/shared/external-annotation.ts index 0f2b633a..f88cc84b 100644 --- a/packages/shared/external-annotation.ts +++ b/packages/shared/external-annotation.ts @@ -144,6 +144,16 @@ export function transformPlanInput( return { error: `annotations[${i}] DELETION type requires non-empty "originalText" field` }; } + // COMMENT requires originalText so the renderer can pin it to a phrase. + // External agents that want sidebar-only feedback should use GLOBAL_COMMENT + // instead — without a phrase to anchor to, a COMMENT renders as an empty + // quote bubble in the sidebar and exports as `Feedback on: ""`. + if (type === "COMMENT" && (typeof obj.originalText !== "string" || obj.originalText.length === 0)) { + return { + error: `annotations[${i}] COMMENT requires non-empty "originalText" field. Use GLOBAL_COMMENT for sidebar-only feedback.`, + }; + } + annotations.push({ id: crypto.randomUUID(), blockId: "external", diff --git a/packages/ui/components/ActionMenu.tsx b/packages/ui/components/ActionMenu.tsx index 90eda7d4..3ca1f888 100644 --- a/packages/ui/components/ActionMenu.tsx +++ b/packages/ui/components/ActionMenu.tsx @@ -62,6 +62,7 @@ interface ActionMenuItemProps { onClick: () => void; icon: React.ReactNode; label: string; + subtitle?: string; badge?: React.ReactNode; } @@ -69,6 +70,7 @@ export const ActionMenuItem: React.FC = ({ onClick, icon, label, + subtitle, badge, }) => ( ); diff --git a/packages/ui/components/MobileMenu.tsx b/packages/ui/components/MobileMenu.tsx deleted file mode 100644 index c0d8b21a..00000000 --- a/packages/ui/components/MobileMenu.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import React from 'react'; -import { - ActionMenu, - ActionMenuDivider, - ActionMenuItem, - ActionMenuSectionLabel, -} from './ActionMenu'; -import { useTheme } from './ThemeProvider'; - -interface MobileMenuProps { - isPanelOpen: boolean; - onTogglePanel: () => void; - annotationCount: number; - onOpenExport: () => void; - onOpenSettings: () => void; - onDownloadAnnotations: () => void; - onCopyShareLink: () => void; - onOpenImport: () => void; - onPrint: () => void; - shareUrl?: string; - sharingEnabled: boolean; - className?: string; -} - -export const MobileMenu: React.FC = ({ - isPanelOpen, - onTogglePanel, - annotationCount, - onOpenExport, - onOpenSettings, - onDownloadAnnotations, - onCopyShareLink, - onOpenImport, - onPrint, - sharingEnabled, - className, -}) => { - const { theme, setTheme } = useTheme(); - - return ( - ( - - )} - panelClassName="absolute top-full right-0 mt-1 w-56 bg-popover border border-border rounded-lg shadow-xl z-[70] py-1" - > - {({ closeMenu }) => ( - <> -
-
- Theme -
-
- {(['light', 'dark', 'system'] as const).map((t) => ( - - ))} -
-
- - - - { - closeMenu(); - onOpenSettings(); - }} - icon={} - label="Settings" - /> - - { - closeMenu(); - onTogglePanel(); - }} - icon={} - label={isPanelOpen ? 'Hide Annotations' : 'Show Annotations'} - badge={annotationCount > 0 ? ( - - {annotationCount} - - ) : undefined} - /> - - - - { - closeMenu(); - onOpenExport(); - }} - icon={} - label="Export" - /> - - { - closeMenu(); - onDownloadAnnotations(); - }} - icon={} - label="Download Annotations" - /> - - { - closeMenu(); - onPrint(); - }} - icon={} - label="Print Plan" - /> - - {sharingEnabled && ( - { - closeMenu(); - onCopyShareLink(); - }} - icon={} - label="Copy Share Link" - /> - )} - - {sharingEnabled && ( - <> - - { - closeMenu(); - onOpenImport(); - }} - icon={} - label="Import Review" - /> - - )} - - )} -
- ); -}; - -// Icons -const SettingsIcon = () => ( - - - - -); - -const AnnotationsIcon = () => ( - - - -); - -const ExportIcon = () => ( - - - -); - -const DownloadIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const ImportIcon = () => ( - - - -); - -const PrintIcon = () => ( - - - -); diff --git a/packages/ui/components/PlanHeaderMenu.tsx b/packages/ui/components/PlanHeaderMenu.tsx new file mode 100644 index 00000000..4c5e13b8 --- /dev/null +++ b/packages/ui/components/PlanHeaderMenu.tsx @@ -0,0 +1,303 @@ +import React from 'react'; +import { + ActionMenu, + ActionMenuDivider, + ActionMenuItem, + ActionMenuSectionLabel, +} from './ActionMenu'; +import { useTheme } from './ThemeProvider'; +import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons'; +import { ReviewAgentsIcon } from './ReviewAgentsIcon'; + +interface PlanHeaderMenuProps { + appVersion: string; + onOpenSettings: () => void; + onOpenExport: () => void; + onCopyAgentInstructions: () => void; + onDownloadAnnotations: () => void; + onPrint: () => void; + onCopyShareLink: () => void; + onOpenImport: () => void; + onSaveToObsidian: () => void; + onSaveToBear: () => void; + onSaveToOctarine: () => void; + sharingEnabled: boolean; + isApiMode: boolean; + agentInstructionsEnabled: boolean; + hasNewSettingsHints: boolean; + obsidianConfigured: boolean; + bearConfigured: boolean; + octarineConfigured: boolean; +} + +export const PlanHeaderMenu: React.FC = ({ + appVersion, + onOpenSettings, + onOpenExport, + onCopyAgentInstructions, + onDownloadAnnotations, + onPrint, + onCopyShareLink, + onOpenImport, + onSaveToObsidian, + onSaveToBear, + onSaveToOctarine, + sharingEnabled, + isApiMode, + agentInstructionsEnabled, + hasNewSettingsHints, + obsidianConfigured, + bearConfigured, + octarineConfigured, +}) => { + const { theme, setTheme } = useTheme(); + + const anyNotesAppConfigured = + isApiMode && (obsidianConfigured || bearConfigured || octarineConfigured); + + return ( + ( + + )} + > + {({ closeMenu }) => ( + <> +
+ Theme +
+ {(['light', 'dark', 'system'] as const).map((mode) => ( + + ))} +
+
+ + + + { + closeMenu(); + onOpenSettings(); + }} + icon={} + label="Settings" + /> + { + closeMenu(); + onOpenExport(); + }} + icon={} + label="Export" + /> + {agentInstructionsEnabled && ( + { + closeMenu(); + onCopyAgentInstructions(); + }} + icon={} + label="Agent Instructions" + subtitle="Copy agent instructions for external annotations" + /> + )} + + + + { + closeMenu(); + onDownloadAnnotations(); + }} + icon={} + label="Download Annotations" + /> + { + closeMenu(); + onPrint(); + }} + icon={} + label="Print Plan" + /> + {sharingEnabled && ( + { + closeMenu(); + onCopyShareLink(); + }} + icon={} + label="Copy Share Link" + /> + )} + {sharingEnabled && ( + { + closeMenu(); + onOpenImport(); + }} + icon={} + label="Import Review" + /> + )} + + {anyNotesAppConfigured && ( + <> + + {obsidianConfigured && ( + { + closeMenu(); + onSaveToObsidian(); + }} + icon={} + label="Save to Obsidian" + /> + )} + {bearConfigured && ( + { + closeMenu(); + onSaveToBear(); + }} + icon={} + label="Save to Bear" + /> + )} + {octarineConfigured && ( + { + closeMenu(); + onSaveToOctarine(); + }} + icon={} + label="Save to Octarine" + /> + )} + + )} + + + +
+
+ Plannotator + + v{appVersion} + +
+ +
+ + )} +
+ ); +}; + +const MenuIcon = () => ( + + + +); + +const CloseIcon = () => ( + + + +); + +const SettingsIcon = () => ( + + + + +); + +const ExportIcon = () => ( + + + +); + +const DownloadIcon = () => ( + + + +); + +const PrintIcon = () => ( + + + +); + +const LinkIcon = () => ( + + + +); + +const ImportIcon = () => ( + + + +); + +const NoteIcon = () => ( + + + +); + diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 119f1e15..bf8e4d19 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -551,7 +551,11 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const [quickLabelsState, setQuickLabelsState] = useState([]); const [editingTipIndex, setEditingTipIndex] = useState(null); const [editingTipValue, setEditingTipValue] = useState(''); - const [showNewHints, setShowNewHints] = useState(() => hasNewSettings()); + // Captured at mount so future hint markers (e.g. `{showNewHints && }`) + // stay visible for the duration of the dialog session even after + // markNewSettingsSeen() runs on open. Bump CURRENT_HINT_VERSION in + // newSettingsHint.ts when adding new highlighted settings. + const [showNewHints] = useState(() => hasNewSettings()); const [aiProvider, setAiProvider] = useState(null); const [fileBrowserSettings, setFileBrowserSettings] = useState({ enabled: false, directories: [] }); const [newDirPath, setNewDirPath] = useState(''); @@ -758,12 +762,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange - {showNewHints && !showDialog && ( - - - - - )} {showDialog && createPortal( @@ -802,9 +800,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange }`} > {tab.label} - {showNewHints && (tab.id === 'display' || tab.id === 'labels') && ( - new - )} ))} @@ -823,9 +818,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange }`} > {tab.label} - {showNewHints && (tab.id === 'display' || tab.id === 'labels') && ( - new - )} ))}
@@ -1112,7 +1104,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange {/* Plan Width */}
-
Plan Width{showNewHints && new}
+
Plan Width
Maximum width of the plan card
@@ -1368,7 +1360,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange <>
-
Quick Labels{showNewHints && new}
+
Quick Labels
Preset annotations for one-click feedback
diff --git a/packages/ui/components/ThemeTab.tsx b/packages/ui/components/ThemeTab.tsx index 4fe8023d..483a8dde 100644 --- a/packages/ui/components/ThemeTab.tsx +++ b/packages/ui/components/ThemeTab.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTheme, type Mode } from './ThemeProvider'; +import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons'; export const ThemeTab: React.FC = () => { const { mode, setMode, colorTheme, setColorTheme, availableThemes, resolvedMode } = useTheme(); @@ -22,25 +23,19 @@ export const ThemeTab: React.FC = () => { > {m === 'dark' && ( - - - + Dark )} {m === 'light' && ( - - - + Light )} {m === 'system' && ( - - - + System )} diff --git a/packages/ui/components/icons/themeIcons.tsx b/packages/ui/components/icons/themeIcons.tsx new file mode 100644 index 00000000..dad95f89 --- /dev/null +++ b/packages/ui/components/icons/themeIcons.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +/** + * Shared theme-mode icons (Sun / Moon / System). + * + * Several components render the same theme picker (PlanHeaderMenu, ThemeTab, + * historically MobileMenu / ModeToggle). They used to inline these SVGs + * independently, which meant any tweak to a glyph had to be hunted down + * across files. Centralizing them here keeps the iconography consistent. + * + * Each icon takes an optional `className` so callers control sizing — + * the dropdown segmented controls use w-3.5, the settings tab uses w-3. + */ + +interface IconProps { + className?: string; +} + +export const SunIcon: React.FC = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +export const MoonIcon: React.FC = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +export const SystemIcon: React.FC = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); diff --git a/packages/ui/utils/planAgentInstructions.ts b/packages/ui/utils/planAgentInstructions.ts new file mode 100644 index 00000000..25987c9e --- /dev/null +++ b/packages/ui/utils/planAgentInstructions.ts @@ -0,0 +1,142 @@ +/** + * Builds the clipboard payload that teaches an external agent (Claude Code, + * Codex, custom scripts, etc.) how to post annotations into a live Plannotator + * **plan-review** session via the /api/external-annotations HTTP API. + * + * Plan mode and code-review mode have different annotation shapes (plan uses + * `originalText` for inline highlighting; review uses `filePath` + line ranges + * + severity), so each mode owns its own instructions module. The + * code-review counterpart will live alongside this file when it's added. + * + * The body is intentionally short (~110 lines of markdown) so an agent can read + * it top-to-bottom and start posting in 30 seconds. Edit freely — this file is + * the single source of truth for the agent-facing contract surface. + * + * The only dynamic value is `origin`, which is interpolated at click time from + * `window.location.origin` so the agent gets the correct base URL whether the + * server is running on a random local port or the fixed remote port (19432). + */ +export function buildPlanAgentInstructions(origin: string): string { + return `# Plannotator — External Annotations + +You can submit review feedback on the user's current plan-review session by POSTing annotations to a small HTTP API. The user will see them immediately — inline highlights on the plan and entries in a sidebar — and can accept, edit, or delete them. + +This is one-way submission. Any tool can post: linters, agents, scripts. The user does not see who you are unless you tell them via \`text\` or \`author\`. + +## Base URL +${origin} + +All endpoints below are relative to that base. No authentication. + +## Workflow +1. Read the plan so you know what to comment on. +2. POST your annotations (single or batch). +3. Optionally clean up your previous annotations before reposting on a re-run. + +There is no "send" or "done" step — each POST is live the moment it lands. + +## Reading the plan + +\`\`\`sh +curl -s ${origin}/api/plan | jq -r .plan +\`\`\` + +**Line numbers do not apply and cannot be referenced.** The renderer pins your comments to the plan by matching the \`originalText\` field as a verbatim substring of the rendered text. Quote the exact phrase, never say "line 12." + +## Two kinds of comment + +You have exactly two shapes to choose from: + +- **Inline comment** — pinned to a specific phrase in the plan. The matched phrase gets a yellow highlight in the rendered plan and the comment appears in the sidebar. Use this for feedback about a particular sentence, step, or block. +- **Global comment** — not tied to any phrase. Sidebar entry only. Use this for high-level feedback like "this plan is missing a rollback section" or "the ordering of steps 3 and 4 should be swapped." + +## Posting an inline comment + +\`\`\`sh +curl -s ${origin}/api/external-annotations \\ + -H 'Content-Type: application/json' \\ + -d '{ + "source": "claude-code", + "type": "COMMENT", + "text": "This step needs error handling.", + "originalText": "open the file and parse it" + }' +\`\`\` + +\`originalText\` must be a verbatim substring of the plan body. Pick something unique enough that it appears once — longer is safer than shorter. If the substring doesn't match anything in the rendered plan, the comment silently falls back to sidebar-only. + +## Posting a global comment + +\`\`\`sh +curl -s ${origin}/api/external-annotations \\ + -H 'Content-Type: application/json' \\ + -d '{ + "source": "claude-code", + "type": "GLOBAL_COMMENT", + "text": "Missing a rollback section. Steps 3 and 4 should also be swapped." + }' +\`\`\` + +Both endpoints return \`201 {"ids": [""]}\` on success, \`400 {"error": "..."}\` on validation failure. + +### Fields + +| Field | Required | Notes | +|---|---|---| +| \`source\` | yes | Stable identifier for *you* (e.g. \`"claude-code"\`, \`"codex"\`, \`"my-linter"\`). Reuse the same value for every annotation you post — it lets you clean up your own later. Pick something specific enough that it won't collide with other tools running against the same session. | +| \`text\` | yes | The comment body the user will read. | +| \`type\` | yes | \`"COMMENT"\` for inline, \`"GLOBAL_COMMENT"\` for sidebar-only. | +| \`originalText\` | for \`COMMENT\` | A verbatim substring of the plan body. Required when \`type\` is \`"COMMENT"\`. Omit for \`"GLOBAL_COMMENT"\`. | +| \`author\` | no | Human-readable label shown next to the comment (e.g. \`"Claude Opus"\`). | + +## Batching + +\`\`\`sh +curl -s ${origin}/api/external-annotations \\ + -H 'Content-Type: application/json' \\ + -d '{ + "annotations": [ + {"source": "claude-code", "type": "COMMENT", "text": "Missing error case.", "originalText": "open the file"}, + {"source": "claude-code", "type": "COMMENT", "text": "This assumes the cache is warm — flag it.", "originalText": "look up the user in the cache"}, + {"source": "claude-code", "type": "GLOBAL_COMMENT", "text": "Overall structure looks good. Add a rollback section."} + ] + }' +\`\`\` + +Batches are atomic: if any item fails validation, the whole batch is rejected with an error like \`annotations[2] missing required "text" field\`. + +## Listing and deleting + +\`\`\`sh +# List everything (yours and others') +curl -s ${origin}/api/external-annotations | jq + +# Delete one annotation by id — works on any source, including the user's +curl -s -X DELETE "${origin}/api/external-annotations?id=" + +# Delete all annotations from one source — the standard cleanup before reposting +curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" + +# Delete everything in the session +curl -s -X DELETE ${origin}/api/external-annotations +\`\`\` + +You have full delete authority. Use it responsibly. + +## Cleaning up on a re-run + +If you re-run on the same session, your previous annotations are still there. POSTing again will create duplicates. Standard pattern: + +\`\`\`sh +curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" +curl -s ${origin}/api/external-annotations -H 'Content-Type: application/json' -d '{ ...fresh annotations... }' +\`\`\` + +This is why \`source\` matters. Pick a stable identifier and stick with it. + +## Notes +- The plan can change underneath you. If the user denies and resubmits, refetch \`/api/plan\` — your prior \`originalText\` substrings may no longer match. +- No idempotency. Posting the same annotation twice creates two entries. +- This API is local to the user's machine. Treat it as a UI surface, not a public service. +`; +}