mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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.
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user