feat(plan): unified header dropdown + Agent Instructions (#515)

* 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 <div className="hidden"> 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.
This commit is contained in:
Michael Ramos
2026-04-07 15:03:30 -07:00
committed by GitHub
parent 57495ec816
commit 7e8f914904
11 changed files with 603 additions and 428 deletions
+2 -1
View File
@@ -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)
+78 -186
View File
@@ -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<EditorMode>(getEditorMode);
const [inputMethod, setInputMethod] = useState<InputMethod>(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<ToastPayload>(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 = () => {
>
<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 ${getAgentBadge(origin)}`}>
{agentName}
</span>
)}
</div>
<div className="flex items-center gap-1 md:gap-2">
@@ -1290,7 +1287,7 @@ const App: React.FC = () => {
</>
)}
{isApiMode && !linkedDocHook.isActive && !archive.archiveMode && (
{isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && (
<>
<FeedbackButton
onClick={() => {
@@ -1348,165 +1345,60 @@ const App: React.FC = () => {
</>
)}
{/* Desktop buttons — hidden on mobile */}
<div className="hidden md:flex items-center gap-2">
<ModeToggle />
{!linkedDocHook.isActive && <Settings taterMode={taterMode} onTaterModeChange={handleTaterModeChange} onIdentityChange={handleIdentityChange} origin={origin} onUIPreferencesChange={setUiPrefs} externalOpen={mobileSettingsOpen} onExternalClose={() => setMobileSettingsOpen(false)} gitUser={gitUser} />}
{/* Annotations panel toggle — top-level header button */}
<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'
}`}
title={isPanelOpen ? 'Hide annotations' : 'Show annotations'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
</button>
<button
onClick={() => 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="px-2.5 py-1 rounded-l-md text-xs font-medium bg-muted hover:bg-muted/80 transition-colors"
title="Export"
>
Export
</button>
<button
onClick={() => setShowExportDropdown(prev => !prev)}
className="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>
<button
onClick={() => { setShowExportDropdown(false); window.print(); }}
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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Print Plan
<span className="ml-auto text-[10px] text-muted-foreground/60">{modKey}+P</span>
</button>
<div className="my-1 border-t border-border" />
{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 && isOctarineConfigured() && (
<button
onClick={() => handleQuickSaveToNotes('octarine')}
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 Octarine
</button>
)}
{isApiMode && !isObsidianConfigured() && !getBearSettings().enabled && !isOctarineConfigured() && (
<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="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
</svg>
Import Review
</button>
</>
)}
</div>
)}
</div>
{/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */}
<div className="hidden">
<Settings
taterMode={taterMode}
onTaterModeChange={handleTaterModeChange}
onIdentityChange={handleIdentityChange}
origin={origin}
onUIPreferencesChange={setUiPrefs}
externalOpen={mobileSettingsOpen}
onExternalClose={() => setMobileSettingsOpen(false)}
gitUser={gitUser}
/>
</div>
{/* Mobile hamburger menu */}
<MobileMenu
className="md:hidden"
isPanelOpen={isPanelOpen}
onTogglePanel={() => 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' });
<PlanHeaderMenu
appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'}
hasNewSettingsHints={hasNewSettingsHints}
onOpenSettings={() => {
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()}
/>
</div>
</header>
-5
View File
@@ -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); }
}
+10
View File
@@ -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",
+10 -1
View File
@@ -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<ActionMenuItemProps> = ({
onClick,
icon,
label,
subtitle,
badge,
}) => (
<button
@@ -76,7 +78,14 @@ export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<span className="text-muted-foreground">{icon}</span>
<span className="flex-1">{label}</span>
{subtitle ? (
<span className="flex flex-1 flex-col gap-0.5">
<span>{label}</span>
<span className="text-[10px] text-muted-foreground">{subtitle}</span>
</span>
) : (
<span className="flex-1">{label}</span>
)}
{badge}
</button>
);
-211
View File
@@ -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<MobileMenuProps> = ({
isPanelOpen,
onTogglePanel,
annotationCount,
onOpenExport,
onOpenSettings,
onDownloadAnnotations,
onCopyShareLink,
onOpenImport,
onPrint,
sharingEnabled,
className,
}) => {
const { theme, setTheme } = useTheme();
return (
<ActionMenu
className={className}
renderTrigger={({ isOpen, toggleMenu }) => (
<button
onClick={toggleMenu}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Menu"
>
{isOpen ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
)}
panelClassName="absolute top-full right-0 mt-1 w-56 bg-popover border border-border rounded-lg shadow-xl z-[70] py-1"
>
{({ closeMenu }) => (
<>
<div className="px-3 py-2">
<div className="mb-1.5">
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
</div>
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
{(['light', 'dark', 'system'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`flex-1 px-2 py-1 text-[10px] capitalize rounded-md transition-colors ${
theme === t
? 'bg-background text-foreground shadow-sm font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t}
</button>
))}
</div>
</div>
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenSettings();
}}
icon={<SettingsIcon />}
label="Settings"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onTogglePanel();
}}
icon={<AnnotationsIcon />}
label={isPanelOpen ? 'Hide Annotations' : 'Show Annotations'}
badge={annotationCount > 0 ? (
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{annotationCount}
</span>
) : undefined}
/>
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenExport();
}}
icon={<ExportIcon />}
label="Export"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onDownloadAnnotations();
}}
icon={<DownloadIcon />}
label="Download Annotations"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onPrint();
}}
icon={<PrintIcon />}
label="Print Plan"
/>
{sharingEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onCopyShareLink();
}}
icon={<LinkIcon />}
label="Copy Share Link"
/>
)}
{sharingEnabled && (
<>
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenImport();
}}
icon={<ImportIcon />}
label="Import Review"
/>
</>
)}
</>
)}
</ActionMenu>
);
};
// Icons
const SettingsIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const AnnotationsIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const ExportIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const DownloadIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const LinkIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const ImportIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
</svg>
);
const PrintIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
);
+303
View File
@@ -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<PlanHeaderMenuProps> = ({
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 (
<ActionMenu
renderTrigger={({ isOpen, toggleMenu }) => (
<button
onClick={toggleMenu}
className={`relative flex items-center gap-1.5 p-1.5 md:px-2.5 md:py-1 rounded-md text-xs font-medium transition-colors ${
isOpen
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title="Options"
aria-label="Options"
aria-expanded={isOpen}
>
{isOpen ? <CloseIcon /> : <MenuIcon />}
<span className="hidden md:inline">Options</span>
{hasNewSettingsHints && !isOpen && (
<span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
<span className="absolute inset-0 rounded-full bg-primary opacity-75 animate-ping" />
<span className="relative rounded-full h-2 w-2 bg-primary" />
</span>
)}
</button>
)}
>
{({ closeMenu }) => (
<>
<div className="px-3 py-2 space-y-1.5">
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
{(['light', 'dark', 'system'] as const).map((mode) => (
<button
key={mode}
onClick={() => {
closeMenu();
setTheme(mode);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
theme === mode
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{mode === 'light' ? <SunIcon /> : mode === 'dark' ? <MoonIcon /> : <SystemIcon />}
<span className="capitalize">{mode}</span>
</button>
))}
</div>
</div>
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenSettings();
}}
icon={<SettingsIcon />}
label="Settings"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenExport();
}}
icon={<ExportIcon />}
label="Export"
/>
{agentInstructionsEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onCopyAgentInstructions();
}}
icon={<ReviewAgentsIcon />}
label="Agent Instructions"
subtitle="Copy agent instructions for external annotations"
/>
)}
<ActionMenuDivider />
<ActionMenuItem
onClick={() => {
closeMenu();
onDownloadAnnotations();
}}
icon={<DownloadIcon />}
label="Download Annotations"
/>
<ActionMenuItem
onClick={() => {
closeMenu();
onPrint();
}}
icon={<PrintIcon />}
label="Print Plan"
/>
{sharingEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onCopyShareLink();
}}
icon={<LinkIcon />}
label="Copy Share Link"
/>
)}
{sharingEnabled && (
<ActionMenuItem
onClick={() => {
closeMenu();
onOpenImport();
}}
icon={<ImportIcon />}
label="Import Review"
/>
)}
{anyNotesAppConfigured && (
<>
<ActionMenuDivider />
{obsidianConfigured && (
<ActionMenuItem
onClick={() => {
closeMenu();
onSaveToObsidian();
}}
icon={<NoteIcon />}
label="Save to Obsidian"
/>
)}
{bearConfigured && (
<ActionMenuItem
onClick={() => {
closeMenu();
onSaveToBear();
}}
icon={<NoteIcon />}
label="Save to Bear"
/>
)}
{octarineConfigured && (
<ActionMenuItem
onClick={() => {
closeMenu();
onSaveToOctarine();
}}
icon={<NoteIcon />}
label="Save to Octarine"
/>
)}
</>
)}
<ActionMenuDivider />
<div className="px-3 py-2 space-y-2">
<div className="flex items-center justify-between gap-3">
<ActionMenuSectionLabel>Plannotator</ActionMenuSectionLabel>
<span className="text-[10px] font-mono text-muted-foreground/70">
v{appVersion}
</span>
</div>
<div className="flex flex-col items-start gap-1 text-[11px]">
<a
href="https://github.com/backnotprop/plannotator/releases"
target="_blank"
rel="noopener noreferrer"
onClick={closeMenu}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Release notes
</a>
<a
href="https://github.com/backnotprop/plannotator"
target="_blank"
rel="noopener noreferrer"
onClick={closeMenu}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Project repo
</a>
</div>
</div>
</>
)}
</ActionMenu>
);
};
const MenuIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const SettingsIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const ExportIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const DownloadIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const PrintIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
);
const LinkIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
const ImportIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
</svg>
);
const NoteIcon = () => (
<svg className="w-3.5 h-3.5" 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>
);
+7 -15
View File
@@ -551,7 +551,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
const [editingTipIndex, setEditingTipIndex] = useState<number | null>(null);
const [editingTipValue, setEditingTipValue] = useState('');
const [showNewHints, setShowNewHints] = useState(() => hasNewSettings());
// Captured at mount so future hint markers (e.g. `{showNewHints && <Badge>}`)
// 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<string | null>(null);
const [fileBrowserSettings, setFileBrowserSettings] = useState<FileBrowserSettings>({ enabled: false, directories: [] });
const [newDirPath, setNewDirPath] = useState('');
@@ -758,12 +762,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{showNewHints && !showDialog && (
<span className="absolute top-0 right-0 flex h-2 w-2">
<span className="absolute inset-0 rounded-full bg-primary opacity-60 animate-[settings-ping_2s_cubic-bezier(0,0,0.2,1)_infinite]" />
<span className="relative rounded-full h-2 w-2 bg-primary" />
</span>
)}
</button>
{showDialog && createPortal(
@@ -802,9 +800,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
}`}
>
{tab.label}
{showNewHints && (tab.id === 'display' || tab.id === 'labels') && (
<span className="text-[8px] font-semibold uppercase tracking-wide px-1 py-px rounded-full bg-primary/15 text-primary leading-none">new</span>
)}
</button>
))}
</nav>
@@ -823,9 +818,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
}`}
>
{tab.label}
{showNewHints && (tab.id === 'display' || tab.id === 'labels') && (
<span className="text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-primary/15 text-primary leading-none">new</span>
)}
</button>
))}
</div>
@@ -1112,7 +1104,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
{/* Plan Width */}
<div className="space-y-3">
<div>
<div className="text-sm font-medium flex items-center gap-2">Plan Width{showNewHints && <span className="text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-primary/15 text-primary leading-none">new</span>}</div>
<div className="text-sm font-medium flex items-center gap-2">Plan Width</div>
<div className="text-xs text-muted-foreground">
Maximum width of the plan card
</div>
@@ -1368,7 +1360,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
<>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium flex items-center gap-2">Quick Labels{showNewHints && <span className="text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-full bg-primary/15 text-primary leading-none">new</span>}</div>
<div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
<div className="text-xs text-muted-foreground">
Preset annotations for one-click feedback
</div>
+4 -9
View File
@@ -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' && (
<span className="flex items-center gap-1.5">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<MoonIcon className="w-3 h-3" />
Dark
</span>
)}
{m === 'light' && (
<span className="flex items-center gap-1.5">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
<SunIcon className="w-3 h-3" />
Light
</span>
)}
{m === 'system' && (
<span className="flex items-center gap-1.5">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
<SystemIcon className="w-3 h-3" />
System
</span>
)}
@@ -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<IconProps> = ({ className = 'w-3.5 h-3.5' }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
export const MoonIcon: React.FC<IconProps> = ({ className = 'w-3.5 h-3.5' }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
export const SystemIcon: React.FC<IconProps> = ({ className = 'w-3.5 h-3.5' }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</svg>
);
+142
View File
@@ -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": ["<uuid>"]}\` 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=<uuid>"
# 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.
`;
}