From 7e8f914904f0d01ccd90e079ac70c099b0a8d101 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 7 Apr 2026 15:03:30 -0700 Subject: [PATCH] feat(plan): unified header dropdown + Agent Instructions (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plan): unify header settings into combined dropdown Replace the desktop sprawl (ModeToggle, Settings button, Export sub-dropdown) and the separate MobileMenu with a single PlanHeaderMenu modeled on ReviewHeaderMenu — one ActionMenu rendered at all breakpoints. Move version + release notes into the dropdown footer, drop the origin/agent badge, and stub an "Agent Instructions" item that copies the current plan plus an /api/external-annotations URL for external agents (real protocol body lands in a follow-up). Also fix the annotate-folder Send button: it was hidden whenever a file was opened via linkedDoc, so users in `plannotator annotate ./` couldn't submit feedback. Gate now allows linked-doc state through in annotate mode. Settings is always mounted instead of skipping when a linked doc is active, so the dropdown's Settings item works in every state. For provenance purposes, this commit was AI assisted. * feat(plan): real Agent Instructions clipboard payload Replace the placeholder string in handleCopyAgentInstructions with a real, contract-accurate markdown payload that teaches an external agent (Claude Code, Codex, custom scripts) how to: - read the current plan via GET /api/plan - POST single or batch annotations to /api/external-annotations - choose COMMENT vs DELETION vs GLOBAL_COMMENT and use originalText for inline highlighting (or skip it for sidebar-only) - list, delete-by-id, and delete-by-source for cleanup before reposting The payload lives in packages/ui/utils/planAgentInstructions.ts as buildPlanAgentInstructions(origin) — a single pure function that takes the base URL and returns the markdown. Plan and code-review modes have different annotation shapes, so each owns its own instructions module; the review counterpart will live alongside this file when it's added. For provenance purposes, this commit was AI assisted. * docs: list planAgentInstructions.ts in AGENTS.md utils enumeration For provenance purposes, this commit was AI assisted. * fix(plan): restore 'system' theme option in header dropdown The unified header dropdown only exposed light/dark, dropping the 'system' (follow OS) option that the previous ModeToggle and MobileMenu both supported. Clicking either explicit mode would silently overwrite a prior 'system' setting. Add it back as a third segmented control, matching the old MobileMenu's three-way layout. For provenance purposes, this commit was AI assisted. * refactor(ui): extract shared theme-mode icons PlanHeaderMenu and ThemeTab each inlined their own Sun/Moon/System SVGs (and the deleted MobileMenu had its own copy too). Pull them into a shared icons/themeIcons.tsx module so the iconography stays consistent across the dropdown and the settings tab. Use the cleaner Heroicons paths from ThemeTab as the canonical art. ReviewHeaderMenu still inlines its own copies — left alone for this PR since the broader review/plan menu unification is a separate follow-up. For provenance purposes, this commit was AI assisted. * fix(plan): use ReviewAgentsIcon for Agent Instructions menu item Reuse the magnifying-glass-with-cog icon already used for the Review Agents tab so the same visual marker identifies agent-related affordances across plan and review modes. For provenance purposes, this commit was AI assisted. * fix(plan): drop DELETION + unanchored COMMENT from agent instructions Plannotator's existing render and export pipeline only handles two shapes well: an anchored COMMENT with originalText, and a GLOBAL_COMMENT with no anchor. The previous instructions also documented: - COMMENT without originalText — sidebar shows an empty quote bubble ("") and the export header reads `Feedback on: ""`. The text survives but the framing is broken. - DELETION with explanatory text — the text is invisible in the sidebar and silently dropped on export, replaced by a generic template. Real information loss. Rather than expand the export pipeline to honor those edge cases, narrow the documented surface to the two shapes that work today: inline COMMENT (requires originalText) and GLOBAL_COMMENT (no anchor). DELETION is omitted entirely; agents that need to suggest a removal can post an inline COMMENT pointing at the phrase. For provenance purposes, this commit was AI assisted. * fix(plan): clear current new-settings hints, surface pulse on menu trigger The new-settings indicator infrastructure (hasNewSettings / version constant / markNewSettingsSeen) stays — it's the scaffolding for future hint flagging — but every current marker is removed: - Drop the four inline 'new' badges in Settings.tsx (Plan Width row, Quick Labels row, mobile + desktop tab navigators). - Drop the dead pulse on the standalone gear button — that button now lives inside
after the header refactor, so the pulse was unreachable anyway. Surface the pulse on the new PlanHeaderMenu trigger instead. App.tsx seeds local hasNewSettingsHints state from hasNewSettings(), passes it to the menu, and clears it (plus calls markNewSettingsSeen) when the user opens Settings from the dropdown. For provenance purposes, this commit was AI assisted. * docs: list icons/ subdirectory in AGENTS.md components tree For provenance purposes, this commit was AI assisted. * fix(server): require originalText for plan COMMENT annotations The plan-mode external-annotation contract documents that COMMENT annotations must carry an originalText anchor; sidebar-only feedback uses GLOBAL_COMMENT instead. transformPlanInput already enforced this for DELETION but silently defaulted COMMENT's originalText to "" when missing — producing a broken render (empty quote bubble in the sidebar, exported as `Feedback on: ""`). Reject COMMENT without a non-empty originalText and steer the caller toward GLOBAL_COMMENT in the error message so the fix is self-evident to misbehaving agents. For provenance purposes, this commit was AI assisted. * chore: drop dead new-hints scaffolding leftovers After clearing the four current inline hint markers, two pieces of support code were unreachable: - The destructured setShowNewHints setter in Settings.tsx — the state itself stays as a stable mount-time snapshot for any future hint markers, but the setter has no callers. Drop it and add a comment explaining why the useState wrapper is intentional scaffolding. - The settings-ping keyframe in editor/index.css — only the deleted gear-button pulse referenced it; the new pulse on PlanHeaderMenu's trigger uses Tailwind's built-in animate-ping instead. For provenance purposes, this commit was AI assisted. --- AGENTS.md | 3 +- packages/editor/App.tsx | 264 +++++------------ packages/editor/index.css | 5 - packages/shared/external-annotation.ts | 10 + packages/ui/components/ActionMenu.tsx | 11 +- packages/ui/components/MobileMenu.tsx | 211 -------------- packages/ui/components/PlanHeaderMenu.tsx | 303 ++++++++++++++++++++ packages/ui/components/Settings.tsx | 22 +- packages/ui/components/ThemeTab.tsx | 13 +- packages/ui/components/icons/themeIcons.tsx | 47 +++ packages/ui/utils/planAgentInstructions.ts | 142 +++++++++ 11 files changed, 603 insertions(+), 428 deletions(-) delete mode 100644 packages/ui/components/MobileMenu.tsx create mode 100644 packages/ui/components/PlanHeaderMenu.tsx create mode 100644 packages/ui/components/icons/themeIcons.tsx create mode 100644 packages/ui/utils/planAgentInstructions.ts 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. +`; +}